forked from kunchenguid/firstmate
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfm-spawn.sh
More file actions
executable file
·4030 lines (3936 loc) · 197 KB
/
Copy pathfm-spawn.sh
File metadata and controls
executable file
·4030 lines (3936 loc) · 197 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
#!/usr/bin/env bash
# Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a
# secondmate in its isolated firstmate home.
# Usage: fm-spawn.sh <task-id> <project-dir> --mode <no-mistakes|direct-PR|local-only> --yolo <on|off> [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>]
# fm-spawn.sh <task-id> <project-dir> --scout [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>]
# fm-spawn.sh <task-id> [<firstmate-home>] [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] --secondmate
# --mode and --yolo are this task's delivery contract, REQUIRED for every ship
# spawn and refused on --scout and --secondmate spawns. Firstmate resolves both
# per task at intake (AGENTS.md section 7); data/projects.md holds the captain's
# standing posture as context, not as this task's answer, so a spawn never looks
# the mode up. A ship spawn additionally reads the brief's recorded
# "Delivery contract: mode=<mode>" line and REFUSES a mismatch, so the worker's
# instructions and the recorded task delivery cannot drift apart; a brief
# scaffolded before that line existed warns once and launches on the flag. A
# ship or scout spawn also refuses leftover `{TASK}` / `{FIRSTMATE_SPEC}`
# placeholders, an empty Task, or an incomplete pair of Task subsections.
# Every ship or scout spawn renders `launch-brief.md`; for a no-mistakes ship
# it also carries the current `--intent` contract and the extracted captain
# intent. A legacy mixed Task is accepted there only under bin/fm-dod-lib.sh's
# provenance-marking rules; unmarked legacy Tasks stop for migration rather
# than becoming intent. That library owns the parsing and intent rules. When
# the explicit mode carries less rigor than the project's standing posture, a
# loud one-line deviation notice is printed and the spawn continues.
# no-mistakes-prod-only is a registry policy rather than a task mode and is
# refused as a flag value.
# Ship/scout launches always supply fm-dod-lib.sh's current worker role scope
# using the same private launch-brief overlay. This never rewrites a project's
# instruction files or a secondmate's charter.
# fm-spawn.sh <task-id> --relaunch [--harness <name>] [--model <name>] [--effort <level>]
# --relaunch launches a replacement agent for an EXISTING task into that
# task's own recorded endpoint and worktree instead of creating either. It is
# the launch half of the control plane (bin/fm-control.sh relaunch), which
# owns the checkpoint, the progress note, stopping the previous agent, and the
# transaction; call fm-control rather than this flag directly unless you are
# deliberately re-launching an already-stopped task. Every identity axis -
# backend, kind, project or home, worktree, endpoint - comes from the task's
# validated state/<id>.meta, so --backend, --scout, --secondmate, a project
# positional, and batch pairs are all refused alongside it; only harness,
# model, and effort may change, which is what makes a harness switch one
# ordinary relaunch. It refuses unless the recorded endpoint is positively
# agent-free on a backend with a recovery-grade agent-state classifier (tmux
# or herdr), refuses unless the endpoint's shell is sitting in the recorded
# worktree, and clears the previous harness's per-task wiring before arming
# the new incarnation.
# --harness <name> is the explicit per-spawn harness/profile adapter. The old
# positional harness arg still works for back-compat.
# --model <name> and --effort <low|medium|high|xhigh|max|ultra> are concrete profile
# axes chosen by firstmate at intake. They are only threaded into harnesses whose
# installed CLIs were verified to support that axis; unsupported axes are omitted
# from that harness's launch rather than guessed. Ultra is the explicit
# exception: bin/fm-harness.sh validate-native-effort owns its model scope;
# supported Pi launches receive --codex-effort ultra, never --thinking ultra.
# --backend <name> is the explicit runtime session-provider backend for this
# exact task only (docs/configuration.md "Runtime backend" owns when that flag
# is authorized). Without it, the script resolves FM_BACKEND, then
# config/backend, then runtime auto-detection from the runtime firstmate's
# environment: $TMUX, HERDR_ENV=1, or cmux runtime signals (via
# bin/fm-backend.sh's fm_backend_detect, with cmux fallback details in
# docs/cmux-backend.md),
# then tmux.
# Spawn-capable backends are the reference tmux adapter and experimental
# herdr, zellij, orca, and cmux. Orca owns both the task worktree and
# terminal, so ship/scout Orca spawns do not run treehouse get; cmux is a
# session provider only, exactly like herdr/zellij, so it does. An
# auto-detected herdr or cmux spawn prints a loud stderr notice;
# auto-detected tmux stays silent; zellij and orca are never auto-detected.
# codex-app is not a known backend yet; docs/codex-app-backend.md owns that
# blocked backend contract. Default tmux spawns do not write backend= to meta;
# absent backend= means tmux. cmux does not support --secondmate spawns yet.
# A backend spawn refusal (missing dependency, version gate, unauthenticated
# socket, or unsupported secondmate mode) is terminal for that selected backend;
# callers must surface it instead of silently retrying another backend.
# A herdr crewmate or scout is placed in the exact workspace of the firstmate
# or secondmate process launching it, resolved from that process's own herdr
# pane rather than from a workspace label (herdr enforces no label uniqueness,
# so a label cannot tell two "firstmate" workspaces apart). A claimed parent
# identity that is unreadable, contradictory, stale, or from another herdr
# session stops the spawn before any worker endpoint exists. A launcher
# outside herdr has no workspace to inherit and uses this home's own labeled
# workspace, which must then match exactly one. --secondmate is the deliberate
# exception: it stands up that secondmate home's own workspace.
# Herdr additionally uses a presentation-only layout by default when the
# selected client and running server meet the Herdr 0.8.0 floor. The local
# config/herdr-presentation-spaces file can say off to disable it or on to
# opt in below that floor; an empty file remains the historical opt-in form.
# A clean fresh task first writes state/<id>.herdr-presentation atomically,
# then creates a disposable
# workspace containing only the ordinary task pane. A successful clean create
# upgrades its attempt journal with exact home, session, workspace, tab, pane,
# parent, and label bindings. On a same-identity restart, that complete binding
# plus authoritative metadata may replace one exact agent-free husk in place.
# The journal, visible token, and labels alone are never endpoint or ownership
# authority, and every ambiguous recovery stays on the flat fallback after
# duplicate-agent risk is independently absent. Treehouse allocation and task
# metadata are unchanged.
# A clean projected create or exact resume makes one bounded attempt to hold
# the one session-scoped presentation-order lock (keyed by named session plus
# canonical socket, outside any home's state/) through launch handoff. Lock
# contention warns and falls back to the ordinary flat layout before any
# projection mutation. The exact response-derived new workspace is inserted
# immediately after its owning parent (firstmate or 2ndmate-<id>) contiguous
# child block. Ordering never authorizes lifecycle cleanup, and any
# unavailable, ambiguous, or failed move warns while the spawn continues.
# Every projected create, prune, and move captures and verifies the named
# session's exact active workspace and tab. A detected focus change restores
# only that exact tab id; an ambiguous pre-operation snapshot refuses the
# focus-sensitive presentation mutation.
# Every single-task invocation holds one task-id-scoped lock across backend
# creation through metadata publication, so concurrent same-id spawns serialize
# even when they select different backends. A fresh spawn first takes the
# per-home task-set lock and refuses rather than waits when forced teardown owns
# it; relaunch is exempt because the existing task's control lock covers it.
# A fresh Treehouse-backed spawn also takes the project-identity lock in the local
# root Firstmate home's state directory before slot allocation and holds it through
# task metadata publication. Teardown holds that same lock while proving and
# returning a slot, so allocation cannot reuse a slot before its owner record
# is published. The local root is whatever bin/fm-wake-lib.sh's
# fm_firstmate_root_home resolves, so a home seeded from another machine anchors
# that lock itself rather than failing to resolve one;
# contention refuses rather than waits.
# With no harness arg, a crewmate/scout spawn resolves the CREW harness only when
# config/crew-dispatch.json is absent. When that file exists, crewmate/scout
# spawns require an explicit harness so firstmate cannot silently skip dispatch
# profile consultation. A --secondmate spawn is exempt and resolves the SECONDMATE
# harness (config/secondmate-harness -> config/crew-harness -> own), so the
# secondmate-vs-crewmate split is DURABLE across every respawn (recovery,
# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|gemini|muse|rovo|omp)
# overrides it for this spawn (either kind). A non-flag string containing
# whitespace is treated as a RAW launch command - the escape hatch for verifying
# new adapters. For pi and pi-signed, fm-spawn resolves the selected executable
# name from PATH once, probes that concrete path with --help, and launches the
# same path. It adds --tui-mode regular only when that help advertises the flag;
# a failed or inconclusive probe omits it so older Pi versions remain launchable.
# A missing selected executable refuses before endpoint creation, and pi-signed
# never falls back to pi.
# For omp (Oh My Pi), fm-spawn resolves the `omp` executable from PATH once and
# refuses when it is absent. Every omp launch clears the foreign harness
# markers (omp publishes none of its own), sets the Firstmate-owned
# FM_OMP_HARNESS=omp detection marker, suppresses the first-run provider
# wizard with OMP_SKIP_SETUP=1, forces --auto-approve, pins the working
# directory with --cwd, and passes the tracked worker posture overlay
# .omp/fm-worker-overlay.yml through --config. That overlay pins composer
# shape, plan mode off, prewalk off, and the non-interactive usage-reserve
# policy for the one session only (--auto-approve alone owns approval); the
# captain's own ~/.omp/agent/config.yml (model roles, providers, theme) is
# never written.
# A model written as <provider>/<id> is validated against `omp models --json`
# only when that provider appears in the listing; a provider absent from the
# listing (an extension-registered provider such as claude-bridge, which omp
# never lists) passes through unvalidated with a stderr notice, and a bare
# fuzzy pattern is left to omp's own matcher. A crewmate or scout loads its
# per-task busy-state extension with -e from state/ (outside the worktree, so
# auto-discovery cannot load it a second time); a secondmate passes no -e at
# all and relies on omp auto-discovering the home's tracked .omp/extensions/
# (verified, omp 18.1.11: a file named both ways loads twice, and discovery is
# cwd-only with no trust dialog).
# config/secondmate-harness may also carry an optional model and effort as extra
# whitespace-separated tokens ("<harness> [<model>] [<effort>]"). For a
# --secondmate spawn, those tokens apply only when this spawn also resolves its
# harness from config/secondmate-harness. An explicit per-spawn --harness,
# positional harness arg, or raw launch command starts with clean model/effort
# defaults unless the caller also passes explicit --model/--effort flags. When
# the file governs the spawn, its model/effort tokens are re-resolved on every
# respawn exactly like the harness axis, and explicit --model/--effort flags
# still win over the file's tokens.
# A --secondmate spawn also propagates the primary's declared inherited local
# material, so the secondmate's OWN crewmates inherit primary config and the
# secondmate receives the primary's read-only shared captain-preference file
# (fm-config-inherit-lib.sh). A successful launch clears pending inherited
# config reread generations because the new agent reads the converged files.
# --scout records kind=scout in the task's meta (report deliverable, scratch worktree;
# see AGENTS.md task lifecycle); --secondmate records kind=secondmate and launches in a
# provisioned firstmate home; the default is kind=ship.
# Before a secondmate launch, the home is fast-forwarded to the primary's
# default-branch commit when safe: directly for a local home, or through the
# configured host for a remote home. Skipped syncs warn and launch unchanged.
# Ship/scout spawns refuse to launch unless the resolved task path is a real
# git worktree root distinct from both the spawning project and its repository's
# primary checkout, including when the spawning project is a linked worktree.
# On the backends that discover that path by reading the task pane's own cwd,
# the same isolation test screens every read: a pane still showing the project
# or the repository primary while `treehouse get` prepares the slot is waited
# out as a transient rather than adopted and then refused, so a home that is
# itself a linked worktree of the project repository still launches. A pane
# that never reaches an isolated worktree refuses at the end of that wait,
# naming the last path seen and why it was rejected.
# That placement is proven only at launch. Every ship or scout pane therefore
# also receives `export FM_TASK_ID=<task-id>` before the launch command, on
# the same channel as GOTMPDIR, and bin/fm-test-run.sh refuses to execute the
# behavior suite from the repository primary checkout while that marker is
# set (its header owns the refusal). A secondmate runs in its own home and is
# not marked.
# Only after this isolation check, every fresh ship or scout requires a clean
# task worktree. When an origin configuration is detected, spawn fetches it,
# resolves the current remote default branch, and resets to its tip. When none
# is detected, spawn skips that remote freshness check and launches from the
# clean worktree's current HEAD. Relaunch reuses the recorded worktree without
# fetching or resetting its base. An unreachable detected origin, unresolved
# default branch, or non-clean worktree refuses a fresh spawn rather than
# risking a PR based on stale history or discarding local work.
# A slot whose only deviation is a stale submodule gitlink is refused by that
# same clean check, but is reported as a stale checkout naming each submodule
# and both pins; nothing is converged or removed, and no remedy is suggested.
# That report is only reached when each submodule's checked-out commit is
# already contained in one of its remotes, so a submodule carrying an unpushed
# commit keeps the conservative uncommitted-work refusal instead. That
# containment test reads local refs only and never fetches, so this gate stays
# usable offline; a stale remote-tracking ref can therefore make an unpushed
# commit look contained, which is exactly why no remedy command is printed.
# Batch dispatch: pass one or more `id=repo` pairs instead of a single <id> <project>, e.g.
# fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout]
# Each pair re-execs this script in single-task mode, so the single path stays the only
# source of truth; shared --scout/--harness/--model/--effort/--backend/--mode/--yolo
# applies to every pair. A ship batch therefore carries one delivery contract, and each
# pair still checks it against its own brief; a batch spanning modes is two invocations.
# If config/crew-dispatch.json exists, shared --harness is required for crewmate
# and scout batches. The loop lives here, in bash, so callers never hand-write a
# multi-task shell loop (the tool shell is zsh, which does not word-split unquoted
# $vars and silently breaks ad-hoc `for ... in $pairs` loops).
# Launch environment (config/launch-env-allowlist):
# Absent means unchanged ambient inheritance. A present readable regular file
# opts every launch (ship, scout, secondmate, raw command, and relaunch) into
# /usr/bin/env -i followed by /bin/sh -c of the existing launch command.
# Each line is one POSIX environment name, never a value or shell expression;
# blank lines and lines beginning with # are ignored. Invalid input refuses
# before launch, as do path inspection errors such as inaccessible config
# directories. An empty file retains only the operational floor below.
# Names are read once per spawn; values are expanded in the destination pane,
# not copied from the invoking process or written into the launch text.
# Unset names stay unset and empty values stay empty.
# The fixed operational floor is HOME PATH USER LOGNAME SHELL TERM COLORTERM
# LANG LC_ALL LC_CTYPE TMPDIR TMP TEMP GOTMPDIR, plus backend identity/routing:
# TMUX TMUX_PANE HERDR_ENV HERDR_SESSION HERDR_SOCKET_PATH HERDR_PANE_ID
# CMUX_WORKSPACE_ID CMUX_SURFACE_ID CMUX_TAB_ID CMUX_PANEL_ID CMUX_SOCKET_PATH
# ZELLIJ ZELLIJ_SESSION_NAME ZELLIJ_PANE_ID FM_ZELLIJ_SESSION, plus the task
# marker FM_TASK_ID that ship and scout panes receive above.
# An enabled task trace also retains TRACEPARENT. Explicit Firstmate launch
# assignments still apply inside the filtered environment. Raw commands must
# be POSIX sh compatible under this opt-in; the absent-file path is unchanged.
# This is an exec environment boundary, not a sandbox for the pane's startup
# shell, credential files, same-user processes, or later shell initialization.
# See docs/configuration.md for provider/Git setup and supported limits.
# Launch templates live in launch_template() below; placeholders replaced before launch:
# __BRIEF__ absolute path to data/<task-id>/brief.md
# __PIBIN__ quoted concrete Pi-family executable path resolved from PATH
# __PITUIMODE__ optional --tui-mode regular when that executable advertises it
# __TURNEND__ absolute path to state/<task-id>.turn-ended (for harnesses whose
# turn-end signal rides the launch command, e.g. codex -c notify=[...])
# __PIEXT__ absolute path to state/<task-id>.pi-ext.ts (pi turn-end extension,
# written by this script; outside the worktree to avoid pi's trust gate)
# __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home
# __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home
# __OMPBIN__ quoted concrete omp executable path resolved from PATH
# __OMPEXT__ absolute path to state/<task-id>.omp-ext.ts (omp busy-state and
# turn-end extension, written by this script; outside the worktree so
# omp's cwd-only auto-discovery cannot load it a second time)
# __OMPWORKERCFG__ absolute path to the tracked .omp/fm-worker-overlay.yml posture overlay
# __OPINPUT__ absolute path to the canonical operational-input encoder
# __WORKTREE__ absolute path to the task worktree
# __CURSORBIN__ resolved, cursor-verified executable for a cursor launch
# __GEMINISETTINGS__ firstmate-owned per-task gemini settings file (busy-state hooks)
# __ROVOBIN__ resolved, rovo-verified executable for a rovo launch
# Verified per-harness turn-end hooks are installed automatically where enabled; some live outside the worktree.
# Kimi uses one surgically installed Firstmate region in $HOME/.kimi-code/config.toml,
# a firstmate-owned global hook and registry, and a gitignored per-task pointer.
# grok uses a firstmate-owned global hook under ${GROK_HOME:-$HOME/.grok}/hooks
# plus a gitignored .fm-grok-turnend worktree pointer and a state token.
# muse installs no hook at all - its plugin engine is off in the default build - so
# it writes state/<id>.muse-session to bind the pane to muse's own session event
# log; muse and gemini are crewmate/scout only and are refused for --secondmate.
# rovo installs no hook either - its eventHooks fire at tool granularity only,
# never turn-end - so it carries no busy-source wiring at all and no turn-end
# hook. A positional brief is dead-on-arrival (rovo loads, never works, and drops
# to an idle shell), so rovo launches BARE and receives an absolute brief pointer
# only after a TUI readiness gate, then a delivery-confirmation gate - the same
# launch-then-send shape as kimi. Its busy state is a screen-scrape fallback like
# grok. rovo is crewmate/scout only and is refused for --secondmate, like muse.
# cursor installs no per-task hook either: it writes state/<id>.cursor-session to
# bind the pane to cursor's own conversation transcript (projects root, the exact
# workspace path cursor records in .workspace-trusted, and the conversations that
# already existed for that workspace). It is launched through the verified binary
# resolver because `cursor` is not the CLI name. A cursor SECONDMATE instead runs
# the tracked project-scope .cursor/hooks.json in its own home, whose stop-hook
# park owns that home's supervision (docs/supervision-protocols/cursor.md).
# claude is the one harness whose pre-launch setup can REFUSE the spawn: before
# any per-task state exists, and before its worktree .claude/settings.local.json
# hooks are written, a non-secondmate claude launch pre-registers the worktree in
# the launching user's own Claude trust store through bin/fm-claude-trust.sh,
# because Claude's interactive workspace-trust dialog gates a fresh worktree and
# firstmate cannot answer it. That helper's header owns the structural scope test
# and every refusal; a failed registration stops this spawn rather than launching
# a worker that would wedge on the dialog. A --secondmate launch never runs it,
# so a claude secondmate home keeps its own one-time trust decision.
# Every claude launch also carries the attribution-off policy in its per-launch
# --settings JSON, so a spawned worker never writes a Co-Authored-By trailer,
# Claude-Session link, or generated-with line into a commit or PR body;
# launch_template() below owns the reason it cannot come from the captain's own
# settings.
# Publishing the record and moving this home's backlog item to In flight are one
# step, not two: bin/fm-backlog-transition-lib.sh owns that invariant, and this
# script performs the transition under the task's own meta lock before it reports
# success. A ship or scout dispatch therefore REFUSES up front, before any
# endpoint, worktree, or record exists, unless the home's backlog has an
# unheld, unblocked Queued or In flight item for the id; a transition that fails
# after publication removes the record it just wrote rather than leaving a
# worker the backlog does not own. A relaunch re-reads the row instead of
# re-running the transition, so an eligible In-flight item is left untouched.
# The transition is
# skipped entirely for --secondmate spawns (persistent agents are not work
# items), on a config/backlog-backend=manual home, and in a markdown home that
# keeps no data/backlog.md. A configured non-markdown adapter remains
# active without a markdown file; any active automatic backend without
# compatible tasks-axi refuses before creating lifecycle state.
# On success prints: spawned <id> harness=<name> kind=<ship|scout|secondmate> [mode=<mode> yolo=<on|off>] window=<backend-target> worktree=<path>
# A ship task records the explicit mode/yolo it was passed; a secondmate spawn records
# mode=secondmate, yolo=off, home=, and projects=; a scout records neither, and both the
# success line and state/<id>.meta omit them.
# Every fresh spawn or relaunch records a new spawn_gen= incarnation token so durable
# consumers can distinguish a replacement worker that reuses the same task id.
# When the home session's frozen trace-context decision is enabled (see
# docs/configuration.md and bin/fm-trace-context-lib.sh), the meta also records
# one W3C traceparent= carrier, the same value injected into the pane as
# TRACEPARENT; the default-off path writes neither, leaving the generated meta
# and launch environment unchanged.
# --traceparent <carrier> delivers a carrier that a REMOTE parent already
# resolved and will record, instead of resolving one from this home's frozen
# decision. It is accepted only for --secondmate spawns, only as a strictly
# validated W3C traceparent, and exists because a remote secondmate's task
# identity is owned by the parent home that holds its task metadata, while the
# pane export happens on the remote host (bin/fm-remote-secondmate-control.sh).
# Local spawns never pass it and resolve their own carrier exactly as before.
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() {
# The whole leading comment block, ending at the first line that is not a
# comment. Derived rather than a fixed line range, which silently truncated
# this help mid-sentence every time the header above grew.
sed -n '2,${/^#/!q;p;}' "$0" | sed 's/^# \{0,1\}//'
}
case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
# shellcheck source=bin/fm-tasks-axi-lib.sh
. "$SCRIPT_DIR/fm-tasks-axi-lib.sh"
# shellcheck source=bin/fm-backlog-transition-lib.sh
. "$SCRIPT_DIR/fm-backlog-transition-lib.sh"
resolve_directory_input() {
local name=$1 path=$2 resolved raw_bytes
raw_bytes=$(fm_backlog_bytes_of_string "$path") || return 1
if ! fm_backlog_control_bytes_valid 0 "$raw_bytes"; then
echo "error: $name directory contains an invalid control byte" >&2
return 1
fi
case "$path" in
/*) printf '%s\n' "$path"; return 0 ;;
esac
resolved=$(CDPATH='' cd -- "$path" 2>/dev/null && pwd -P) || {
echo "error: $name directory cannot be resolved: $path" >&2
return 1
}
printf '%s\n' "$resolved"
}
FM_HOME=$(resolve_directory_input FM_HOME "$FM_HOME") || exit 1
if [ -n "${FM_STATE_OVERRIDE:-}" ]; then
FM_STATE_OVERRIDE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1
fi
if [ -n "${FM_DATA_OVERRIDE:-}" ]; then
FM_DATA_OVERRIDE=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1
fi
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"
PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}"
CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"
# shellcheck source=bin/fm-config-inherit-lib.sh
. "$SCRIPT_DIR/fm-config-inherit-lib.sh"
if ! LAUNCH_ENV_ENABLED=$(fm_config_source_present "$CONFIG/launch-env-allowlist"); then
exit 1
fi
LAUNCH_ENV_NAMES=
if [ "$LAUNCH_ENV_ENABLED" = 1 ]; then
if [ ! -f "$CONFIG/launch-env-allowlist" ] || [ ! -r "$CONFIG/launch-env-allowlist" ]; then
echo "error: config/launch-env-allowlist must be a readable regular file" >&2
exit 1
fi
if ! LAUNCH_ENV_NAMES=$(jq -Rrs '
split("\n") | map(select(. != "" and (startswith("#") | not))) |
if all(.[]; test("^[A-Za-z_][A-Za-z0-9_]*$")) then .[]
else error("expected environment names only") end
' "$CONFIG/launch-env-allowlist" 2>/dev/null); then
echo "error: config/launch-env-allowlist must contain one environment name per line, blank lines, or # comments" >&2
exit 1
fi
fi
SUB_HOME_MARKER=".fm-secondmate-home"
if [ -e "$STATE" ] || [ -L "$STATE" ]; then
fm_backlog_directory_present "$STATE" "state directory" || {
echo "error: spawn refused: $FM_BACKLOG_TRANSITION_ERROR" >&2
exit 1
}
fi
# shellcheck source=bin/fm-ff-lib.sh
. "$SCRIPT_DIR/fm-ff-lib.sh"
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"
fm_backlog_directory_present "$STATE" "state directory" || {
echo "error: spawn refused: $FM_BACKLOG_TRANSITION_ERROR" >&2
exit 1
}
# shellcheck source=bin/fm-secondmate-nudge-lib.sh
. "$SCRIPT_DIR/fm-secondmate-nudge-lib.sh"
# shellcheck source=bin/fm-backend.sh
. "$SCRIPT_DIR/fm-backend.sh"
# shellcheck source=bin/fm-control-lib.sh
. "$SCRIPT_DIR/fm-control-lib.sh"
# shellcheck source=bin/fm-gate-refuse-lib.sh
. "$SCRIPT_DIR/fm-gate-refuse-lib.sh"
# shellcheck source=bin/fm-busy-lib.sh
. "$SCRIPT_DIR/fm-busy-lib.sh"
# shellcheck source=bin/fm-cursor-lib.sh
. "$SCRIPT_DIR/fm-cursor-lib.sh"
# shellcheck source=bin/fm-pr-lib.sh
. "$SCRIPT_DIR/fm-pr-lib.sh"
# shellcheck source=bin/fm-dod-lib.sh
. "$SCRIPT_DIR/fm-dod-lib.sh"
# shellcheck source=bin/fm-trace-context-lib.sh
. "$SCRIPT_DIR/fm-trace-context-lib.sh"
# shellcheck source=bin/fm-remote-readiness-lib.sh
. "$SCRIPT_DIR/fm-remote-readiness-lib.sh"
# Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn
# a direct report (see bin/fm-gate-refuse-lib.sh).
fm_refuse_if_gate_agent
# Skip the watcher guard when re-exec'd for one pair of a batch (FM_SPAWN_NO_GUARD is
# set by the batch loop below), so the guard runs once for the batch, not once per pair.
[ -n "${FM_SPAWN_NO_GUARD:-}" ] || "$FM_ROOT/bin/fm-guard.sh" || true
KIND=ship
KIND_SET=0
HARNESS_ARG=
MODEL=
EFFORT=
BACKEND_ARG=
MODE=
YOLO=
TRACEPARENT_ARG=
HARNESS_SET=0
MODEL_SET=0
EFFORT_SET=0
BACKEND_SET=0
MODE_SET=0
YOLO_SET=0
TRACEPARENT_SET=0
RELAUNCH=0
POS=()
want_value=
for a in "$@"; do
if [ -n "$want_value" ]; then
case "$a" in
--*) echo "error: --$want_value requires a value" >&2; exit 1 ;;
esac
case "$want_value" in
harness) HARNESS_ARG=$a; HARNESS_SET=1 ;;
model) MODEL=$a; MODEL_SET=1 ;;
effort) EFFORT=$a; EFFORT_SET=1 ;;
backend) BACKEND_ARG=$a; BACKEND_SET=1 ;;
mode) MODE=$a; MODE_SET=1 ;;
yolo) YOLO=$a; YOLO_SET=1 ;;
traceparent) TRACEPARENT_ARG=$a; TRACEPARENT_SET=1 ;;
*) echo "error: internal parser state for --$want_value" >&2; exit 1 ;;
esac
want_value=
continue
fi
case "$a" in
--scout) KIND=scout; KIND_SET=1 ;;
--secondmate) KIND=secondmate; KIND_SET=1 ;;
--relaunch) RELAUNCH=1 ;;
--harness) want_value=harness ;;
--harness=*) HARNESS_ARG=${a#--harness=}; HARNESS_SET=1 ;;
--model) want_value=model ;;
--model=*) MODEL=${a#--model=}; MODEL_SET=1 ;;
--effort) want_value=effort ;;
--effort=*) EFFORT=${a#--effort=}; EFFORT_SET=1 ;;
--backend) want_value=backend ;;
--backend=*) BACKEND_ARG=${a#--backend=}; BACKEND_SET=1 ;;
--mode) want_value=mode ;;
--mode=*) MODE=${a#--mode=}; MODE_SET=1 ;;
--yolo) want_value=yolo ;;
--yolo=*) YOLO=${a#--yolo=}; YOLO_SET=1 ;;
--traceparent) want_value=traceparent ;;
--traceparent=*) TRACEPARENT_ARG=${a#--traceparent=}; TRACEPARENT_SET=1 ;;
*) POS+=("$a") ;;
esac
done
[ -z "$want_value" ] || { echo "error: --$want_value requires a value" >&2; exit 1; }
[ "$HARNESS_SET" -eq 0 ] || [ -n "$HARNESS_ARG" ] || { echo "error: --harness requires a non-empty value" >&2; exit 1; }
[ "$MODEL_SET" -eq 0 ] || [ -n "$MODEL" ] || { echo "error: --model requires a non-empty value" >&2; exit 1; }
[ "$EFFORT_SET" -eq 0 ] || [ -n "$EFFORT" ] || { echo "error: --effort requires a non-empty value" >&2; exit 1; }
[ "$BACKEND_SET" -eq 0 ] || [ -n "$BACKEND_ARG" ] || { echo "error: --backend requires a non-empty value" >&2; exit 1; }
[ "$MODE_SET" -eq 0 ] || [ -n "$MODE" ] || { echo "error: --mode requires a non-empty value" >&2; exit 1; }
[ "$YOLO_SET" -eq 0 ] || [ -n "$YOLO" ] || { echo "error: --yolo requires a non-empty value" >&2; exit 1; }
[ "$TRACEPARENT_SET" -eq 0 ] || [ -n "$TRACEPARENT_ARG" ] || { echo "error: --traceparent requires a non-empty value" >&2; exit 1; }
# A parent-delivered carrier replaces this home's own resolution, so it is
# refused unless it is a secondmate spawn carrying a strictly valid W3C value.
# Nothing else may reach the pane's TRACEPARENT export.
if [ "$TRACEPARENT_SET" -eq 1 ]; then
[ "$KIND" = secondmate ] || {
echo "error: --traceparent applies only to --secondmate spawns; every other spawn resolves its own carrier from this home's frozen trace-context decision" >&2
exit 1
}
fm_trace_context_valid "$TRACEPARENT_ARG" || {
echo "error: --traceparent is not a valid W3C traceparent" >&2
exit 1
}
fi
case "$EFFORT" in
''|low|medium|high|xhigh|max|ultra) ;;
*) echo "error: --effort must be one of low, medium, high, xhigh, max, ultra" >&2; exit 1 ;;
esac
# --relaunch reuses an existing task's endpoint, worktree, project, and kind,
# so every axis this block resolves for a fresh spawn instead comes from that
# task's own durable record below. Contradicting it on the command line is a
# refusal rather than a silently-ignored flag.
if [ "$RELAUNCH" -eq 1 ]; then
[ "$BACKEND_SET" -eq 0 ] || { echo "error: --relaunch reuses the task's recorded backend; --backend cannot override it" >&2; exit 1; }
[ "$KIND_SET" -eq 0 ] || { echo "error: --relaunch reuses the task's recorded kind; --scout/--secondmate cannot override it" >&2; exit 1; }
[ "$MODE_SET" -eq 0 ] || { echo "error: --relaunch reuses the task's recorded delivery mode; --mode cannot override it" >&2; exit 1; }
[ "$YOLO_SET" -eq 0 ] || { echo "error: --relaunch reuses the task's recorded yolo posture; --yolo cannot override it" >&2; exit 1; }
else
# Delivery contract (AGENTS.md section 7). A ship task's mode and yolo are
# firstmate's per-task decision, so they are required and closed-set validated
# here rather than resolved from the project registry. Scouts deliver a report
# and record no delivery posture; secondmate spawns hardcode theirs.
if [ "$KIND" = ship ]; then
[ "$MODE_SET" -eq 1 ] || {
echo "error: ship spawns require --mode <no-mistakes|direct-PR|local-only>; resolve it at intake from the captain's instruction and the project's registered posture in data/projects.md" >&2
exit 1
}
[ "$YOLO_SET" -eq 1 ] || {
echo "error: ship spawns require --yolo <on|off>; it is this task's merge authority, not a project lookup" >&2
exit 1
}
case "$MODE" in
no-mistakes|direct-PR|local-only) ;;
no-mistakes-prod-only)
echo "error: no-mistakes-prod-only is a registry policy, not a task mode; classify this task's surface and resolve it to no-mistakes or direct-PR at intake" >&2
exit 1 ;;
*) echo "error: --mode must be one of no-mistakes, direct-PR, local-only (got '$MODE')" >&2; exit 1 ;;
esac
case "$YOLO" in
on|off) ;;
*) echo "error: --yolo must be on or off (got '$YOLO')" >&2; exit 1 ;;
esac
else
[ "$MODE_SET" -eq 0 ] || {
echo "error: --mode applies only to ship spawns; a scout delivers a report and a secondmate records its own fixed posture" >&2
exit 1
}
[ "$YOLO_SET" -eq 0 ] || {
echo "error: --yolo applies only to ship spawns; a scout delivers a report and a secondmate records its own fixed posture" >&2
exit 1
}
fi
fi
spawn_remote_secondmate() {
local id=$1 remote host root home harness positional model effort backend out rc meta tmp
local remote_backend remote_target remote_harness remote_herdr_session registry_lock remote_lock remote_generation
local remote_traceparent remote_recorded_traceparent sm_primary_head sync_out sync_rc
local -a launch_args
id=${POS[0]:-}
fm_task_id_creation_valid "$id" || { echo "error: invalid task id" >&2; return 2; }
mkdir -p "$STATE" || { echo "error: could not create parent state directory" >&2; return 1; }
SPAWN_TASK_LOCK="$STATE/.spawn-$id.lock"
if ! fm_lock_try_acquire "$SPAWN_TASK_LOCK"; then
echo "error: another spawn is already creating task $id" >&2
return 1
fi
registry_lock=$(secondmate_registry_lock_path "$STATE")
if ! fm_lock_acquire_wait "$registry_lock"; then
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: secondmate registry could not be locked for remote spawn" >&2
return 1
fi
remote=$(secondmate_registry_field "$DATA/secondmates.md" "$id" remote 2>/dev/null || true)
if [ "$remote" != 1 ]; then
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
return 3
fi
host=$(secondmate_registry_field "$DATA/secondmates.md" "$id" host)
root=$(secondmate_registry_field "$DATA/secondmates.md" "$id" root)
home=$(secondmate_registry_field "$DATA/secondmates.md" "$id" home)
positional=${POS[1]:-}
if [ "${#POS[@]}" -gt 2 ]; then
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: remote secondmate spawn accepts no local home positional argument" >&2
return 2
fi
if [ -n "$HARNESS_ARG" ]; then
harness=$HARNESS_ARG
elif [ -n "$positional" ]; then
harness=$positional
else
harness=$("$FM_ROOT/bin/fm-harness.sh" secondmate)
fi
case "$harness" in
claude|codex|opencode|pi|pi-signed|grok|kimi|cursor) ;;
*)
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: remote secondmate spawn requires a verified harness adapter, not a raw launch command: $harness" >&2
return 1
;;
esac
model=${MODEL:--}
effort=${EFFORT:--}
if [ -z "$HARNESS_ARG" ] && [ -z "$positional" ]; then
if [ "$MODEL_SET" -eq 0 ]; then
model=$("$SCRIPT_DIR/fm-harness.sh" secondmate-model)
[ -n "$model" ] || model=-
fi
if [ "$EFFORT_SET" -eq 0 ]; then
effort=$("$SCRIPT_DIR/fm-harness.sh" secondmate-effort)
[ -n "$effort" ] || effort=-
fi
fi
# A remote second mate always runs on Herdr: its server belongs to the host's
# own GUI login session, so the endpoint outlives every SSH connection that
# supervises it. bin/fm-remote-doctor.sh gates that host on the same
# requirement, and the remote home's config/backend never overrides it.
case "${BACKEND_ARG:--}" in
-|herdr) backend=herdr ;;
*)
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: a remote secondmate runs only on the herdr backend, not '$BACKEND_ARG'" >&2
return 1
;;
esac
case "$effort" in
-|low|medium|high|xhigh|max|ultra) ;;
*)
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: invalid configured remote secondmate effort: $effort" >&2
return 1
;;
esac
if [ "$effort" = ultra ] && ! "$SCRIPT_DIR/fm-harness.sh" validate-native-effort "$harness" "$model" "$effort"; then
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
return 1
fi
meta="$STATE/$id.meta"
if [ -e "$meta" ] || [ -L "$meta" ]; then
if ! fm_backlog_record_present "$meta" "task record" "$STATE" \
|| [ "$(fm_meta_get "$meta" kind)" != secondmate ] \
|| [ "$(fm_meta_get "$meta" remote_host)" != "$host" ] \
|| [ "$(fm_meta_get "$meta" remote_root)" != "$root" ] \
|| [ "$(fm_meta_get "$meta" home)" != "$home" ]; then
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: existing metadata for $id does not identify this remote secondmate route" >&2
return 1
fi
fi
# Gate the host before anything is published or transferred, so a host that
# cannot hold a durable Herdr endpoint refuses here rather than half-way
# through a launch. This is also the readiness gate every liveness relaunch
# passes through, because recovery respawns through this same route.
rc=0
fm_remote_readiness_ensure "$SCRIPT_DIR" "$id" || rc=$?
if [ "$rc" -ne 0 ]; then
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
# Summary first, then the doctor's own text: a caller that reports only the
# first line, such as the startup liveness sweep, must still say something
# actionable.
if [ "$rc" -eq 255 ]; then
echo "error: remote secondmate $id readiness could not be confirmed; preserved route $host:$home" >&2
else
echo "error: remote secondmate $id host $host is not ready for a remote second mate; launch refused" >&2
fi
[ -z "$FM_REMOTE_READINESS_OUT" ] || printf '%s\n' "$FM_REMOTE_READINESS_OUT" >&2
[ "$rc" -ne 255 ] || return 255
return 1
fi
# Pre-launch sync, the remote twin of the local-HEAD sync below: this home
# follows THIS primary's default-branch commit, not the Firstmate copy on that
# host, so the commit is resolved here and handed over for the host to import
# and fast-forward to. A skipped sync warns and launches the home unchanged.
if sm_primary_head=$(primary_head_commit "$FM_ROOT"); then
if sync_out=$("$SCRIPT_DIR/fm-on.sh" "$id" fm-remote-secondmate-control.sh sync "$id" \
"$sm_primary_head" < /dev/null 2>&1); then
:
else
sync_rc=$?
echo "warning: remote secondmate $id sync skipped before launch: $(remote_sync_failure_reason "$sync_rc" "$sync_out")" >&2
fi
else
echo "warning: remote secondmate $id sync skipped before launch: primary default-branch commit cannot be resolved" >&2
fi
remote_lock=$(fm_remote_inherit_transaction_lock_path "$STATE" "$id")
if ! fm_lock_acquire_wait "$remote_lock"; then
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: remote secondmate $id inheritance transaction could not be locked" >&2
return 1
fi
remote_generation=$(fm_remote_inherit_generation_next "$STATE" "$id" 2>/dev/null || true)
if [ -z "$remote_generation" ]; then
fm_lock_release "$remote_lock" || true
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: remote secondmate $id inheritance generation could not be published" >&2
return 1
fi
if "$SCRIPT_DIR/fm-remote-inherit-push.sh" "$id" "$remote_generation" >/dev/null; then
:
else
rc=$?
fm_lock_release "$remote_lock" || true
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
if [ "$rc" -eq 255 ]; then
echo "error: remote secondmate $id inheritance completion is unknown; launch refused and route preserved for reconciliation" >&2
else
echo "error: remote secondmate $id inheritance failed; launch refused" >&2
fi
return "$rc"
fi
# This parent home owns the remote secondmate's task identity because it holds
# the task metadata an observer reads, exactly as for a local spawn: the
# carrier is resolved against THIS task's own meta (reused verbatim on
# relaunch, freshly rooted otherwise, never adopting this process's ambient
# TRACEPARENT) under this home's frozen decision, then handed to the remote
# host to export into the agent's pane. Disabled resolves to empty and the
# remote launch call stays byte-identical to the untraced one.
remote_traceparent=
if [ "$(fm_trace_context_session_effective "$STATE/.trace-context-effective")" = on ]; then
remote_traceparent=$(FM_TRACE_CONTEXT=on fm_trace_context_resolve "$CONFIG" "$meta" || true)
fi
launch_args=("$id" "$harness" "$model" "$effort" "$backend")
[ -z "$remote_traceparent" ] || launch_args+=("$remote_traceparent")
if out=$("$SCRIPT_DIR/fm-on.sh" "$id" fm-remote-secondmate-control.sh launch \
"${launch_args[@]}" < /dev/null 2>&1); then
rc=0
else
rc=$?
fi
if [ "$rc" -ne 0 ]; then
fm_lock_release "$remote_lock" || true
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
[ -z "$out" ] || printf '%s\n' "$out" >&2
if [ "$rc" -eq 255 ]; then
echo "error: remote secondmate $id is unavailable or launch completion is unknown; preserved route $host:$home" >&2
fi
return "$rc"
fi
remote_backend=$(printf '%s\n' "$out" | sed -n 's/^backend=//p' | tail -1)
remote_target=$(printf '%s\n' "$out" | sed -n 's/^target=//p' | tail -1)
remote_harness=$(printf '%s\n' "$out" | sed -n 's/^harness=//p' | tail -1)
remote_herdr_session=$(printf '%s\n' "$out" | sed -n 's/^herdr_session=//p' | tail -1)
if [ "$remote_backend" != herdr ]; then
fm_lock_release "$remote_lock" || true
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: remote launch returned backend '${remote_backend:-missing}', expected herdr; preserving the remote route for reconciliation" >&2
return 1
fi
[ -n "$remote_target" ] && [ "$remote_harness" = "$harness" ] || {
fm_lock_release "$remote_lock" || true
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: remote launch returned malformed route metadata; preserving the remote route for reconciliation" >&2
return 1
}
if [ "$remote_herdr_session" != fm-remote ] || [ "${remote_target%%:*}" != "$remote_herdr_session" ]; then
fm_lock_release "$remote_lock" || true
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: remote launch returned Herdr session '${remote_herdr_session:-missing}', expected 'fm-remote'; preserving the remote route for reconciliation" >&2
return 1
fi
# Record what the remote endpoint ACTUALLY carries, read back from its own
# launch, rather than what this side hoped to deliver. That keeps the #995
# guarantee that the recorded carrier is the identity the child received even
# when the remote host already had a live agent and reused its endpoint. An
# off decision delivers no carrier, but an endpoint already holding one still
# reports it here so the parent does not deny the agent's actual identity.
remote_recorded_traceparent=$(printf '%s\n' "$out" | sed -n 's/^traceparent=//p' | tail -1)
fm_trace_context_valid "$remote_recorded_traceparent" || remote_recorded_traceparent=
tmp="$meta.tmp.$$"
{
echo "window=remote:$id"
echo "endpoint_task_id=$id"
echo "worktree=$home"
echo "project=$root"
echo "harness=$harness"
echo "kind=secondmate"
echo "mode=secondmate"
echo "yolo=off"
echo "tasktmp="
echo "model=${model#-}"
echo "effort=${effort#-}"
echo "home=$home"
echo "projects=$(secondmate_registry_field "$DATA/secondmates.md" "$id" projects)"
echo "remote_host=$host"
echo "remote_root=$root"
echo "remote_backend=$remote_backend"
echo "remote_herdr_session=$remote_herdr_session"
echo "remote_target=$remote_target"
[ -z "$remote_recorded_traceparent" ] || echo "traceparent=$remote_recorded_traceparent"
} > "$tmp"
if ! fm_backlog_atomic_transition publish "$tmp" "$meta" "task record" "$STATE"; then
if [ "$SPAWN_TASK_SET_LOCK_HELD" = 1 ]; then
SPAWN_TASK_SET_LOCK_HELD=0
fm_lock_release "$SPAWN_TASK_SET_LOCK" || true
fi
fm_lock_release "$remote_lock" || true
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
echo "error: remote secondmate $id launched, but its task record could not be published ($FM_BACKLOG_TRANSITION_ERROR)" >&2
return 1
fi
if [ "$SPAWN_TASK_SET_LOCK_HELD" = 1 ]; then
SPAWN_TASK_SET_LOCK_HELD=0
fm_lock_release "$SPAWN_TASK_SET_LOCK"
fi
fm_lock_release "$remote_lock" || true
fm_lock_release "$registry_lock" || true
fm_lock_release "$SPAWN_TASK_LOCK" || true
"$SCRIPT_DIR/fm-home-summary-refresh.sh" --best-effort || true
if ! "$SCRIPT_DIR/fm-procevent-remote-reply.sh" arm "$id" >/dev/null; then
echo "error: remote secondmate $id launched, but its reply source could not be armed; endpoint metadata is preserved" >&2
return 1
fi
echo "spawned $id harness=$harness kind=secondmate mode=secondmate yolo=off window=remote:$id worktree=$home remote=$host backend=$remote_backend"
return 0
}
BACKEND=
ORCA_ABORT_CLEANUP=0
ORCA_WORKTREE_ID=
ORCA_TERMINAL=
HERDR_PROJECTION_ABORT_CLEANUP=0
HERDR_PROJECTION_ABORT_SESSION=
HERDR_PROJECTION_ABORT_TASK_PANE=
HERDR_PROJECTION_ABORT_SEEDED_PANE=
HERDR_PRESENTATION_ORDER_LOCK=
HERDR_PRESENTATION_ORDER_LOCK_HELD=0
SPAWN_TASK_LOCK=
SPAWN_TASK_LOCK_HELD=0
SPAWN_CONTROL_LOCK=
SPAWN_CONTROL_LOCK_HELD=0
SPAWN_CONTROL_PARENT=0
SPAWN_META_TMP=
SPAWN_META_LOCK=
SPAWN_META_LOCK_HELD=0
SPAWN_META_PUBLISH_STARTED=0
SPAWN_FRESH_COMMIT_PENDING=0
SPAWN_TASK_SET_LOCK=
SPAWN_TASK_SET_LOCK_HELD=0
SPAWN_TREEHOUSE_PROJECT_LOCK=
SPAWN_TREEHOUSE_PROJECT_LOCK_HELD=0
RELAUNCH_REPLACEMENT_PENDING=0
RELAUNCH_REPLACEMENT_BUSY_GEN=
RELAUNCH_REPLACEMENT_HARNESS=
RELAUNCH_REPLACEMENT_STATE=
RELAUNCH_REPLACEMENT_WT=
CONFIG_INHERIT_LOCK=
CONFIG_INHERIT_LOCK_HELD=0
spawn_fresh_commit_rollback() {
if fm_backlog_atomic_transition rollback "$STATE/$ID.meta" \
"$FM_ROOT/bin/fm-busy-event.sh" "$STATE" "$ID" "${BUSY_GEN:-}"; then
SPAWN_FRESH_COMMIT_PENDING=0
return 0
fi
echo "error: $FM_BACKLOG_TRANSITION_ERROR" >&2
return 1
}
parse_orca_worktree_result() {
local raw=$1 rest
ORCA_WORKTREE_ID=${raw%%$'\t'*}
if [ "$raw" = "$ORCA_WORKTREE_ID" ]; then
WT=
ORCA_TERMINAL=
return 1
fi
rest=${raw#*$'\t'}
WT=${rest%%$'\t'*}
if [ "$rest" != "$WT" ]; then
ORCA_TERMINAL=${rest#*$'\t'}
else
ORCA_TERMINAL=
fi
}
spawn_abort_cleanup() {
local status=$?
if [ "$RELAUNCH_REPLACEMENT_PENDING" = 1 ] \
&& [ "$SPAWN_META_PUBLISH_STARTED" = 1 ] \
&& [ -n "$SPAWN_META_TMP" ] \
&& [ ! -e "$SPAWN_META_TMP" ] \
&& [ ! -L "$SPAWN_META_TMP" ]; then
RELAUNCH_REPLACEMENT_PENDING=0
fi
if [ "$RELAUNCH_REPLACEMENT_PENDING" = 1 ]; then
RELAUNCH_REPLACEMENT_PENDING=0
if ! clear_relaunch_harness_wiring \
"$RELAUNCH_REPLACEMENT_HARNESS" \
"$RELAUNCH_REPLACEMENT_WT" \
"$RELAUNCH_REPLACEMENT_STATE" \
"$ID"; then
echo "warning: could not remove replacement wiring after aborted relaunch of $ID" >&2
fi
if [ -n "$RELAUNCH_REPLACEMENT_BUSY_GEN" ]; then
if ! "$FM_ROOT/bin/fm-busy-event.sh" retire \
"$RELAUNCH_REPLACEMENT_STATE" "$ID" \
--gen "$RELAUNCH_REPLACEMENT_BUSY_GEN"; then
echo "warning: could not retire replacement busy generation after aborted relaunch of $ID" >&2
fi
fi
fi
if [ "$HERDR_PROJECTION_ABORT_CLEANUP" = 1 ] \
&& [ "$HERDR_PRESENTATION_ORDER_LOCK_HELD" != 1 ]; then
if ! spawn_herdr_presentation_order_lock_acquire "${HERDR_PROJECTION_ABORT_SESSION:-}"; then
echo "warning: herdr presentation focus lock unavailable; retaining the projection journal and refusing concurrent abort cleanup" >&2
HERDR_PROJECTION_ABORT_CLEANUP=0
fi
fi
if [ "$HERDR_PROJECTION_ABORT_CLEANUP" = 1 ]; then
HERDR_PROJECTION_ABORT_CLEANUP=0
fm_backend_herdr_projection_cleanup_exact \
"$HERDR_PROJECTION_ABORT_SESSION" \
"$HERDR_PROJECTION_ABORT_TASK_PANE" \
"$HERDR_PROJECTION_ABORT_SEEDED_PANE" || true
fi
if [ "$HERDR_PRESENTATION_ORDER_LOCK_HELD" = 1 ]; then
HERDR_PRESENTATION_ORDER_LOCK_HELD=0
fm_lock_release "$HERDR_PRESENTATION_ORDER_LOCK" || true
fi
if [ "$ORCA_ABORT_CLEANUP" = 1 ]; then
ORCA_ABORT_CLEANUP=0
if [ -n "${ORCA_TERMINAL:-}" ]; then
fm_backend_kill orca "$ORCA_TERMINAL" 2>/dev/null || true
fi
if [ -n "${ORCA_WORKTREE_ID:-}" ]; then
if ! fm_backend_remove_worktree orca "$ORCA_WORKTREE_ID" 2>/dev/null; then
if [ "$SPAWN_FRESH_COMMIT_PENDING" = 1 ]; then
if ! spawn_fresh_commit_rollback; then
status=1
fi
SPAWN_FRESH_COMMIT_PENDING=0
fi
mkdir -p "$STATE" 2>/dev/null || true
if [ -d "$STATE" ]; then
SPAWN_META_TMP="$STATE/.$ID.meta.orca-recovery.${BASHPID:-$$}"
{
echo "window=$W"
echo "endpoint_task_id=$ID"
echo "cleanup_recovery=orca"
echo "worktree=${WT:-}"
echo "project=$PROJ_ABS"
echo "harness=$HARNESS"
echo "kind=$KIND"
[ -z "${MODE:-}" ] || echo "mode=$MODE"
[ -z "${YOLO:-}" ] || echo "yolo=$YOLO"
echo "tasktmp=${TASK_TMP:-}"
echo "model=${MODEL:-default}"
echo "effort=${EFFORT:-default}"
echo "backend=orca"
echo "orca_worktree_id=$ORCA_WORKTREE_ID"
[ -z "${ORCA_TERMINAL:-}" ] || echo "terminal=$ORCA_TERMINAL"
} > "$SPAWN_META_TMP" 2>/dev/null \
&& fm_backlog_atomic_transition publish "$SPAWN_META_TMP" "$STATE/$ID.meta" "task record" "$STATE" \
|| true
fi
fi
fi
fi
if [ "$SPAWN_TASK_LOCK_HELD" = 1 ]; then
SPAWN_TASK_LOCK_HELD=0
fm_lock_release "$SPAWN_TASK_LOCK" || true
fi
if [ "$SPAWN_FRESH_COMMIT_PENDING" = 1 ]; then
if ! spawn_fresh_commit_rollback; then