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
·4122 lines (4011 loc) · 192 KB
/
Copy pathfm-spawn.sh
File metadata and controls
executable file
·4122 lines (4011 loc) · 192 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
# FM_ACCOUNT_DIRECTORY_CUTOVER: direct-pool-rotation-v3
# Spawn a direct report: a new crewmate in a treehouse worktree, an eligible
# pre-cutover Orca direct recovery with empirically verified provider authority,
# or a secondmate in its isolated firstmate home.
# Usage: fm-spawn.sh <task-id> <project-dir> [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--account-pool <pool>] [--account-profile <profile>] [--no-account-routing] [--backlog-row-exemption <test-fixture|tracking-backend-repair>] [--scout]
# fm-spawn.sh <task-id> [<firstmate-home>] [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--account-pool <pool>] [--account-profile <profile>] [--no-account-routing] --secondmate
# fm-spawn.sh <task-id> --recover-direct-account
# fm-spawn.sh <task-id> (--resume-account|--continue-account) [--harness <claude|codex>] [--account-pool <pool>] [--account-profile <profile>]
# --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> 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.
# --backend <name> is the explicit runtime session-provider backend for this
# spawn. Without it, the script resolves FM_BACKEND, then config/backend, then
# runtime auto-detection (the runtime firstmate itself is executing inside -
# $TMUX, HERDR_ENV=1, or cmux runtime signals; bin/fm-backend.sh's
# fm_backend_detect, with cmux fallback details in docs/cmux-backend.md),
# then tmux.
# New-task spawn-capable backends are the reference tmux adapter and
# experimental herdr, zellij, and cmux. Orca's legacy respawn design owns both
# the task worktree and terminal, but currently fails closed before provider
# mutation because its lifecycle authority is unverified. 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. orca and cmux do not support --secondmate spawns.
# 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.
# With no harness arg, a crewmate/scout spawn resolves the crewmate 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|grok)
# 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.
# 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.
# Claude ship/scout launches never inherit the CLI's ambient model.
# They resolve config/claude-crew-model (inherited into secondmate homes), whose
# absent-file default and only accepted value is claude-opus-5. An explicit
# --model must equal that anchor. An empty/default/unresolvable/mismatched model
# or a raw Claude launch fails closed before endpoint creation.
# Account routing is independently default-off. Its precedence and off/observe/
# enforce resolution is owned by fm-account-routing-lib.sh. Direct account-
# directory launch currently covers ship/scout crewmates only; secondmate
# integration is deferred and retains legacy Agent Fleet routing.
# For a NEW routed Claude or Codex ship/scout, fm-account-directory.sh discovers
# the current user's account homes, chooses one through its direct per-vendor
# usage contract, installs that profile's Herdr hook, and prefixes the provider
# command with CLAUDE_CONFIG_DIR or CODEX_HOME.
# Existing --account-pool and --account-profile inputs remain compatibility
# activation signals for new direct launches; their aliases do not constrain
# the direct usage choice. --no-account-routing remains the emergency per-spawn
# opt-out and cannot be combined with either account flag. Off launches retain
# their existing default-identity behavior.
# config/secondmate-account-pool remains the primary's durable, non-inherited
# Agent Fleet selection input for secondmate agents when routing is enabled. A
# secondmate's own crewmates use inherited crewmate dispatch/routing policy, not
# this setting.
# --resume-account and --continue-account are legacy recovery paths only for
# existing account_profile metadata. They retain the sealed Agent Fleet
# session/lease behavior needed to recover those already-managed generations;
# ship/scout launches never create that metadata.
# --recover-direct-account is the ship/scout account_home recovery path. It reloads kind,
# project, worktree, harness, backend, model, effort, mode, yolo, and report
# requirements from metadata, selects a fresh account directory, and creates
# only a replacement endpoint in the recorded worktree.
# A --secondmate spawn also propagates the primary's declared inheritable config
# into the secondmate home's config/, so the secondmate's OWN crewmates,
# dispatch profiles, and backlog backend inherit the primary's settings
# (fm-config-inherit-lib.sh).
# --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.
# A genuinely new ship/scout spawn must already have an In flight or Queued row
# in this home's configured backlog. --backlog-row-exemption accepts only
# test-fixture or tracking-backend-repair and records the category in metadata.
# Before a secondmate launch, the home must fast-forward safely to the primary
# default-branch commit and independently match the live default tip.
# Any unproven freshness state refuses launch.
# Ship/scout spawns refresh the primary checkout before Treehouse acquisition,
# surface dirty pool entries, and durably lease one available worktree before
# creating the endpoint. They refuse to create that endpoint unless the leased
# path is a clean isolated worktree from the requested repository whose HEAD
# matches its live upstream or local default-branch tip. Dirty acquisitions
# remain under their durable lease for manual recovery. Other pre-commit
# failures close the prepared endpoint, restore prior task state, and return
# only a worktree whose repository identity, cleanliness, and expected detached
# tip are re-proven before and after owned hook cleanup, with the return held
# under the common checkout mutation lock.
# 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/account
# flags and --backlog-row-exemption apply to every pair.
# 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 templates live in launch_template() below; placeholders replaced before launch:
# __BRIEF__ absolute path to data/<task-id>/brief.md
# __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
# Per-harness turn-end hooks are installed automatically; some live outside the worktree.
# 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.
# On success prints: spawned <id> harness=<name> kind=<ship|scout|secondmate> mode=<mode> yolo=<on|off> window=<backend-target> worktree=<path>
# mode/yolo are resolved per-project from data/projects.md for ship/scout tasks;
# secondmate spawns record mode=secondmate, yolo=off, home=, and projects=.
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() {
sed -n '2,78p' "$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}}"
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}"
CHECKOUT_STATE_BASE="${FM_CHECKOUT_REFRESH_STATE_BASE:-${XDG_STATE_HOME:-$HOME/.local/state}/firstmate/checkout-refresh}"
SUB_HOME_MARKER=".fm-secondmate-home"
# shellcheck source=bin/fm-checkout-lock-lib.sh
. "$SCRIPT_DIR/fm-checkout-lock-lib.sh"
CHECKOUT_LOCK_ROOT=$(fm_checkout_lock_root "$CHECKOUT_STATE_BASE")
# shellcheck source=bin/fm-ff-lib.sh
. "$SCRIPT_DIR/fm-ff-lib.sh"
# shellcheck source=bin/fm-config-inherit-lib.sh
. "$SCRIPT_DIR/fm-config-inherit-lib.sh"
# shellcheck source=bin/fm-account-routing-lib.sh
. "$SCRIPT_DIR/fm-account-routing-lib.sh"
# shellcheck source=bin/fm-report-contract-lib.sh
. "$SCRIPT_DIR/fm-report-contract-lib.sh"
# shellcheck source=bin/fm-tasks-axi-lib.sh
. "$SCRIPT_DIR/fm-tasks-axi-lib.sh"
# shellcheck source=bin/fm-backend.sh
. "$SCRIPT_DIR/fm-backend.sh"
# shellcheck source=bin/fm-gate-refuse-lib.sh
. "$SCRIPT_DIR/fm-gate-refuse-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
spawn_managed_endpoint_kill() { # <backend> <target> <tab-id> <label> <kind> <secondmate-home> [recorded-scoped-target]
local backend=$1 target=$2 tab_id=$3 label=$4 kind=$5 secondmate_home=${6:-} recorded_scoped_target=${7:-} endpoint_home
endpoint_home=$(fm_backend_endpoint_home "$backend" "$kind" "$FM_HOME" "$secondmate_home")
if [ "$endpoint_home" != "$FM_HOME" ]; then
( unset FM_ROOT_OVERRIDE; FM_HOME="$endpoint_home" FM_ROOT="$endpoint_home" fm_backend_kill "$backend" "$target" "$tab_id" "$label" "$recorded_scoped_target" )
else
fm_backend_kill "$backend" "$target" "$tab_id" "$label" "$recorded_scoped_target"
fi
}
spawn_managed_endpoint_state() { # <backend> <target> <label> <kind> <secondmate-home> [recorded-scoped-target]
local backend=$1 target=$2 label=$3 kind=$4 secondmate_home=${5:-} recorded_scoped_target=${6:-} endpoint_home
endpoint_home=$(fm_backend_endpoint_home "$backend" "$kind" "$FM_HOME" "$secondmate_home")
if [ "$endpoint_home" != "$FM_HOME" ]; then
( unset FM_ROOT_OVERRIDE; FM_HOME="$endpoint_home" FM_ROOT="$endpoint_home" fm_backend_target_state "$backend" "$target" "$label" "$recorded_scoped_target" )
else
fm_backend_target_state "$backend" "$target" "$label" "$recorded_scoped_target"
fi
}
git_repository_probe() (
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_CEILING_DIRECTORIES
unset GIT_DISCOVERY_ACROSS_FILESYSTEM GIT_IMPLICIT_WORK_TREE GIT_PREFIX
unset GIT_SUPER_PREFIX GIT_INTERNAL_SUPER_PREFIX GIT_INDEX_FILE
unset GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_NAMESPACE
unset GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_CONFIG_SYSTEM GIT_CONFIG_GLOBAL
command git "$@"
)
git_common_dir_real() {
local repo=$1 common
(
cd "$repo" 2>/dev/null || exit 1
common=$(git_repository_probe rev-parse --git-common-dir 2>/dev/null) || exit 1
cd "$common" 2>/dev/null || exit 1
pwd -P
)
}
git_worktree_dir_real() {
local repo=$1 git_dir
(
cd "$repo" 2>/dev/null || exit 1
git_dir=$(git_repository_probe rev-parse --git-dir 2>/dev/null) || exit 1
cd "$git_dir" 2>/dev/null || exit 1
pwd -P
)
}
git_directory_identity() {
# shellcheck disable=SC2016 # JavaScript source is intentionally single-quoted.
node -e '
const fs = require("fs");
const stat = fs.lstatSync(process.argv[1], { bigint: true });
if (!stat.isDirectory() || stat.isSymbolicLink()) process.exit(1);
process.stdout.write(`${stat.dev}:${stat.ino}`);
' "$1"
}
git_worktree_ref() {
git_repository_probe -C "$1" symbolic-ref -q HEAD 2>/dev/null
}
git_worktree_head() {
git_repository_probe -C "$1" rev-parse --verify 'HEAD^{commit}' 2>/dev/null
}
capture_worktree_git_physical_identity() {
local worktree=$1
WORKTREE_GIT_DIR=$(git_worktree_dir_real "$worktree" 2>/dev/null) || return 1
WORKTREE_GIT_DIR_IDENTITY=$(git_directory_identity "$WORKTREE_GIT_DIR" 2>/dev/null) || return 1
[ -n "$WORKTREE_GIT_DIR" ] && [ -n "$WORKTREE_GIT_DIR_IDENTITY" ]
}
capture_direct_launch_authoritative_state() {
local current_ref current_head expected_ref
current_ref=$(git_worktree_ref "$WT" 2>/dev/null || true)
current_head=$(git_worktree_head "$WT" 2>/dev/null) || return 1
expected_ref="refs/heads/fm/$ID"
WORKTREE_GIT_REF=$expected_ref
WORKTREE_GIT_HEAD=
WORKTREE_GIT_SETUP_REF=
WORKTREE_GIT_SETUP_HEAD=
if [ "$current_ref" != "$expected_ref" ]; then
WORKTREE_GIT_SETUP_REF=$current_ref
WORKTREE_GIT_SETUP_HEAD=$current_head
fi
}
validate_direct_recovery_physical_identity() {
local worktree_real worktree_literal current_git_dir current_git_dir_identity
worktree_real=$(cd "$WT" 2>/dev/null && pwd -P) || worktree_real=
worktree_literal=${WT%/}
if [ -z "$worktree_real" ] || [ "$worktree_literal" != "$worktree_real" ]; then
echo "error: recorded direct account recovery worktree '$WT' is redirected or non-canonical; refusing endpoint creation" >&2
return 1
fi
current_git_dir=$(git_worktree_dir_real "$WT" 2>/dev/null) || current_git_dir=
current_git_dir_identity=$(git_directory_identity "$current_git_dir" 2>/dev/null) || current_git_dir_identity=
if [ -z "$current_git_dir" ] || [ -z "$current_git_dir_identity" ] \
|| [ "$current_git_dir" != "$RECORDED_WORKTREE_GIT_DIR" ] \
|| [ "$current_git_dir_identity" != "$RECORDED_WORKTREE_GIT_DIR_IDENTITY" ]; then
echo "error: recorded direct account recovery worktree '$WT' no longer has its exact Git-dir identity; refusing endpoint creation" >&2
return 1
fi
WORKTREE_GIT_DIR=$current_git_dir
WORKTREE_GIT_DIR_IDENTITY=$current_git_dir_identity
}
validate_direct_recovery_worktree_identity() {
local current_git_ref current_git_head
validate_direct_recovery_physical_identity || return 1
current_git_ref=$(git_worktree_ref "$WT" 2>/dev/null || true)
current_git_head=$(git_worktree_head "$WT" 2>/dev/null) || current_git_head=
if [ -n "$RECORDED_WORKTREE_GIT_SETUP_HEAD" ]; then
if [ "$current_git_ref" = "$RECORDED_WORKTREE_GIT_REF" ]; then
WORKTREE_GIT_REF=$current_git_ref
WORKTREE_GIT_HEAD=
WORKTREE_GIT_SETUP_REF=
WORKTREE_GIT_SETUP_HEAD=
return 0
fi
if [ "$current_git_ref" != "$RECORDED_WORKTREE_GIT_SETUP_REF" ] \
|| [ -z "$current_git_head" ] \
|| [ "$current_git_head" != "$RECORDED_WORKTREE_GIT_SETUP_HEAD" ]; then
echo "error: recorded direct account recovery worktree '$WT' changed branch identity; refusing endpoint creation" >&2
return 1
fi
WORKTREE_GIT_REF=$RECORDED_WORKTREE_GIT_REF
WORKTREE_GIT_HEAD=
WORKTREE_GIT_SETUP_REF=$RECORDED_WORKTREE_GIT_SETUP_REF
WORKTREE_GIT_SETUP_HEAD=$RECORDED_WORKTREE_GIT_SETUP_HEAD
return 0
fi
if [ -n "$RECORDED_WORKTREE_GIT_REF" ]; then
if [ "$current_git_ref" != "$RECORDED_WORKTREE_GIT_REF" ]; then
echo "error: recorded direct account recovery worktree '$WT' changed branch identity; refusing endpoint creation" >&2
return 1
fi
WORKTREE_GIT_REF=$current_git_ref
WORKTREE_GIT_HEAD=
else
if [ -n "$current_git_ref" ] || [ -z "$current_git_head" ] \
|| [ "$current_git_head" != "$RECORDED_WORKTREE_GIT_HEAD" ]; then
echo "error: recorded direct account recovery worktree '$WT' changed detached HEAD identity; refusing endpoint creation" >&2
return 1
fi
WORKTREE_GIT_REF=
WORKTREE_GIT_HEAD=$current_git_head
fi
WORKTREE_GIT_SETUP_REF=
WORKTREE_GIT_SETUP_HEAD=
}
validate_direct_launch_worktree_identity() {
local current_git_dir current_git_dir_identity
current_git_dir=$(git_worktree_dir_real "$WT" 2>/dev/null) || current_git_dir=
current_git_dir_identity=$(git_directory_identity "$current_git_dir" 2>/dev/null) || current_git_dir_identity=
if [ -z "$current_git_dir" ] || [ -z "$current_git_dir_identity" ] \
|| [ "$current_git_dir" != "$WORKTREE_GIT_DIR" ] \
|| [ "$current_git_dir_identity" != "$WORKTREE_GIT_DIR_IDENTITY" ]; then
echo "error: direct account worktree '$WT' changed exact Git-dir identity before metadata install" >&2
return 1
fi
}
# 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=
ACCOUNT_POOL=
ACCOUNT_PROFILE=
NO_ACCOUNT_ROUTING=0
BACKLOG_ROW_EXEMPTION=
BACKLOG_ROW_EXEMPTION_SET=0
RESUME_ACCOUNT=0
CONTINUE_ACCOUNT=0
DIRECT_ACCOUNT_RECOVERY=0
CONTINUATION_LAUNCH_DIR=
CONTINUATION_PROMPT_FILE=
CONTINUATION_PROMPT_DIR_ID=
CONTINUATION_PROMPT_FILE_ID=
CONTINUATION_PROMPT_CONTENT_ID=
HARNESS_SET=0
MODEL_SET=0
EFFORT_SET=0
BACKEND_SET=0
ACCOUNT_POOL_SET=0
ACCOUNT_PROFILE_SET=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 ;;
account-pool) ACCOUNT_POOL=$a; ACCOUNT_POOL_SET=1 ;;
account-profile) ACCOUNT_PROFILE=$a; ACCOUNT_PROFILE_SET=1 ;;
backlog-row-exemption) BACKLOG_ROW_EXEMPTION=$a; BACKLOG_ROW_EXEMPTION_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 ;;
--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 ;;
--account-pool) want_value=account-pool ;;
--account-pool=*) ACCOUNT_POOL=${a#--account-pool=}; ACCOUNT_POOL_SET=1 ;;
--account-profile) want_value=account-profile ;;
--account-profile=*) ACCOUNT_PROFILE=${a#--account-profile=}; ACCOUNT_PROFILE_SET=1 ;;
--no-account-routing) NO_ACCOUNT_ROUTING=1 ;;
--backlog-row-exemption) want_value='backlog-row-exemption' ;;
--backlog-row-exemption=*) BACKLOG_ROW_EXEMPTION=${a#--backlog-row-exemption=}; BACKLOG_ROW_EXEMPTION_SET=1 ;;
--resume-account) RESUME_ACCOUNT=1 ;;
--continue-account) CONTINUE_ACCOUNT=1 ;;
--recover-direct-account) DIRECT_ACCOUNT_RECOVERY=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; }
[ "$ACCOUNT_POOL_SET" -eq 0 ] || [ -n "$ACCOUNT_POOL" ] || { echo "error: --account-pool requires a non-empty value" >&2; exit 1; }
[ "$ACCOUNT_PROFILE_SET" -eq 0 ] || [ -n "$ACCOUNT_PROFILE" ] || { echo "error: --account-profile requires a non-empty value" >&2; exit 1; }
[ "$BACKLOG_ROW_EXEMPTION_SET" -eq 0 ] || [ -n "$BACKLOG_ROW_EXEMPTION" ] \
|| { echo "error: --backlog-row-exemption requires a non-empty value" >&2; exit 1; }
case "$BACKLOG_ROW_EXEMPTION" in
''|test-fixture|tracking-backend-repair) ;;
*)
echo "error: --backlog-row-exemption must be one of test-fixture, tracking-backend-repair" >&2
exit 1
;;
esac
if [ "$NO_ACCOUNT_ROUTING" = 1 ] && { [ "$ACCOUNT_POOL_SET" = 1 ] || [ "$ACCOUNT_PROFILE_SET" = 1 ]; }; then
echo "error: --no-account-routing cannot be combined with --account-pool or --account-profile" >&2
exit 1
fi
[ "$RESUME_ACCOUNT" = 0 ] || [ "$NO_ACCOUNT_ROUTING" = 0 ] || { echo "error: --resume-account cannot disable account routing" >&2; exit 1; }
[ "$CONTINUE_ACCOUNT" = 0 ] || [ "$NO_ACCOUNT_ROUTING" = 0 ] || { echo "error: --continue-account cannot disable account routing" >&2; exit 1; }
[ "$RESUME_ACCOUNT" = 0 ] || [ "$CONTINUE_ACCOUNT" = 0 ] || { echo "error: --resume-account and --continue-account are mutually exclusive" >&2; exit 1; }
if [ $((RESUME_ACCOUNT + CONTINUE_ACCOUNT + DIRECT_ACCOUNT_RECOVERY)) -gt 1 ]; then
echo "error: --resume-account, --continue-account, and --recover-direct-account are mutually exclusive" >&2
exit 1
fi
if [ "$DIRECT_ACCOUNT_RECOVERY" = 1 ]; then
if [ "$KIND_SET" = 1 ] || [ "$HARNESS_SET" = 1 ] || [ "$MODEL_SET" = 1 ] \
|| [ "$EFFORT_SET" = 1 ] || [ "$BACKEND_SET" = 1 ] \
|| [ "$ACCOUNT_POOL_SET" = 1 ] || [ "$ACCOUNT_PROFILE_SET" = 1 ] \
|| [ "$NO_ACCOUNT_ROUTING" = 1 ] || [ "$BACKLOG_ROW_EXEMPTION_SET" = 1 ]; then
echo "error: --recover-direct-account accepts only a task id; task context comes from metadata" >&2
exit 1
fi
fi
[ -z "$ACCOUNT_POOL" ] || fm_account_valid_id "$ACCOUNT_POOL" || { echo "error: invalid --account-pool '$ACCOUNT_POOL'" >&2; exit 1; }
[ -z "$ACCOUNT_PROFILE" ] || fm_account_valid_id "$ACCOUNT_PROFILE" || { echo "error: invalid --account-profile '$ACCOUNT_PROFILE'" >&2; exit 1; }
case "$EFFORT" in
''|low|medium|high|xhigh|max) ;;
*) echo "error: --effort must be one of low, medium, high, xhigh, max" >&2; exit 1 ;;
esac
RECOVERY_ACCOUNT=0
[ "$RESUME_ACCOUNT" = 0 ] && [ "$CONTINUE_ACCOUNT" = 0 ] && [ "$DIRECT_ACCOUNT_RECOVERY" = 0 ] || RECOVERY_ACCOUNT=1
[ "$RECOVERY_ACCOUNT" = 0 ] || [ "$BACKLOG_ROW_EXEMPTION_SET" = 0 ] || {
echo "error: --backlog-row-exemption applies only to a genuinely new ship or scout task" >&2
exit 1
}
RESUME_META=
LIFECYCLE_LOCK=
LIFECYCLE_LOCK_OWNED=0
SECONDMATE_HOME_LIFECYCLE_LOCK=
SECONDMATE_TARGET_HOME_LIFECYCLE_LOCK=
LIFECYCLE_LOCK_INHERITED_PID=
LIFECYCLE_LOCK_INHERITED_START=
SPAWN_META_PRESENT=0
SPAWN_META_SNAPSHOT=
SPAWN_PREFLIGHT_ID=${POS[0]:-}
spawn_idpart=${SPAWN_PREFLIGHT_ID%%=*}
SPAWN_PREFLIGHT_BATCH=0
release_secondmate_home_lifecycle_locks() {
[ -z "${SECONDMATE_TARGET_HOME_LIFECYCLE_LOCK:-}" ] \
|| fm_account_lifecycle_lock_release "$SECONDMATE_TARGET_HOME_LIFECYCLE_LOCK" >/dev/null 2>&1 || true
[ -z "${SECONDMATE_HOME_LIFECYCLE_LOCK:-}" ] \
|| fm_account_lifecycle_lock_release "$SECONDMATE_HOME_LIFECYCLE_LOCK" >/dev/null 2>&1 || true
SECONDMATE_TARGET_HOME_LIFECYCLE_LOCK=
SECONDMATE_HOME_LIFECYCLE_LOCK=
}
if [ -n "$SPAWN_PREFLIGHT_ID" ] && [ "$SPAWN_PREFLIGHT_ID" != "$spawn_idpart" ] \
&& case "$spawn_idpart" in */*) false ;; *) true ;; esac; then
SPAWN_PREFLIGHT_BATCH=1
fi
spawn_preflight_read_meta() { # <meta>
node "$SCRIPT_DIR/fm-contained-read.cjs" "$STATE" "$1" 1048576
}
spawn_preflight_load_meta() { # <required:0|1>
local required=$1 cleanup_count cleanup_value
RESUME_META="$STATE/$SPAWN_PREFLIGHT_ID.meta"
if [ -e "$STATE" ] || [ -L "$STATE" ]; then
[ -d "$STATE" ] && [ ! -L "$STATE" ] || {
echo "error: state directory must be a real directory before spawning: $STATE" >&2
return 1
}
else
[ "$required" = 0 ] || {
echo "error: no metadata for managed recovery at $RESUME_META" >&2
return 1
}
return 0
fi
if [ -e "$RESUME_META" ] || [ -L "$RESUME_META" ]; then
SPAWN_META_SNAPSHOT=$(spawn_preflight_read_meta "$RESUME_META") || {
echo "error: unsafe metadata for spawn preflight at $RESUME_META" >&2
return 1
}
SPAWN_META_PRESENT=1
cleanup_count=$(printf '%s\n' "$SPAWN_META_SNAPSHOT" | grep -c '^orca_cleanup_pending=' || true)
if [ "$cleanup_count" -ne 0 ]; then
cleanup_value=$(spawn_preflight_meta_value orca_cleanup_pending)
if [ "$cleanup_count" -eq 1 ] && [ "$cleanup_value" = 1 ]; then
echo "error: Orca cleanup is pending for $SPAWN_PREFLIGHT_ID; run fm-teardown.sh $SPAWN_PREFLIGHT_ID before retrying spawn" >&2
else
echo "error: invalid Orca cleanup metadata for $SPAWN_PREFLIGHT_ID; refusing spawn" >&2
fi
return 1
fi
elif [ "$required" = 1 ]; then
echo "error: no metadata for managed recovery at $RESUME_META" >&2
return 1
fi
}
spawn_preflight_meta_value() { # <key>
printf '%s\n' "$SPAWN_META_SNAPSHOT" | sed -n "s/^$1=//p" | tail -1
}
spawn_preflight_kind_value() {
local parsed count value
parsed=$(printf '%s\n' "$SPAWN_META_SNAPSHOT" | awk '
index($0, "kind=") == 1 {
count++
value = substr($0, 6)
}
END {
printf "%d\t%s\n", count + 0, value
}
') || return 1
count=${parsed%%$'\t'*}
value=${parsed#*$'\t'}
case "$count:$value" in
0:) printf '%s\n' ship ;;
1:ship|1:scout|1:secondmate) printf '%s\n' "$value" ;;
1:*)
echo "error: managed recovery metadata has invalid kind '$value' for $SPAWN_PREFLIGHT_ID" >&2
return 1
;;
*)
echo "error: managed recovery metadata has duplicate kind records for $SPAWN_PREFLIGHT_ID" >&2
return 1
;;
esac
}
spawn_manual_backlog_has_row() { # <task-id> <backlog-file>
local task_id=$1 backlog=$2
[ -f "$backlog" ] || return 1
awk -v wanted="$task_id" '
/^## (In flight|Queued)[[:space:]]*$/ { active = 1; next }
/^## / { active = 0 }
!active { next }
{
line = $0
sub(/^[[:space:]]*-[[:space:]]*/, "", line)
sub(/^\[[ xX]\][[:space:]]*/, "", line)
if (substr(line, 1, 2) == "**") {
line = substr(line, 3)
closing_pos = index(line, "**")
if (closing_pos == 0) next
key = substr(line, 1, closing_pos - 1)
} else {
key = line
sub(/[[:space:]].*$/, "", key)
}
if (key == wanted) found = 1
}
END { exit(found ? 0 : 1) }
' "$backlog"
}
spawn_backlog_has_row() { # <task-id>
local task_id=$1 backlog task
backlog="$DATA/backlog.md"
if fm_tasks_axi_backend_available "$CONFIG"; then
task=$(tasks-axi show "$task_id" --backend markdown --file "$backlog" 2>/dev/null) || return 1
printf '%s\n' "$task" | grep -Eq '^[[:space:]]*state: (in_flight|queued)[[:space:]]*$'
return $?
fi
spawn_manual_backlog_has_row "$task_id" "$backlog"
}
spawn_shell_quote() { # <value>
printf "'"
printf '%s' "$1" | sed "s/'/'\\\\''/g"
printf "'"
}
spawn_refuse_missing_backlog_row() { # <task-id> <kind> <project-dir>
local task_id=$1 kind=$2 project=$3 repo backlog
repo=${project%/}
repo=${repo##*/}
[ -n "$repo" ] || repo=unknown
backlog="$DATA/backlog.md"
echo "error: new $kind task $task_id has no In flight or Queued row in $backlog; file it before dispatch." >&2
printf 'fix: tasks-axi add %s %s --kind %s --repo %s --start --backend markdown --file %s\n' \
"$(spawn_shell_quote "$task_id")" "$(spawn_shell_quote '<one line>')" \
"$(spawn_shell_quote "$kind")" "$(spawn_shell_quote "$repo")" \
"$(spawn_shell_quote "$backlog")" >&2
}
spawn_refuse_report_required_orca() {
local report_count
if [ "$SPAWN_META_PRESENT" != 1 ]; then
echo "error: backend=orca cannot host new report-required tasks: Orca has no reliable endpoint-absence proof, so report-gated teardown could never complete; spawn report-required work on tmux, herdr, zellij, or cmux" >&2
return 1
fi
report_count=$(printf '%s\n' "$SPAWN_META_SNAPSHOT" | grep -c '^report_required=' || true)
[ "$report_count" -eq 0 ] || {
if [ "$report_count" -eq 1 ] && [ "$(spawn_preflight_meta_value report_required)" = 1 ]; then
echo "error: backend=orca cannot host new report-required tasks: Orca has no reliable endpoint-absence proof, so report-gated teardown could never complete; spawn report-required work on tmux, herdr, zellij, or cmux" >&2
else
echo "error: invalid report_required metadata for $SPAWN_PREFLIGHT_ID; legacy Orca recovery requires the marker to be absent" >&2
fi
return 1
}
}
reconcile_failed_direct_recovery() {
local task=$1 meta="$STATE/$1.meta" lock marker generation backend target tmux_session_target tab kind home tasktmp
local backup_name backup_token backup backup_snapshot artifacts_name artifacts_token artifacts endpoint_state tmp current_backup_snapshot
lock=$(fm_account_meta_lock_acquire "$STATE" "$task") || return 1
marker=$(fm_account_meta_value "$meta" direct_recovery_cleanup)
if [ "$marker" != pending ]; then
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
echo "error: invalid retained direct recovery state for $task" >&2
return 1
fi
generation=$(fm_account_meta_value "$meta" generation_id)
backend=$(fm_backend_of_meta "$meta")
target=$(fm_backend_target_of_meta "$meta")
tmux_session_target=$(fm_account_meta_value "$meta" tmux_session_target)
tab=$(fm_account_meta_value "$meta" zellij_tab_id)
kind=$(fm_account_meta_value "$meta" kind)
[ -n "$kind" ] || kind=ship
home=$(fm_account_meta_value "$meta" home)
tasktmp=$(fm_account_meta_value "$meta" tasktmp)
backup_name=$(fm_account_meta_value "$meta" direct_recovery_backup)
artifacts_name=$(fm_account_meta_value "$meta" direct_recovery_artifacts)
case "$backup_name" in
".$task.meta.rollback."*) ;;
*)
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
echo "error: unsafe retained direct recovery backup for $task" >&2
return 1
;;
esac
backup_token=${backup_name#".$task.meta.rollback."}
if ! fm_account_valid_id "$backup_token"; then
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
echo "error: unsafe retained direct recovery backup for $task" >&2
return 1
fi
case "$artifacts_name" in
".$task.artifacts.rollback."*) ;;
*)
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
echo "error: unsafe retained direct recovery artifacts for $task" >&2
return 1
;;
esac
artifacts_token=${artifacts_name#".$task.artifacts.rollback."}
if ! fm_account_valid_id "$artifacts_token"; then
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
echo "error: unsafe retained direct recovery artifacts for $task" >&2
return 1
fi
backup="$STATE/$backup_name"
artifacts="$STATE/$artifacts_name"
backup_snapshot=$(spawn_preflight_read_meta "$backup") || {
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
echo "error: retained direct recovery backup is missing or unsafe for $task" >&2
return 1
}
if [ ! -d "$artifacts" ] || [ -L "$artifacts" ]; then
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
echo "error: retained direct recovery artifacts are missing or unsafe for $task" >&2
return 1
fi
if ! fm_account_task_tmp_is_expected "$task" "$tasktmp" "$generation" \
|| [ -z "$generation" ] || [ -z "$target" ]; then
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
echo "error: retained direct recovery metadata is incomplete for $task" >&2
return 1
fi
fm_account_meta_lock_release "$lock" || return 1
lock=
spawn_managed_endpoint_kill "$backend" "$target" "$tab" "fm-$task" "$kind" "$home" "$tmux_session_target" 2>/dev/null || true
endpoint_state=$(spawn_managed_endpoint_state "$backend" "$target" "fm-$task" "$kind" "$home" "$tmux_session_target" 2>/dev/null)
case "$endpoint_state" in
absent) ;;
present)
echo "error: retained direct recovery endpoint is still alive for $task" >&2
return 1
;;
*)
echo "error: retained direct recovery endpoint state is unknown for $task" >&2
return 1
;;
esac
lock=$(fm_account_meta_lock_acquire "$STATE" "$task") || return 1
current_backup_snapshot=$(spawn_preflight_read_meta "$backup" 2>/dev/null) || current_backup_snapshot=
if [ ! -f "$meta" ] \
|| [ "$(fm_account_meta_value "$meta" direct_recovery_cleanup)" != pending ] \
|| [ "$(fm_account_meta_value "$meta" generation_id)" != "$generation" ] \
|| [ "$(fm_backend_of_meta "$meta")" != "$backend" ] \
|| [ "$(fm_backend_target_of_meta "$meta")" != "$target" ] \
|| [ "$(fm_account_meta_value "$meta" direct_recovery_backup)" != "$backup_name" ] \
|| [ "$(fm_account_meta_value "$meta" direct_recovery_artifacts)" != "$artifacts_name" ] \
|| [ "$current_backup_snapshot" != "$backup_snapshot" ] \
|| [ ! -d "$artifacts" ] || [ -L "$artifacts" ]; then
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
echo "error: retained direct recovery generation changed before cleanup for $task" >&2
return 1
fi
if ! fm_account_restore_artifacts "$STATE" "$task" "$artifacts_name" "$tasktmp" 1 "$generation"; then
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
echo "error: retained direct recovery artifacts could not be restored for $task" >&2
return 1
fi
tmp=$(mktemp "$STATE/.$task.meta.direct-recovery-restore.XXXXXX") || {
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
return 1
}
if ! printf '%s\n' "$backup_snapshot" > "$tmp" \
|| ! fm_account_meta_merge_extensions "$meta" "$tmp" \
|| ! fm_account_safe_file_destination "$meta" \
|| ! mv "$tmp" "$meta"; then
rm -f "$tmp"
fm_account_meta_lock_release "$lock" >/dev/null 2>&1 || true
echo "error: retained direct recovery metadata could not be restored for $task" >&2
return 1
fi
rm -f "$backup" || echo "warning: retained direct recovery backup remains for $task" >&2
rm -rf "$artifacts" || echo "warning: retained direct recovery artifacts remain for $task" >&2
fm_account_meta_lock_release "$lock" || return 1
SPAWN_META_SNAPSHOT=$(spawn_preflight_read_meta "$meta") || return 1
SPAWN_META_PRESENT=1
echo "fm-spawn: cleaned retained direct recovery endpoint for $task" >&2
}
spawn_refuse_existing_orca_provider_identity() {
[ "$SPAWN_META_PRESENT" != 1 ] || [ "$(spawn_preflight_meta_value backend)" != orca ] || {
echo "error: existing Orca provider identity for $SPAWN_PREFLIGHT_ID must be cleared by teardown before respawn" >&2
return 1
}
}
spawn_refuse_unsupported_secondmate_backend() {
[ "$KIND" != secondmate ] || [ "$BACKEND" != orca ] || {
echo "error: backend=orca does not support --secondmate spawns yet" >&2
return 1
}
[ "$KIND" != secondmate ] || [ "$BACKEND" != cmux ] || {
echo "error: backend=cmux does not support --secondmate spawns yet" >&2
return 1
}
}
if [ "$RECOVERY_ACCOUNT" = 1 ]; then
[ "${#POS[@]}" -ge 1 ] || { echo "error: account recovery requires a task id" >&2; exit 1; }
case "$SPAWN_PREFLIGHT_ID" in *=*) echo "error: account recovery does not support batch syntax" >&2; exit 1 ;; esac
if [ "$DIRECT_ACCOUNT_RECOVERY" = 1 ] && [ "${#POS[@]}" -ne 1 ]; then
echo "error: --recover-direct-account accepts exactly one task id" >&2
exit 1
fi
spawn_preflight_load_meta 1 || exit 1
recorded_kind=$(spawn_preflight_kind_value) || exit 1
if [ "$KIND" != ship ] && [ "$KIND" != "$recorded_kind" ]; then
echo "error: account recovery kind '$KIND' does not match recorded kind '$recorded_kind'" >&2
exit 1
fi
KIND=$recorded_kind
if [ "$DIRECT_ACCOUNT_RECOVERY" = 1 ]; then
case "$KIND" in
ship|scout) ;;
*) echo "error: --recover-direct-account supports only recorded ship or scout tasks" >&2; exit 1 ;;
esac
fi
recorded_backend=$(spawn_preflight_meta_value backend)
[ -n "$recorded_backend" ] || recorded_backend=tmux
if [ "$BACKEND_SET" = 1 ] && [ "$BACKEND_ARG" != "$recorded_backend" ]; then
echo "error: account recovery backend override '$BACKEND_ARG' does not match recorded backend '$recorded_backend'" >&2
exit 1
fi
BACKEND_ARG=$recorded_backend
BACKEND_SET=1
fi
if [ "$BACKEND_SET" -eq 1 ]; then
BACKEND=$BACKEND_ARG
else
BACKEND=$(fm_backend_name)
fi
fm_backend_validate_spawn "$BACKEND" || exit 1
fm_backend_source "$BACKEND" || exit 1
spawn_refuse_unsupported_secondmate_backend || exit 1
if [ "$BACKEND" = orca ] && [ "$RECOVERY_ACCOUNT" = 0 ] && [ "$SPAWN_PREFLIGHT_BATCH" = 0 ] \
&& { [ -e "$STATE/$SPAWN_PREFLIGHT_ID.meta" ] || [ -L "$STATE/$SPAWN_PREFLIGHT_ID.meta" ]; }; then
spawn_preflight_load_meta 0 || exit 1
fi
if [ "$BACKEND" = orca ] && [ "$SPAWN_PREFLIGHT_BATCH" = 0 ] && [ "$SPAWN_META_PRESENT" = 1 ]; then
spawn_refuse_report_required_orca || exit 1
[ "$DIRECT_ACCOUNT_RECOVERY" = 1 ] || spawn_refuse_existing_orca_provider_identity || exit 1
fi
if [ "$BACKEND" = orca ] && { [ "$RECOVERY_ACCOUNT" = 0 ] || [ "$DIRECT_ACCOUNT_RECOVERY" = 1 ]; }; then
fm_backend_orca_runtime_check || exit 1
fi
if [ "$RECOVERY_ACCOUNT" = 0 ] && [ "$SPAWN_PREFLIGHT_BATCH" = 0 ] && [ "$SPAWN_META_PRESENT" = 0 ]; then
spawn_preflight_load_meta 0 || exit 1
fi
if [ "$BACKEND" = orca ] && [ "$SPAWN_PREFLIGHT_BATCH" = 0 ]; then
spawn_refuse_report_required_orca || exit 1
[ "$DIRECT_ACCOUNT_RECOVERY" = 1 ] || spawn_refuse_existing_orca_provider_identity || exit 1
fi
if [ "$SPAWN_PREFLIGHT_BATCH" = 0 ]; then
SECONDMATE_HOME_LIFECYCLE_LOCK=$(fm_secondmate_home_lifecycle_lock_acquire "$CHECKOUT_LOCK_ROOT" "$FM_HOME") || exit 1
trap 'release_secondmate_home_lifecycle_locks' EXIT
fm_checkout_trusted_dir "$FM_HOME" >/dev/null || {
echo "error: active firstmate home was removed or redirected while spawn waited for lifecycle ownership" >&2
exit 1
}
if [ -e "$FM_HOME/$SUB_HOME_MARKER" ] || [ -L "$FM_HOME/$SUB_HOME_MARKER" ]; then
[ -f "$FM_HOME/$SUB_HOME_MARKER" ] && [ ! -L "$FM_HOME/$SUB_HOME_MARKER" ] || {
echo "error: unsafe secondmate home marker at $FM_HOME/$SUB_HOME_MARKER" >&2
exit 1
}
elif [ -f "$FM_HOME/data/charter.md" ]; then
echo "error: secondmate home changed while spawn waited for lifecycle ownership" >&2
exit 1
fi
fi
if [ -n "${FM_ACCOUNT_LIFECYCLE_LOCK_HELD:-}" ]; then
[ "${#POS[@]}" -ge 1 ] || { echo "error: inherited account lifecycle lock requires a task id" >&2; exit 1; }
inherited_lock_id=${POS[0]}
case "$inherited_lock_id" in *=*) echo "error: inherited account lifecycle lock does not support batch syntax" >&2; exit 1 ;; esac
expected_lifecycle_lock="$STATE/.account-lifecycle-$inherited_lock_id.lock"
inherited_lock_identity=
if [ "$FM_ACCOUNT_LIFECYCLE_LOCK_HELD" = "$expected_lifecycle_lock" ]; then
inherited_lock_identity=$(fm_account_lifecycle_lock_identity "$FM_ACCOUNT_LIFECYCLE_LOCK_HELD" 2>/dev/null) || inherited_lock_identity=
fi
case "$inherited_lock_identity" in
*$'\n'*)
LIFECYCLE_LOCK_INHERITED_PID=${inherited_lock_identity%%$'\n'*}
LIFECYCLE_LOCK_INHERITED_START=${inherited_lock_identity#*$'\n'}
;;
*)
echo "error: invalid inherited account lifecycle lock for $inherited_lock_id" >&2
exit 1
;;
esac
if [ -z "$LIFECYCLE_LOCK_INHERITED_PID" ] || [ -z "$LIFECYCLE_LOCK_INHERITED_START" ]; then
echo "error: invalid inherited account lifecycle lock for $inherited_lock_id" >&2
exit 1
fi
LIFECYCLE_LOCK=$FM_ACCOUNT_LIFECYCLE_LOCK_HELD
if [ ! -f "$LIFECYCLE_LOCK" ] || [ -L "$LIFECYCLE_LOCK" ]; then
echo "error: inherited account lifecycle lock for $inherited_lock_id cannot transfer ownership" >&2
exit 1
fi
lifecycle_handoff_start=$(fm_account_process_start_time "$$") || {
echo "error: cannot record inherited account lifecycle lock handoff for $inherited_lock_id" >&2
exit 1
}
lifecycle_handoff_tmp=$(mktemp "$STATE/.account-lifecycle-$inherited_lock_id.handoff.XXXXXX") || exit 1
if ! printf '%s\n%s\n' "$$" "$lifecycle_handoff_start" > "$lifecycle_handoff_tmp"; then
rm -f "$lifecycle_handoff_tmp"
exit 1
fi
current_lock_identity=$(fm_account_lifecycle_lock_identity "$LIFECYCLE_LOCK" 2>/dev/null || true)
if [ "$current_lock_identity" != "$inherited_lock_identity" ] \
|| [ ! -f "$LIFECYCLE_LOCK" ] || [ -L "$LIFECYCLE_LOCK" ] \
|| ! mv "$lifecycle_handoff_tmp" "$LIFECYCLE_LOCK"; then
rm -f "$lifecycle_handoff_tmp"
echo "error: inherited account lifecycle lock was lost before ownership handoff for $inherited_lock_id" >&2
exit 1
fi
LIFECYCLE_LOCK_OWNED=1
trap '[ "${LIFECYCLE_LOCK_OWNED:-0}" != 1 ] || [ -z "${LIFECYCLE_LOCK:-}" ] || fm_account_lifecycle_lock_release "$LIFECYCLE_LOCK" >/dev/null 2>&1 || true; release_secondmate_home_lifecycle_locks' EXIT
# The handoff replaces the lock inode while live ownership prevents reclaim until this child releases the replacement.
if ! fm_account_lifecycle_lock_owned "$LIFECYCLE_LOCK"; then
echo "error: inherited account lifecycle lock ownership handoff failed for $inherited_lock_id" >&2
exit 1
fi
fi
if [ "$RECOVERY_ACCOUNT" = 1 ]; then
[ -f "$RESUME_META" ] || { echo "error: no metadata for managed recovery at $RESUME_META" >&2; exit 1; }
fm_account_safe_file_destination "$RESUME_META" || { echo "error: unsafe metadata for managed recovery at $RESUME_META" >&2; exit 1; }
if [ -z "$LIFECYCLE_LOCK" ]; then
LIFECYCLE_LOCK=$(fm_account_lifecycle_lock_acquire "$STATE" "${POS[0]}") || exit 1
LIFECYCLE_LOCK_OWNED=1
fi
trap '[ "${LIFECYCLE_LOCK_OWNED:-0}" != 1 ] || [ -z "${LIFECYCLE_LOCK:-}" ] || fm_account_lifecycle_lock_release "$LIFECYCLE_LOCK" >/dev/null 2>&1 || true; release_secondmate_home_lifecycle_locks' EXIT
current_spawn_meta=$(spawn_preflight_read_meta "$RESUME_META") || {
echo "error: unsafe metadata for managed recovery at $RESUME_META" >&2
exit 1
}
# The early snapshot owns preflight refusals only. Once lifecycle ownership
# serializes recovery, refresh it so a waiter validates the committed
# replacement generation instead of rejecting that generation as stale.
SPAWN_META_SNAPSHOT=$current_spawn_meta
current_recorded_kind=$(spawn_preflight_kind_value) || exit 1
[ "$current_recorded_kind" = "$KIND" ] || {
echo "error: managed recovery kind changed before launch for ${POS[0]}" >&2
exit 1
}
rm -rf "$STATE/.${POS[0]}.account-native-launch" "$STATE/.${POS[0]}.account-native-ready" "$STATE/.${POS[0]}.account-native-go" || exit 1
direct_recovery_cleanup=$(fm_account_meta_value "$RESUME_META" direct_recovery_cleanup)
if [ -n "$direct_recovery_cleanup" ]; then
[ "$DIRECT_ACCOUNT_RECOVERY" = 1 ] || {
echo "error: retained direct recovery state exists for ${POS[0]}; use --recover-direct-account" >&2
exit 1
}
[ "$direct_recovery_cleanup" = pending ] || {
echo "error: invalid retained direct recovery state for ${POS[0]}" >&2
exit 1
}
reconcile_failed_direct_recovery "${POS[0]}" || exit 1
fi
if [ "$(fm_account_meta_value "$RESUME_META" account_rollback_cleanup)" = pending ]; then
rollback_id=${POS[0]}
rollback_account_task=$(fm_account_meta_value "$RESUME_META" account_task)
rollback_meta_lock=$(fm_account_meta_lock_acquire "$STATE" "$rollback_id") || exit 1
if [ ! -f "$RESUME_META" ] || [ "$(fm_account_meta_value "$RESUME_META" account_rollback_cleanup)" != pending ] \
|| [ "$(fm_account_meta_value "$RESUME_META" account_task)" != "$rollback_account_task" ]; then
fm_account_meta_lock_release "$rollback_meta_lock" >/dev/null 2>&1 || true
echo "error: managed task generation changed before rollback cleanup for $rollback_id" >&2
exit 1
fi
rollback_kind=$(fm_account_meta_value "$RESUME_META" kind)
[ -n "$rollback_kind" ] || rollback_kind=ship
rollback_backend=$(fm_backend_of_meta "$RESUME_META")
rollback_target=$(fm_backend_target_of_meta "$RESUME_META")
rollback_tmux_session_target=$(fm_account_meta_value "$RESUME_META" tmux_session_target)
[ -n "$rollback_tmux_session_target" ] || rollback_tmux_session_target=$(fm_account_meta_value "$RESUME_META" window)
rollback_tab=$(fm_account_meta_value "$RESUME_META" zellij_tab_id)
rollback_home=$(fm_account_meta_value "$RESUME_META" home)
rollback_tasktmp=$(fm_account_meta_value "$RESUME_META" tasktmp)
rollback_generation=$(fm_account_meta_value "$RESUME_META" generation_id)
rollback_backup=$(fm_account_meta_value "$RESUME_META" account_rollback_backup)
fm_account_meta_lock_release "$rollback_meta_lock" || exit 1
rollback_meta_lock=
if [ -n "$rollback_tasktmp" ] && ! fm_account_task_tmp_is_expected "$rollback_id" "$rollback_tasktmp" "$rollback_generation"; then
echo "error: unsafe task temp path in rollback metadata for $rollback_id: $rollback_tasktmp" >&2
exit 1
fi
if [ -n "$rollback_target" ]; then
spawn_managed_endpoint_kill "$rollback_backend" "$rollback_target" "$rollback_tab" "fm-$rollback_id" "$rollback_kind" "$rollback_home" "$rollback_tmux_session_target" 2>/dev/null || true
fi
rollback_endpoint_state=$(spawn_managed_endpoint_state "$rollback_backend" "$rollback_target" "fm-$rollback_id" "$rollback_kind" "$rollback_home" "$rollback_tmux_session_target" 2>/dev/null)
case "$rollback_endpoint_state" in
absent) ;;
present)
echo "error: failed Agent Fleet attempt endpoint is still alive for $rollback_id; retaining its lease and metadata" >&2
exit 1
;;
*)
echo "error: failed Agent Fleet attempt endpoint state is unknown for $rollback_id; retaining its lease and metadata" >&2
exit 1
;;
esac
if ! fm_account_cleanup_rollback "$RESUME_META" "$DATA" "$rollback_id"; then
echo "error: failed Agent Fleet attempt cleanup remains pending for $rollback_id" >&2
exit 1
fi
rollback_profile=$(fm_account_meta_value "$RESUME_META" account_profile)
if [ -z "$rollback_profile" ] && [ "$rollback_kind" = secondmate ] && [ -z "$rollback_backup" ]; then
rm -f "$RESUME_META" "$STATE/$rollback_id.status" "$STATE/$rollback_id.turn-ended" "$STATE/$rollback_id.check.sh" "$STATE/$rollback_id.pi-ext.ts" "$STATE/$rollback_id.grok-turnend-token"
if [ -n "$rollback_tasktmp" ] \
&& { fm_account_task_tmp_is_current "$rollback_id" "$rollback_tasktmp" "$rollback_generation" \
|| fm_account_task_tmp_is_previous "$rollback_id" "$rollback_tasktmp"; }; then
fm_account_safe_remove_task_tmp "$rollback_id" "$rollback_tasktmp" "$rollback_generation" || exit 1
fi
fi
if [ -z "$rollback_profile" ]; then
if [ -n "$rollback_backup" ]; then
echo "error: failed Agent Fleet attempt was cleaned for $rollback_id and the previous task state was restored; rerun against the restored task generation" >&2
elif [ "$rollback_kind" = secondmate ]; then
echo "error: failed Agent Fleet attempt was cleaned for $rollback_id; retry the secondmate spawn without tearing down its home" >&2
else
echo "error: failed Agent Fleet attempt was cleaned for $rollback_id; tear down its retained worktree before spawning again" >&2
fi
exit 1
fi
fi
if [ "$(fm_account_meta_value "$RESUME_META" account_predecessor_cleanup)" = pending ]; then