Repository navigation
Expand file tree
/
Copy pathlaunch.sh
More file actions
executable file
·1776 lines (1663 loc) · 71.1 KB
/
Copy pathlaunch.sh
File metadata and controls
executable file
·1776 lines (1663 loc) · 71.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
set -euo pipefail
# Create bare repos, build image, launch N agent containers.
# Usage: ./launch.sh {start|stop|logs N|status|wait|post-process|interactive}
SWARM_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
cat <<HELP
Usage: $0 [COMMAND] [OPTIONS]
Orchestrate coding agents in Docker containers.
Default command is 'start' when none is specified.
Commands:
start [OPTIONS] Build image, create bare repo, launch agents.
stop Stop all running agent containers.
logs N Tail logs for agent N (default: 1).
status Show running/stopped state for each agent.
wait Wait for already-started numbered agents,
then post-process and harvest. Does not
start agents.
post-process Run only the post-processing agent, then
harvest.
interactive PROFILE Start an interactive driver session from a
named agent profile.
chat PROFILE Alias for interactive PROFILE.
shell PROFILE Start an interactive shell from a named
agent profile.
retire [--post-process] [--discard-uncommitted]
Remove stopped containers whose files all live
in their workspace volumes, after copying the
Git checkouts they have outside them into the
checkout.
cleanup [--discard-uncommitted]
Delete the engagement (containers, workspace
volumes, bare repo, mirrors) after copying what
Git keeps there into the checkout.
Start options:
--dashboard Open the TUI dashboard after launch.
Interactive options:
--agent NAME Select agents[].name explicitly.
--agent-index N Select the Nth agents[] entry.
--shell Open a shell instead of the driver UI.
--chat Open the driver UI (default).
Environment:
ANTHROPIC_API_KEY API key (required unless OAuth).
CLAUDE_CODE_OAUTH_TOKEN OAuth token for subscription auth.
SWARM_CONFIG Path to swarmfile (or place swarm.json in repo root).
SWARM_TITLE Dashboard title override.
SWARM_SKIP_DEP_CHECK Set to 1 to silence version warnings.
SWARM_RUNTIME_DIR Runtime directory (bare repo, mirrors, state);
default ~/.local/state/swarm-core/<project>.
SWARM_PROJECT Project id for the runtime directory, image
and containers (default: the repo name).
SWARM_ENGAGEMENT_ID Engagement id for a new bare repo, or the one
later commands expect (default: generated).
SWARM_LOCK_FD Descriptor of an engagement lock the caller
already holds.
HELP
exit 0
fi
source "$SWARM_DIR/lib/check-deps.sh"
check_deps git jq docker
# shellcheck source=lib/project.sh
source "$SWARM_DIR/lib/project.sh"
# shellcheck source=lib/preserve.sh
source "$SWARM_DIR/lib/preserve.sh"
# retire and cleanup read and write Git data: none of the caller's GIT_*
# variables may point them at another repository or inject settings
# (only the choice of config files stays). Before the repository is
# looked up below.
case "${1:-}" in
retire|cleanup)
for _v in $(env | sed -n 's/^\(GIT_[A-Za-z0-9_]*\)=.*/\1/p'); do
case "$_v" in
GIT_CONFIG_GLOBAL|GIT_CONFIG_SYSTEM|GIT_CONFIG_NOSYSTEM) ;;
*) unset "$_v" ;;
esac
done ;;
esac
REPO_ROOT="$(git rev-parse --show-toplevel)"
PROJECT_RAW="$(basename "$REPO_ROOT")"
PROJECT="$(swarm_project_resolve "$REPO_ROOT")" || exit 1
SWARM_RUN_HASH="$(git -C "$REPO_ROOT" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown")"
SWARM_RUN_BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")"
SWARM_RUN_CONTEXT="${PROJECT_RAW}@${SWARM_RUN_HASH} (${SWARM_RUN_BRANCH})"
RUNTIME_DIR="$(swarm_runtime_dir "$PROJECT")" || exit 1
BARE_REPO="${RUNTIME_DIR}/upstream.git"
MIRROR_DIR="${RUNTIME_DIR}/mirrors"
STATE_FILE="${RUNTIME_DIR}/state.json"
IMAGE_NAME="${PROJECT}-agent"
# Every command acts on this project's containers or runtime directory,
# so none runs when the directory belongs to another checkout.
swarm_runtime_verify "$RUNTIME_DIR" "$REPO_ROOT" "$PROJECT" || exit 1
# Every Docker call of this run, from this first listing on, goes to one
# daemon, even if another terminal switches the context meanwhile.
if ! swarm_docker_pin; then
echo "ERROR: cannot read the current Docker context." >&2
exit 1
fi
swarm_containers_verify "$PROJECT" "$REPO_ROOT" || exit 1
CHECKOUT_PATH="$(swarm_checkout_path "$REPO_ROOT")"
CHECKOUT_LABEL="${SWARM_CHECKOUT_LABEL}=${CHECKOUT_PATH}"
PROJECT_LABEL="${SWARM_PROJECT_LABEL}=${PROJECT}"
swarm_engagement_env_check || exit 1
# Expand a single $VAR reference from the host environment.
# Supports "$VAR" (entire value is a reference) only -- not inline
# interpolation. Returns the original string if no match.
expand_env_ref() {
local val="$1"
if [[ "$val" =~ ^\$([A-Za-z_][A-Za-z_0-9]*)$ ]]; then
local varname="${BASH_REMATCH[1]}"
printf '%s' "${!varname:-}"
else
printf '%s' "$val"
fi
}
# Compute the comma-separated SWARM_AGENTS build-arg from a config:
# the union of every agent group's driver and, when post_process is
# present and not null, the post-process driver.
# Used as a Dockerfile build-arg to gate per-CLI install layers; missing
# a driver here is what produces "command not found" inside the agent.
compute_swarm_agents() {
local cfg="$1"
local default_drv seen=" " out="" drv
default_drv=$(jq -r '.driver // "claude-code"' "$cfg")
while IFS= read -r drv; do
[ -z "$drv" ] && drv="$default_drv"
[[ "$seen" == *" $drv "* ]] && continue
seen+="$drv "
out="${out:+${out},}${drv}"
done < <(jq -r '.agents[]? | (.driver // "")' "$cfg")
local pp_drv
pp_drv=$(jq -r '
if .post_process == null then ""
else (.post_process.driver // .driver // "claude-code")
end' "$cfg")
if [ -n "$pp_drv" ] && [[ "$seen" != *" $pp_drv "* ]]; then
out="${out:+${out},}${pp_drv}"
fi
printf '%s' "$out"
}
# Build (or rebuild) the agent image with build-args derived from the current
# config. Docker's layer cache makes this a no-op when the args and Dockerfile
# haven't changed; when the driver set or pinned CLI versions change the cache
# invalidates correctly and the right install layer re-runs. Called by
# cmd_start *and* cmd_post_process so a standalone post-process invocation
# never reuses an image built for a different driver set (which silently
# produces exit-127 on first session -- see harness's `agent exited with code
# 127` retry path).
build_image() {
local swarm_agents cc_version codex_version pi_version
swarm_agents=$(compute_swarm_agents "$CONFIG_FILE")
cc_version=$(jq -r '.claude_code_version // empty' "$CONFIG_FILE" 2>/dev/null || true)
codex_version=$(jq -r '.codex_cli_version // empty' "$CONFIG_FILE" 2>/dev/null || true)
pi_version=$(jq -r '.pi_version // empty' "$CONFIG_FILE" \
2>/dev/null || true)
echo "--- Building agent image (agents: ${swarm_agents}) ---"
docker build -t "$IMAGE_NAME" \
--build-arg "SWARM_AGENTS=${swarm_agents}" \
${cc_version:+--build-arg "CLAUDE_CODE_VERSION=${cc_version}"} \
${codex_version:+--build-arg "CODEX_CLI_VERSION=${codex_version}"} \
${pi_version:+--build-arg "PI_VERSION=${pi_version}"} \
-f "$SWARM_DIR/Dockerfile" "$SWARM_DIR"
}
# Print the state of this checkout's container NAME: its Docker status,
# "not found" when there is none, or "not ours" when a container of
# another checkout or project holds the name.
own_container_state() {
local id
if ! id=$(swarm_own_container_id "$1" "$CHECKOUT_PATH" "$PROJECT" \
2>/dev/null); then
printf 'not ours'
return 0
fi
if [ -z "$id" ]; then
printf 'not found'
return 0
fi
# A created container whose start failed reads "failed": nothing
# runs, and retire takes it.
if swarm_start_failed "$id"; then
printf 'failed'
return 0
fi
docker inspect -f '{{.State.Status}}' "$id" 2>/dev/null \
|| printf 'not found'
}
# Lifecycle commands call this before they touch the bare repo or the
# mirrors: stop on state that older releases left in /tmp (named after
# the directory, as they did), create or check the private runtime
# directory, take its engagement lock, and record this checkout as its
# owner. The lock stays held until the command exits, except that
# interactive releases it once its session is recorded, before docker
# run, and start --dashboard before the dashboard opens; children such
# as harvest.sh inherit it.
prepare_runtime() {
swarm_refuse_legacy_state "$(swarm_project_id "$PROJECT_RAW")" \
|| exit 1
# Only where cleanup can work later; checked before anything is made.
swarm_runtime_location_ok "$RUNTIME_DIR" "$REPO_ROOT" || exit 1
swarm_runtime_init "$RUNTIME_DIR" || exit 1
swarm_lock "$RUNTIME_DIR" || exit 1
swarm_cleanup_pending_check "$STATE_FILE" || exit 1
swarm_legacy_refuse "$RUNTIME_DIR" "$STATE_FILE" || exit 1
swarm_engagement_token_check "$STATE_FILE" || exit 1
swarm_runtime_claim "$RUNTIME_DIR" "$REPO_ROOT" "$PROJECT" || exit 1
}
# Stop unless the runtime directory holds nothing of an engagement: no
# bare repo, no upstream.git.new, no mirrors/, no remnant of a cleanup,
# no container or workspace volume of this checkout, no interactive
# session open or starting. A new engagement starts only there, after
# ./launch.sh cleanup has preserved and removed the last one. Called
# under the lock and the pin, by start, and by post-process and
# interactive when they would create the engagement.
require_clean_runtime() {
local left live engaged rc=0 tmp
left=$(swarm_runtime_leftovers "$RUNTIME_DIR" "$PROJECT" "$REPO_ROOT" \
"$CHECKOUT_PATH") || exit 1
live=$(swarm_pending_live "$RUNTIME_DIR") || exit 1
if [ -n "$live" ]; then
left="${left:+${left}$'\n'} interactive sessions: $(printf '%s\n' \
"$live" | tr '\n' ' ')"
fi
[ -n "$left" ] || return 0
engaged=$(swarm_engagement_recorded "$STATE_FILE") || exit 1
if [ -z "$engaged" ]; then
swarm_legacy_explain "$RUNTIME_DIR" \
"what is left of an engagement it never recorded"
printf '%s\n' "$left" >&2
exit 1
fi
echo "ERROR: a new engagement starts only on a clean runtime" \
"directory; ${RUNTIME_DIR} still holds engagement" \
"${engaged}:" >&2
printf '%s\n' "$left" >&2
if [ -d "$BARE_REPO" ] && swarm_tmp_file tmp e3; then
swarm_preservation_check "$BARE_REPO" "$REPO_ROOT" > "$tmp" || rc=$?
if [ "$rc" -eq 1 ]; then
swarm_preservation_explain "$BARE_REPO" < "$tmp"
fi
rm -f "$tmp"
fi
echo "Stop the agents (./launch.sh stop), run harvest.sh, then" \
"./launch.sh cleanup: it keeps everything Git had there as" \
"recovery refs before it deletes anything." >&2
exit 1
}
# Record the run in the state file, so a standalone dashboard can show
# its title, swarmfile and agent summary. The file is JSON that
# readers parse with jq; nothing sources it.
write_state_file() {
local model_summary config_title
model_summary=$(jq -r \
'(.prompt // "") as $dp | ($dp | split("/") | .[-1] // "" | rtrimstr(".md")) as $dp_stem |
[.agents[] | (.count // 0) as $count | select($count > 0) |
"\($count)x \(.model | split("/") | .[-1])" +
(if .context == "none" then " ctx:bare"
elif .context == "slim" then " ctx:slim"
else "" end) +
(if .prompt and .prompt != $dp then
":" + (.prompt | split("/") | .[-1] | rtrimstr(".md") |
if startswith($dp_stem + "-") then .[$dp_stem | length + 1:] else . end)
else "" end)] | join(", ")' \
"$CONFIG_FILE")
config_title=$(jq -r '.title // empty' "$CONFIG_FILE" 2>/dev/null || true)
# prepare_runtime has recorded the owner; keep it.
swarm_state_update "$STATE_FILE" \
'.title = $title | .config = $config
| .num_agents = $num_agents | .model_summary = $model_summary
| .config_label = $config_label' \
--arg title "${SWARM_TITLE:-${config_title}}" \
--arg config "$CONFIG_FILE" \
--argjson num_agents "$NUM_AGENTS" \
--arg model_summary "$model_summary" \
--arg config_label "$(basename "$CONFIG_FILE")"
}
create_bare_repo() {
local label="${1:-bare repo}" base daemon new="${BARE_REPO}.new"
echo "--- Creating ${label} ---"
# The engagement starts from the commit the checkout has now.
base=$(git -C "$REPO_ROOT" rev-parse --verify --quiet 'HEAD^{commit}') \
|| { echo "ERROR: ${REPO_ROOT} has no commit to start from." >&2
exit 1; }
# The clean-runtime check came first; nothing is ever replaced.
if [ -e "$BARE_REPO" ] || [ -L "$BARE_REPO" ] \
|| [ -e "$new" ] || [ -L "$new" ]; then
echo "ERROR: ${BARE_REPO} or ${new} is there already." >&2
exit 1
fi
if ! daemon=$(swarm_docker_daemon_id); then
echo "ERROR: cannot read the Docker daemon id." >&2
exit 1
fi
# The engagement is recorded first: a run stopped from here on
# leaves at most upstream.git.new under it, which cleanup preserves
# and removes.
begin_engagement "$base" "$daemon" || {
echo "ERROR: cannot record the engagement in ${STATE_FILE}." >&2
exit 1
}
# Through Git's transport (--no-local), not a copy of the object
# store: the bare repo gets new files, so the chmod below cannot
# reach the checkout's own through hardlinks; it borrows nothing
# through alternates; and it holds what the checkout's branches,
# tags and HEAD reach, without the objects only other refs
# (recovery refs among them), the reflogs or nothing reach, which
# each cleanup would otherwise keep again. Later commands join any
# bare repo they find, so the clone gets its name only once it is
# ready.
if ! git clone --bare --no-local "$REPO_ROOT" "$new" \
|| ! prepare_bare_repo "$new" "$base" \
|| ! mv "$new" "$BARE_REPO"; then
echo "ERROR: cannot prepare ${BARE_REPO}." >&2
# This user's files, which no container has seen yet.
rm -rf "$new"
exit 1
fi
}
# Set up the fresh clone DIR for an engagement that starts at BASE.
# Returns 1 at the first failure.
prepare_bare_repo() {
local dir="$1" base="$2" ref refs
# The clone copies every local branch. Branches that harvest or
# the preservation checks treat as agent work start empty, so an
# old local agent-work, interactive or parked branch is never
# taken for work of this engagement.
refs=$(git -C "$dir" for-each-ref --format='%(refname)' \
refs/heads/swarm/ refs/heads/agent-parked/ refs/swarm-runs/) \
|| return 1
# Ref names cannot contain whitespace or glob characters, so word
# splitting yields one name per word.
for ref in $refs; do
git -C "$dir" update-ref -d "$ref" || return 1
done
git -C "$dir" update-ref refs/heads/agent-work "$base" || return 1
git -C "$dir" update-ref refs/swarm/base "$base" || return 1
git -C "$dir" symbolic-ref HEAD refs/heads/agent-work || return 1
# The container's agent user can have a different UID from the host
# user, so the repo stays writable for everyone. The owner-only
# runtime directory around it keeps other host users out.
git -C "$dir" config core.sharedRepository world || return 1
chmod -R a+rwX "$dir" || return 1
}
# A new bare repo starts a new engagement: record its id, which every
# later phase checks against SWARM_ENGAGEMENT_ID, and the commit it
# starts from (BASE). Agents can write to the bare repo, including its
# refs/swarm/base, so the state file keeps the copy that later checks
# of the agents' branches can trust. Returns 1 if either fails.
begin_engagement() {
local base="$1" daemon="$2" id token
id=$(swarm_engagement_new) || return 1
token=$(swarm_random_hex 8) || return 1
# What harvests of an earlier engagement merged means nothing for
# this one (cleanup drops it too).
swarm_state_update "$STATE_FILE" \
'.engagement_id = $id | .engagement_started_at = $at
| .base_commit = $base | .docker_daemon = $daemon
| .engagement_token = $token
| del(.harvested, .harvest_pending)' \
--arg id "$id" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg base "$base" --arg daemon "$daemon" --arg token "$token" \
|| return 1
echo "Engagement: ${id}"
}
# Fill WORKSPACE_ARGS with a new workspace volume for container NAME
# (kind KIND, context CONTEXT) and the run arguments every engine
# container gets after docker_args, so that an entry there cannot
# replace them: the engagement and its token as labels, no restart
# policy and no auto-remove (retire and cleanup refuse both). Needs the
# engagement recorded, so it runs once the bare repo was created or
# joined.
workspace_args() {
local name="$1" kind="$2" context="$3" engagement token vol
engagement=$(swarm_engagement_recorded "$STATE_FILE") || exit 1
token=$(swarm_engagement_token "$STATE_FILE") || exit 1
if [ -z "$engagement" ] || [ -z "$token" ]; then
echo "ERROR: ${STATE_FILE} records no engagement." >&2
exit 1
fi
vol=$(swarm_workspace_volume_create "$name" "$PROJECT" \
"$CHECKOUT_PATH" "$engagement" "$token" "$kind" "$context") \
|| exit 1
WORKSPACE_ARGS=(
--mount "type=volume,src=${vol},dst=/workspace"
--label "${SWARM_ENGAGEMENT_LABEL}=${engagement}"
--label "${SWARM_TOKEN_LABEL}=${token}"
--restart no --rm=false
)
}
# A session that creates the bare repo mirrors the submodules too; one
# that joins keeps the mirrors the engagement has, which other sessions
# may be about to mount, and only adds the ones that are missing. The
# image is built once the checks passed, and before an engagement is
# created, so a failed build leaves the runtime directory clean.
ensure_bare_repo_for_interactive() {
if [ ! -d "$BARE_REPO" ]; then
# A token names an engagement to join, never a new one.
if [ -n "${SWARM_ENGAGEMENT_TOKEN:-}" ]; then
echo "ERROR: the engagement this session was started for" \
"has no bare repo any more." >&2
exit 1
fi
require_clean_runtime
build_image
create_bare_repo "bare repo for interactive session"
mirror_submodules
return
fi
swarm_engagement_join "$STATE_FILE" || exit 1
swarm_engagement_daemon_check "$STATE_FILE" || exit 1
local bare_head local_head
bare_head=$(swarm_no_fetch git -C "$BARE_REPO" rev-parse --verify \
--quiet refs/heads/agent-work 2>/dev/null || true)
local_head=$(git rev-parse HEAD 2>/dev/null || true)
if [ -n "$bare_head" ] && [ "$bare_head" != "$local_head" ] \
&& git merge-base --is-ancestor "$bare_head" HEAD 2>/dev/null; then
echo "ERROR: ${BARE_REPO} is stale (agent-work" \
"${bare_head:0:7} behind local HEAD" \
"${local_head:0:7})." >&2
echo " To start from the current HEAD, end this" \
"engagement: stop the agents, run harvest.sh, then" \
"./launch.sh cleanup, which keeps everything as recovery" \
"refs." >&2
exit 1
fi
build_image
mirror_submodules missing
}
# Print the commit the engagement started from, as begin_engagement
# recorded it in the state file (the bare repo's refs/swarm/base is the
# agents' to write). Fails unless it is a SHA-1 or SHA-256 commit id.
engagement_base() {
local state base
state=$(swarm_state_read "$STATE_FILE") || return 1
base=$(printf '%s' "$state" | jq -r '.base_commit // empty') \
|| return 1
case "${#base}:${base}" in
40:*[!0123456789abcdef]*|64:*[!0123456789abcdef]*) return 1 ;;
40:*|64:*) printf '%s' "$base" ;;
*) return 1 ;;
esac
}
# Mirror every submodule into MIRROR_DIR. A new engagement finds none
# (clean runtime), and one that is there refuses; with "missing", only
# the mirrors that do not exist yet are made and the others, which a
# session may be about to mount, stay. Each is cloned to <name>.git.new
# and renamed once ready, so a run stopped half way leaves only a .new,
# which cleanup preserves and removes.
mirror_submodules() {
local only_missing="${1:-}" base
# The containers check out the submodule commits the engagement's
# base records.
base=$(engagement_base) || {
echo "ERROR: ${STATE_FILE} records no base commit." >&2
exit 1
}
cd "$REPO_ROOT"
mkdir -p "$MIRROR_DIR"
# Git says where each submodule's Git directory is: under
# .git/modules/ by name, not by path (a submodule moved with git mv
# keeps its name), or in the submodule's work tree. One that Git
# cannot find or read stops the listing, and the command with it,
# even an interactive session that joins and has that mirror
# already.
git submodule foreach --quiet \
'gitdir=$(git rev-parse --absolute-git-dir) &&
echo "$name|$sm_path|$gitdir"' | \
while IFS='|' read -r name path gitdir; do
local safe_name mirror entry mode oid
safe_name="${name//\//_}"
mirror="${MIRROR_DIR}/${safe_name}.git"
if [ -e "$mirror" ] || [ -L "$mirror" ]; then
[ "$only_missing" = missing ] && continue
echo "ERROR: ${mirror} is there already." >&2
exit 1
fi
if [ -e "$mirror.new" ] || [ -L "$mirror.new" ]; then
echo "ERROR: ${mirror}.new is left from a run stopped half" \
"way; ./launch.sh cleanup removes it." >&2
exit 1
fi
# What the base records at the submodule's path, read before
# anything is made.
if ! entry=$(git --literal-pathspecs ls-tree "$base" -- "$path")
then
echo "ERROR: cannot read ${path} in the engagement's base" \
"${base}." >&2
exit 1
fi
mode="" oid=""
read -r mode _ oid _ <<< "$entry"
echo "--- Mirroring submodule: ${name} ---"
# Through Git's transport, as create_bare_repo does: new files,
# so the chmod below cannot reach the submodule's Git directory
# through hardlinks, holding what the submodule's branches, tags
# and HEAD reach.
git clone --bare --no-local "$gitdir" "$mirror.new"
# The commit the base records there stays in the mirror under
# refs/swarm/gitlink, fetched by id when none of those reaches
# it (the submodule's checkout moved on).
if [ "$mode" = 160000 ]; then
git -C "$mirror.new" cat-file -e "${oid}^{commit}" 2>/dev/null \
|| git -C "$mirror.new" -c protocol.version=2 fetch -q \
--no-tags --no-write-fetch-head "$gitdir" "$oid" \
2>/dev/null \
|| true
if git -C "$mirror.new" cat-file -e "${oid}^{commit}" \
2>/dev/null; then
git -C "$mirror.new" update-ref refs/swarm/gitlink "$oid"
else
echo "WARNING: ${gitdir} has no commit ${oid}, which" \
"the engagement's base records for ${path}; the" \
"containers cannot check that submodule out." >&2
fi
fi
chmod -R a+rwX "$mirror.new"
mv "$mirror.new" "$mirror"
done
}
# Fill MIRROR_ARGS with one read-only mount per mirrored submodule.
# Each mount stays a single argument, so a space in a path or in a
# submodule name does not split it.
mirror_volume_args() {
local names name safe_name mirror
names=$(cd "$REPO_ROOT" \
&& git submodule foreach --quiet 'echo "$name"' 2>/dev/null) || {
echo "ERROR: cannot list the submodules of ${REPO_ROOT}." >&2
return 1
}
MIRROR_ARGS=()
while IFS= read -r name; do
[ -n "$name" ] || continue
safe_name="${name//\//_}"
mirror="${MIRROR_DIR}/${safe_name}.git"
[ -d "$mirror" ] || continue
MIRROR_ARGS+=(-v "${mirror}:/mirrors/${name}:ro")
done <<< "$names"
}
available_drivers() {
find "$SWARM_DIR/lib/drivers" -type f -name '*.sh' \
-exec basename {} .sh \; | tr '\n' ' '
}
# retire needs no swarmfile: it acts on what the runtime directory and
# Docker already hold.
case "${1:-}" in
retire)
shift
# shellcheck source=lib/preserve.sh
source "$SWARM_DIR/lib/preserve.sh"
# shellcheck source=lib/retire.sh
source "$SWARM_DIR/lib/retire.sh"
rc=0
cmd_retire "$@" || rc=$?
exit "$rc"
;;
cleanup)
shift
# shellcheck source=lib/preserve.sh
source "$SWARM_DIR/lib/preserve.sh"
# shellcheck source=lib/retire.sh
source "$SWARM_DIR/lib/retire.sh"
# shellcheck source=lib/cleanup.sh
source "$SWARM_DIR/lib/cleanup.sh"
rc=0
cmd_cleanup "$@" || rc=$?
exit "$rc"
;;
esac
# Temporary files this run makes, removed when it exits, however it
# does: Bash runs the EXIT trap on exit, under set -e, and when HUP,
# INT or TERM end the script (retire and cleanup, above, make none and
# set their own traps).
SWARM_TMP_FILES=()
# Set when the run gets to an end it means: its last line, or an exit
# with status 0 it makes on purpose.
SWARM_EXIT_OK=false
swarm_tmp_cleanup() {
if [ "${#SWARM_TMP_FILES[@]}" -gt 0 ]; then
rm -f "${SWARM_TMP_FILES[@]}"
fi
}
# The EXIT trap: remove the temporary files, and where Bash needs it
# (swarm_stop_guard_needed), never let a run that stopped short end with
# status 0. Bash can also run this trap with $? at 0 when a signal ends
# the script, so the check is only made where Bash can end a stopped run
# with 0 itself; there a signal can print the line too. Any other
# status stays, a signal's too.
swarm_exit_trap() {
local rc=$?
swarm_tmp_cleanup
if [ "$rc" -eq 0 ] && ! $SWARM_EXIT_OK \
&& swarm_stop_guard_needed "${BASH_VERSINFO[0]}" \
"${BASH_VERSINFO[1]}"; then
echo "ERROR: launch.sh stopped before the end of its run." >&2
exit 1
fi
}
trap swarm_exit_trap EXIT
# Make a temporary file swarm-NAME.XXXXXX in TMPDIR, removed when this
# run exits, and set the variable VAR to its absolute path (the run
# may change directory before it exits).
swarm_tmp_file() {
local _dir="${TMPDIR:-/tmp}" _tmp
case "$_dir" in
/*) ;;
*)
# As for the swarmfile's directory: CDPATH cleared, links
# resolved, trailing newlines kept.
_dir=$(CDPATH='' cd -P -- "$_dir" && pwd -P && echo x) \
|| return 1
_dir="${_dir%x}"
_dir="${_dir%$'\n'}" ;;
esac
_tmp=$(mktemp "${_dir%/}/swarm-$2.XXXXXX") || return 1
SWARM_TMP_FILES+=("$_tmp")
printf -v "$1" '%s' "$_tmp"
}
# Copy the state file to a temporary file and set the variable VAR to
# its path, for stop and wait, which hold no lock: every check they make
# then reads one version of it, whatever another terminal does
# meanwhile (the state file is replaced by a rename, so a copy holds one
# whole version). With no state file, VAR names a file that does not
# exist.
state_snapshot() {
local _snap
swarm_tmp_file _snap state || return 1
if [ -e "$STATE_FILE" ] || [ -L "$STATE_FILE" ]; then
cp "$STATE_FILE" "$_snap" || return 1
else
rm -f "$_snap" || return 1
fi
printf -v "$1" '%s' "$_snap"
}
CONFIG_FILE="${SWARM_CONFIG:-}"
if [ -z "$CONFIG_FILE" ] && [ -f "$REPO_ROOT/swarm.json" ]; then
CONFIG_FILE="$REPO_ROOT/swarm.json"
fi
if [ -z "$CONFIG_FILE" ]; then
echo "ERROR: No swarmfile found. Create swarm.json in your repo root or set SWARM_CONFIG." >&2
exit 1
fi
if [ ! -f "$CONFIG_FILE" ]; then
echo "ERROR: Swarmfile ${CONFIG_FILE} not found." >&2
exit 1
fi
# Keep an absolute path: start changes into the repository root before
# it reads the swarmfile again, and the state file records the path for
# a dashboard started elsewhere. Parameter expansion and the "x"
# sentinel keep trailing newlines in directory names, which dirname and
# command substitution would drop.
case "$CONFIG_FILE" in
*/*) _config_dir="${CONFIG_FILE%/*}" ;;
*) _config_dir="." ;;
esac
_config_dir=$(CDPATH='' cd -P -- "${_config_dir:-/}" && pwd -P && echo x) \
|| { echo "ERROR: cannot resolve ${CONFIG_FILE}." >&2; exit 1; }
_config_dir="${_config_dir%x}"
_config_dir="${_config_dir%$'\n'}"
CONFIG_FILE="${_config_dir%/}/${CONFIG_FILE##*/}"
SWARM_PROMPT=$(jq -r '.prompt // empty' "$CONFIG_FILE")
SWARM_SETUP=$(jq -r '.setup // empty' "$CONFIG_FILE")
MAX_IDLE=$(jq -r '.max_idle // 3' "$CONFIG_FILE")
INJECT_GIT_RULES=$(jq -r 'if has("inject_git_rules") then .inject_git_rules else true end' "$CONFIG_FILE")
GIT_USER_NAME=$(jq -r '.git_user.name // "swarm-agent"' "$CONFIG_FILE")
GIT_USER_EMAIL=$(jq -r '.git_user.email // "agent@swarm.local"' "$CONFIG_FILE")
GIT_SIGNING_KEY=$(jq -r '.git_user.signing_key // empty' "$CONFIG_FILE")
GIT_SIGNING_KEY="$(expand_env_ref "$GIT_SIGNING_KEY")"
# Resolve signing key path and build volume mount.
SIGNING_KEY_ARGS=()
if [ -n "$GIT_SIGNING_KEY" ]; then
GIT_SIGNING_KEY="${GIT_SIGNING_KEY/#\~/$HOME}"
if [ ! -f "$GIT_SIGNING_KEY" ]; then
echo "ERROR: signing key not found: $GIT_SIGNING_KEY" >&2
exit 1
fi
SIGNING_KEY_ARGS=(-v "${GIT_SIGNING_KEY}:/etc/swarm/signing_key:ro")
fi
NUM_AGENTS=$(jq '[.agents[]? | (.count // 0)] | add // 0' "$CONFIG_FILE")
SWARM_DRIVER_DEFAULT=$(jq -r '.driver // "claude-code"' "$CONFIG_FILE")
MAX_RETRY_WAIT=$(jq -r '.max_retry_wait // 0' "$CONFIG_FILE")
DOCKER_EXTRA_ARGS=()
while IFS= read -r _da; do
[ -n "$_da" ] && DOCKER_EXTRA_ARGS+=("$_da")
done < <(jq -r '.docker_args[]?' "$CONFIG_FILE" 2>/dev/null)
parse_start_args() {
OPEN_DASHBOARD=false
while [ $# -gt 0 ]; do
case "$1" in
--dashboard)
OPEN_DASHBOARD=true
shift ;;
*)
echo "Unknown start option: $1" >&2
echo "Try '$0 --help' for more." >&2
exit 1 ;;
esac
done
}
print_interactive_profiles() {
jq -r '.driver as $dd | .agents | to_entries[] |
"\(.key + 1)|\(.value.name // "")|\(.value.model // "")|" +
"\(.value.driver // $dd // "claude-code")"' \
"$CONFIG_FILE" | while IFS='|' read -r idx name model driver; do
if [ -n "$name" ]; then
printf ' - %s (index %s, %s, %s)\n' \
"$name" "$idx" "${model:-model unset}" "$driver"
else
printf ' - --agent-index %s (%s, %s)\n' \
"$idx" "${model:-model unset}" "$driver"
fi
done >&2
}
select_interactive_profile() {
local selector="$1" selector_index="$2" output_file="$3"
local selected count
if [ -n "$selector_index" ]; then
if ! [[ "$selector_index" =~ ^[0-9]+$ ]] \
|| [ "$selector_index" -lt 1 ]; then
echo "ERROR: --agent-index must be a positive integer." >&2
exit 1
fi
selected=$(jq -c --argjson idx "$((selector_index - 1))" \
'.agents[$idx] // empty' "$CONFIG_FILE")
if [ -z "$selected" ] || [ "$selected" = "null" ]; then
echo "ERROR: no agent at index ${selector_index}." >&2
print_interactive_profiles
exit 1
fi
printf '%s\n' "$selected" > "$output_file"
return
fi
if [ -z "$selector" ]; then
local named_count agent_count
named_count=$(jq '[.agents[] | select(.name? and (.name | length > 0))] | length' \
"$CONFIG_FILE")
agent_count=$(jq '.agents | length' "$CONFIG_FILE")
if [ "$named_count" -eq 1 ]; then
selector=$(jq -r '.agents[] | select(.name? and (.name | length > 0)) | .name' \
"$CONFIG_FILE")
elif [ "$agent_count" -eq 1 ]; then
selector_index=1
select_interactive_profile "$selector" "$selector_index" "$output_file"
return
else
echo "ERROR: choose an interactive profile." >&2
echo "Use '$0 interactive NAME' or '$0 interactive --agent-index N'." >&2
echo "Available profiles:" >&2
print_interactive_profiles
exit 1
fi
fi
count=$(jq --arg name "$selector" \
'[.agents[] | select((.name // "") == $name)] | length' \
"$CONFIG_FILE")
if [ "$count" -eq 0 ]; then
echo "ERROR: no agent profile named '${selector}'." >&2
echo "Available profiles:" >&2
print_interactive_profiles
exit 1
fi
if [ "$count" -gt 1 ]; then
echo "ERROR: agent profile name '${selector}' is not unique." >&2
exit 1
fi
jq -c --arg name "$selector" \
'.agents[] | select((.name // "") == $name)' \
"$CONFIG_FILE" > "$output_file"
}
cmd_interactive() {
local mode="$1"; shift
local selector="" selector_index=""
while [ $# -gt 0 ]; do
case "$1" in
--agent)
selector="${2:-}"
if [ -z "$selector" ]; then
echo "ERROR: --agent requires a name." >&2
exit 1
fi
shift 2 ;;
--agent-index)
selector_index="${2:-}"
if [ -z "$selector_index" ]; then
echo "ERROR: --agent-index requires a number." >&2
exit 1
fi
shift 2 ;;
--shell)
mode="shell"
shift ;;
--chat)
mode="chat"
shift ;;
-h|--help)
cat <<HELP
Usage: $0 interactive [--agent NAME | --agent-index N] [--shell]
$0 chat [--agent NAME | --agent-index N]
$0 shell [--agent NAME | --agent-index N]
Start one human-guided container from an agents[] profile.
HELP
SWARM_EXIT_OK=true
exit 0 ;;
*)
if [ -n "$selector" ]; then
echo "ERROR: multiple interactive profiles supplied." >&2
exit 1
fi
selector="$1"
shift ;;
esac
done
local profile_file
swarm_tmp_file profile_file profile || exit 1
select_interactive_profile "$selector" "$selector_index" "$profile_file"
local profile_name profile_label safe_profile short_id branch name
local agent_model agent_base_url agent_api_key agent_effort agent_auth
local agent_context agent_prompt agent_auth_token agent_tag agent_driver
profile_name=$(jq -r '.name // empty' "$profile_file")
profile_label="$profile_name"
if [ -z "$profile_label" ]; then
profile_label="agent-${selector_index:-1}"
fi
safe_profile="$(swarm_project_id "$profile_label")"
safe_profile="${safe_profile:0:32}"
# Random enough that two sessions never meet (a record is never
# written over another; see swarm_pending_write).
short_id="$(date -u +%Y%m%d%H%M%S)-$(swarm_random_hex 4)" || exit 1
branch="swarm/${SWARM_RUN_HASH}/interactive-${safe_profile}-${short_id}"
name="${IMAGE_NAME}-interactive-${safe_profile}-${short_id}"
if ! git check-ref-format --branch "$branch" >/dev/null 2>&1; then
echo "ERROR: invalid interactive branch name: ${branch}" >&2
exit 1
fi
agent_model=$(jq -r '.model // empty' "$profile_file")
agent_base_url=$(jq -r '.base_url // empty' "$profile_file")
agent_api_key=$(jq -r '.api_key // empty' "$profile_file")
agent_api_key="$(expand_env_ref "$agent_api_key")"
agent_effort=$(jq -r '.effort // empty' "$profile_file")
agent_auth=$(jq -r '.auth // empty' "$profile_file")
agent_context=$(jq -r '.context // empty' "$profile_file")
agent_context="${agent_context:-full}"
case "$agent_context" in
full|slim|none) ;;
*)
echo "ERROR: unknown context mode: ${agent_context}" \
"(full, slim or none)." >&2
exit 1 ;;
esac
agent_prompt=$(jq -r '.prompt // empty' "$profile_file")
agent_auth_token=$(jq -r '.auth_token // empty' "$profile_file")
agent_auth_token="$(expand_env_ref "$agent_auth_token")"
agent_tag=$(jq -r '.tag // empty' "$profile_file")
if [ -z "$agent_tag" ]; then
agent_tag=$(jq -r '.tag // empty' "$CONFIG_FILE")
fi
agent_tag="$(expand_env_ref "$agent_tag")"
agent_driver=$(jq -r --arg dd "$SWARM_DRIVER_DEFAULT" \
'.driver // $dd // "claude-code"' "$profile_file")
agent_driver="${agent_driver:-$SWARM_DRIVER_DEFAULT}"
if [ ! -f "$SWARM_DIR/lib/drivers/${agent_driver}.sh" ]; then
echo "ERROR: unknown driver: ${agent_driver}" >&2
echo "Available drivers: $(available_drivers)" >&2
exit 1
fi
# shellcheck source=lib/drivers/claude-code.sh
source "$SWARM_DIR/lib/drivers/${agent_driver}.sh"
agent_model="${agent_model:-$(agent_default_model)}"
local effective_prompt="${agent_prompt:-$SWARM_PROMPT}"
if [ -n "$effective_prompt" ] && [ ! -f "$REPO_ROOT/$effective_prompt" ]; then
echo "ERROR: prompt '${effective_prompt}' not found." >&2
exit 1
fi
prepare_runtime
ensure_bare_repo_for_interactive
MIRROR_ARGS=()
mirror_volume_args
rm -f "$profile_file"
local EXTRA_ENV=()
while IFS= read -r _ae; do
[ -n "$_ae" ] && EXTRA_ENV+=("$_ae")
done < <(agent_docker_auth "$agent_api_key" "$agent_auth_token" \
"$agent_auth" "$agent_base_url")
if [ -n "$agent_effort" ]; then
while IFS= read -r _de; do
[ -n "$_de" ] && EXTRA_ENV+=("$_de")
done < <(agent_docker_env "$agent_effort")
fi
# Forward Pi settings overrides from the host environment.
for _pv in PI_HTTP_IDLE_TIMEOUT_MS PI_MAX_RETRIES \
PI_COMPACTION_RESERVE_TOKENS; do
[ -n "${!_pv:-}" ] && EXTRA_ENV+=(-e "${_pv}=${!_pv}")
done
# Its workspace volume, made under the lock before the session is
# recorded.
workspace_args "$name" interactive "$agent_context"
# The session can last hours and only pushes through git, so it runs
# without the lock. It is recorded under the lock with this
# process's pid; then the lock goes and exec keeps that pid for
# docker run, which makes the container and its mounts. Until this
# process ends, or its container after a detach, start and
# post-process leave the bare repo and the mirrors alone
# (require_clean_runtime). One Docker context serves
# the daemon id and docker run, even if another terminal switches
# it meanwhile.
local daemon
if ! swarm_docker_pin || ! daemon=$(swarm_docker_daemon_id); then
echo "ERROR: cannot read the Docker daemon id." >&2
exit 1
fi
if ! swarm_pending_write "$RUNTIME_DIR" "$name" "$$" "$daemon"; then
echo "ERROR: cannot record the interactive session." >&2
exit 1
fi
swarm_unlock
echo "--- Starting interactive ${profile_label} (${agent_model}) ---"
echo "Branch: ${branch}"
# --name and the session label come after docker_args: the last
# one wins, and the record must find the container Docker makes.
exec docker run -it \
-v "${BARE_REPO}:/upstream:rw" \
"${MIRROR_ARGS[@]+"${MIRROR_ARGS[@]}"}" \
"${SIGNING_KEY_ARGS[@]+"${SIGNING_KEY_ARGS[@]}"}" \
"${DOCKER_EXTRA_ARGS[@]+"${DOCKER_EXTRA_ARGS[@]}"}" \
"${WORKSPACE_ARGS[@]}" \
--name "$name" \
--label "${SWARM_SESSION_LABEL}=${name}" \
"${EXTRA_ENV[@]+"${EXTRA_ENV[@]}"}" \
--label "$CHECKOUT_LABEL" \
--label "$PROJECT_LABEL" \
-e "SWARM_MODEL=${agent_model}" \
-e "SWARM_EFFORT=${agent_effort}" \
-e "CLAUDE_MODEL=${agent_model}" \
-e "SWARM_PROMPT=${effective_prompt}" \
-e "SWARM_SETUP=${SWARM_SETUP}" \
-e "GIT_USER_NAME=${GIT_USER_NAME}" \
-e "GIT_USER_EMAIL=${GIT_USER_EMAIL}" \
-e "INJECT_GIT_RULES=${INJECT_GIT_RULES}" \
-e "AGENT_ID=interactive-${safe_profile}" \
-e "SWARM_TAG=${agent_tag}" \
-e "SWARM_CONTEXT=${agent_context}" \