From d3a4ba8561f12ee9a7194e8da0025d8fdb4c8658 Mon Sep 17 00:00:00 2001 From: Sungin Kim Date: Fri, 11 Sep 2026 19:47:49 +0000 Subject: [PATCH 1/6] Allow design workers to restart through the existing relaunch verb. A live ADR task kept the same endpoint, worktree, inbox, and dispatch-pinned skills instead of being refused or rebound to a later plugin release. Co-authored-by: Cursor --- .agents/skills/design-profile/SKILL.md | 1 + bin/fm-control.sh | 46 +++++-- bin/fm-spawn.sh | 104 ++++++++++++--- docs/agent-control.md | 9 +- docs/fleet-data-contracts.md | 2 +- docs/verification/runtime-backends.md | 14 ++ tests/fm-control-relaunch.test.sh | 176 +++++++++++++++++++++++++ 7 files changed, 314 insertions(+), 38 deletions(-) diff --git a/.agents/skills/design-profile/SKILL.md b/.agents/skills/design-profile/SKILL.md index 092cd9cb3cb..a2217ca6589 100644 --- a/.agents/skills/design-profile/SKILL.md +++ b/.agents/skills/design-profile/SKILL.md @@ -62,6 +62,7 @@ Do not infer or pin a model because each harness's current authenticated catalog Scaffold with `bin/fm-brief.sh --design --mode ` plus any applicable work-item and Herdr flags. Spawn with `bin/fm-spawn.sh --design --mode --yolo --harness ` plus the selected model and effort when present. +Restart a live design worker with `bin/fm-control.sh relaunch`, which keeps `kind=design` and the dispatch-pinned skill release ([`docs/agent-control.md`](../../../docs/agent-control.md#transactional-relaunch); [`docs/fleet-data-contracts.md`](../../../docs/fleet-data-contracts.md#the-design-tasks-plugin-release)). ## Interview authority diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 3cf1c3122f3..1bcf995815f 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -38,13 +38,13 @@ # axis for the replacement. With no explicit axis, a secondmate # re-resolves its durable config/secondmate-harness pin (harness # plus its optional model and effort tokens) exactly as any other -# respawn does, while a ship or scout keeps the exact adapter -# already recorded for it. +# respawn does, while a ship, design, or scout keeps the exact +# adapter already recorded for it. # A prefixed raw-command basename cannot reconstruct its launch # command, so relaunch requires an explicit --harness for it. -# --note is required for a ship or scout, whose replacement -# inherits the local copy but none of the conversation; a -# secondmate reconciles its own home's records at startup, so its +# --note is required for a ship, design, or scout, whose +# replacement inherits the local copy but none of the conversation; +# a secondmate reconciles its own home's records at startup, so its # standing charter is never rewritten. # Records a durable checkpoint and that note, exits the old agent, # then delegates the launch to its single owner, @@ -644,10 +644,10 @@ resolve_relaunch_profile() { # A secondmate's harness, model, and effort are a durable configured pin # that every respawn re-resolves (the secondmate-provisioning contract), so # a relaunch with no explicit harness picks up a newly configured one - # instead of freezing whatever this incarnation happens to run. Crewmates - # and scouts deliberately do NOT resolve config here: their harness comes - # from firstmate's own dispatch-profile judgment at intake, and silently - # re-resolving it would bypass that consultation. + # instead of freezing whatever this incarnation happens to run. Crewmates, + # design workers, and scouts deliberately do NOT resolve config here: their + # harness comes from firstmate's own dispatch-profile judgment at intake, + # and silently re-resolving it would bypass that consultation. CONFIG_HARNESS=$("$SCRIPT_DIR/fm-harness.sh" secondmate 2>/dev/null || true) CONFIG_MODEL=$("$SCRIPT_DIR/fm-harness.sh" secondmate-model 2>/dev/null || true) CONFIG_EFFORT=$("$SCRIPT_DIR/fm-harness.sh" secondmate-effort 2>/dev/null || true) @@ -765,8 +765,27 @@ safe_checkpoint() { fi } +# require_recorded_design_skill_pin: a design relaunch must keep the dispatch +# pin already recorded for this task. Presence is proved here, before the +# agent is stopped, so a missing pin refuses with nothing changed; spawn +# reuses that same recorded release rather than resolving the plugin again +# (docs/fleet-data-contracts.md "The design task's plugin release"). +require_recorded_design_skill_pin() { + local plugin version updated tasktmp dispatch_brief + plugin=$(fm_meta_get "$META" design_skills_plugin) + version=$(fm_meta_get "$META" design_skills_version) + updated=$(fm_meta_get "$META" design_skills_updated) + [ -n "$plugin" ] && [ -n "$version" ] && [ -n "$updated" ] \ + || die "task $ID has no recorded design-skill release; refusing to relaunch rather than resolving a different plugin pin" + tasktmp=$(fm_meta_get "$META" tasktmp) + [ -n "$tasktmp" ] || tasktmp="/tmp/fm-$ID" + dispatch_brief="$tasktmp/brief.md" + [ -f "$dispatch_brief" ] && [ ! -L "$dispatch_brief" ] \ + || die "task $ID has no dispatch-pinned design brief at $dispatch_brief; refusing to relaunch rather than resolving a different plugin release" +} + # record_note: put the required progress note somewhere durable, and - for a -# ship or scout, whose only record of the interrupted reasoning is the +# ship, design, or scout, whose only record of the interrupted reasoning is the # conversation about to be discarded - into the instructions the replacement # actually reads. A secondmate's charter is a durable standing document and is # never rewritten: a secondmate reconciles its own home's records at startup, @@ -777,7 +796,7 @@ record_note() { stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) printf '%s\n' "$NOTE" > "$NOTE_FILE" case "$KIND" in - ship|scout) + ship|design|scout) cp -p "$RELAUNCH_BRIEF" "$BRIEF_PRIOR" \ || die "could not preserve task $ID's instructions before recording the progress note" { @@ -806,12 +825,15 @@ do_relaunch() { resolve_relaunch_profile case "$KIND" in - ship|scout) + ship|design|scout) RELAUNCH_BRIEF="$DATA/$ID/brief.md" [ -f "$RELAUNCH_BRIEF" ] \ || die "task $ID has no instructions at $RELAUNCH_BRIEF; refusing to relaunch a worker with nothing to work from" [ "$NOTE_SET" = 1 ] && [ -n "$NOTE" ] \ || die "relaunch of a $KIND task requires --note (or --note-file): the replacement worker inherits the local copy but none of the conversation, so it must be told what happened" + if [ "$KIND" = design ]; then + require_recorded_design_skill_pin + fi ;; secondmate) # The charter in the secondmate's own home is its instruction source and diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 4e23dba41b1..a2cd970956e 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -43,7 +43,7 @@ # 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/.meta, so --backend, --scout, --secondmate, a project +# validated state/.meta, so --backend, --scout, --design, --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 @@ -179,9 +179,12 @@ # config reread generations because the new agent reads the converged files. # --design records kind=design in the task's meta (interactive ADR deliverable; # see the design-profile skill) and, from the one bin/fm-design-skills.sh -# resolve that gates the dispatch, records design_skills_plugin=, +# resolve that gates a fresh dispatch, records design_skills_plugin=, # design_skills_version=, and design_skills_updated= so the auto-updating -# plugin release that informed the interview stays readable after cleanup; +# plugin release that informed the interview stays readable after cleanup. +# A --relaunch of that same design task reuses the recorded release and the +# dispatch-pinned skill paths rather than resolving again +# (docs/fleet-data-contracts.md "The design task's plugin release"). # --scout records kind=scout (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. @@ -557,7 +560,7 @@ esac # 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; } + [ "$KIND_SET" -eq 0 ] || { echo "error: --relaunch reuses the task's recorded kind; --scout/--design/--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 @@ -1978,8 +1981,10 @@ SOURCE_BRIEF=$BRIEF # resolve call serves as both the dispatch gate and the provenance record, so # what is recorded is exactly what was verified present at dispatch. Reading the # plugin later - at cleanup, say - could name a version that only arrived after -# the interview ended, which is worse than recording none. A relaunch resolves -# again, so the recorded value always names this task's most recent dispatch. +# the interview ended, which is worse than recording none. A relaunch of the +# same design task reuses that recorded release and the worker-facing pinned +# paths rather than resolving again +# (docs/fleet-data-contracts.md "The design task's plugin release"). # The resolver's own refusal already names the missing install or skill. DESIGN_SKILLS_PLUGIN= DESIGN_SKILLS_VERSION= @@ -2010,22 +2015,45 @@ design_skill_path_is_safe() { # esac [ "$(printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177')" = "$1" ] } -if [ "$KIND" = design ]; then - DESIGN_SKILLS_RECORD=$("$FM_ROOT/bin/fm-design-skills.sh" resolve) || { - echo "error: design spawn requires the captain-installed mattpocock design skills; do not install or copy them from a worker" >&2 - exit 1 +# adopt_relaunch_design_skills: reuse the dispatch pin already recorded for +# this design task. Never call fm-design-skills.sh resolve here; a later +# plugin auto-update must not silently rebind the interview. +adopt_relaunch_design_skills() { + local recorded_plugin recorded_version recorded_updated tasktmp dispatch_brief + local binding schema + recorded_plugin=$(fm_meta_get "$RELAUNCH_META" design_skills_plugin) + recorded_version=$(fm_meta_get "$RELAUNCH_META" design_skills_version) + recorded_updated=$(fm_meta_get "$RELAUNCH_META" design_skills_updated) + if [ -z "$recorded_plugin" ] || [ -z "$recorded_version" ] \ + || [ -z "$recorded_updated" ]; then + echo "error: task $ID has no recorded design-skill release; refusing to relaunch rather than resolving a different plugin pin" >&2 + return 1 + fi + tasktmp=$(fm_meta_get "$RELAUNCH_META" tasktmp) + [ -n "$tasktmp" ] || tasktmp="/tmp/fm-$ID" + dispatch_brief="$tasktmp/brief.md" + if [ ! -f "$dispatch_brief" ] || [ -L "$dispatch_brief" ]; then + echo "error: task $ID has no dispatch-pinned design brief at $dispatch_brief; refusing to relaunch rather than resolving a different plugin release" >&2 + return 1 + fi + binding=$(sed -n '/^```json$/{n;p;q;}' "$dispatch_brief") + schema=$(printf '%s\n' "$binding" | jq -r '.schema // empty' 2>/dev/null) || schema= + [ "$schema" = fm-design-skills.dispatch.v1 ] || { + echo "error: task $ID's dispatch-pinned design brief is not a usable skill binding; refusing to relaunch rather than resolving a different plugin release" >&2 + return 1 } - DESIGN_SKILLS_PLUGIN=$(design_skills_field "$DESIGN_SKILLS_RECORD" plugin) - DESIGN_SKILLS_VERSION=$(design_skills_field "$DESIGN_SKILLS_RECORD" version) - DESIGN_SKILLS_UPDATED=$(design_skills_field "$DESIGN_SKILLS_RECORD" last_updated) - DESIGN_SKILLS_GRILLING=$(design_skill_path "$DESIGN_SKILLS_RECORD" grilling) || DESIGN_SKILLS_GRILLING= - DESIGN_SKILLS_DOMAIN_MODELING=$(design_skill_path "$DESIGN_SKILLS_RECORD" domain_modeling) || DESIGN_SKILLS_DOMAIN_MODELING= - if [ -z "$DESIGN_SKILLS_PLUGIN" ] || [ -z "$DESIGN_SKILLS_VERSION" ] \ - || [ -z "$DESIGN_SKILLS_UPDATED" ] \ + DESIGN_SKILLS_PLUGIN=$(design_skills_field "$binding" plugin) + DESIGN_SKILLS_VERSION=$(design_skills_field "$binding" version) + DESIGN_SKILLS_UPDATED=$(design_skills_field "$binding" last_updated) + DESIGN_SKILLS_GRILLING=$(design_skill_path "$binding" grilling) || DESIGN_SKILLS_GRILLING= + DESIGN_SKILLS_DOMAIN_MODELING=$(design_skill_path "$binding" domain_modeling) || DESIGN_SKILLS_DOMAIN_MODELING= + if [ "$DESIGN_SKILLS_PLUGIN" != "$recorded_plugin" ] \ + || [ "$DESIGN_SKILLS_VERSION" != "$recorded_version" ] \ + || [ "$DESIGN_SKILLS_UPDATED" != "$recorded_updated" ] \ || ! design_skill_path_is_safe "$DESIGN_SKILLS_GRILLING" \ || ! design_skill_path_is_safe "$DESIGN_SKILLS_DOMAIN_MODELING"; then - echo "error: the installed mattpocock plugin resolved without a usable identity, version, update stamp, and absolute skill paths, so this design task's inputs could not be pinned and recorded; refusing rather than dispatching an untraceable design" >&2 - exit 1 + echo "error: task $ID's dispatch-pinned design skills do not match its recorded release; refusing to relaunch rather than substituting another plugin pin" >&2 + return 1 fi DESIGN_SKILLS_BINDING=$(jq -cn \ --arg plugin "$DESIGN_SKILLS_PLUGIN" \ @@ -2036,9 +2064,43 @@ if [ "$KIND" = design ]; then '{schema:"fm-design-skills.dispatch.v1", plugin:$plugin, version:$version, last_updated:$last_updated, skills:{grilling:$grilling, domain_modeling:$domain_modeling}}') || { - echo "error: the resolved mattpocock design skills could not be serialized into the dispatch brief" >&2 - exit 1 + echo "error: task $ID's recorded design-skill pin could not be serialized into the replacement dispatch brief" >&2 + return 1 } +} +if [ "$KIND" = design ]; then + if [ "$RELAUNCH" -eq 1 ]; then + adopt_relaunch_design_skills || exit 1 + else + DESIGN_SKILLS_RECORD=$("$FM_ROOT/bin/fm-design-skills.sh" resolve) || { + echo "error: design spawn requires the captain-installed mattpocock design skills; do not install or copy them from a worker" >&2 + exit 1 + } + DESIGN_SKILLS_PLUGIN=$(design_skills_field "$DESIGN_SKILLS_RECORD" plugin) + DESIGN_SKILLS_VERSION=$(design_skills_field "$DESIGN_SKILLS_RECORD" version) + DESIGN_SKILLS_UPDATED=$(design_skills_field "$DESIGN_SKILLS_RECORD" last_updated) + DESIGN_SKILLS_GRILLING=$(design_skill_path "$DESIGN_SKILLS_RECORD" grilling) || DESIGN_SKILLS_GRILLING= + DESIGN_SKILLS_DOMAIN_MODELING=$(design_skill_path "$DESIGN_SKILLS_RECORD" domain_modeling) || DESIGN_SKILLS_DOMAIN_MODELING= + if [ -z "$DESIGN_SKILLS_PLUGIN" ] || [ -z "$DESIGN_SKILLS_VERSION" ] \ + || [ -z "$DESIGN_SKILLS_UPDATED" ] \ + || ! design_skill_path_is_safe "$DESIGN_SKILLS_GRILLING" \ + || ! design_skill_path_is_safe "$DESIGN_SKILLS_DOMAIN_MODELING"; then + echo "error: the installed mattpocock plugin resolved without a usable identity, version, update stamp, and absolute skill paths, so this design task's inputs could not be pinned and recorded; refusing rather than dispatching an untraceable design" >&2 + exit 1 + fi + DESIGN_SKILLS_BINDING=$(jq -cn \ + --arg plugin "$DESIGN_SKILLS_PLUGIN" \ + --arg version "$DESIGN_SKILLS_VERSION" \ + --arg last_updated "$DESIGN_SKILLS_UPDATED" \ + --arg grilling "$DESIGN_SKILLS_GRILLING" \ + --arg domain_modeling "$DESIGN_SKILLS_DOMAIN_MODELING" \ + '{schema:"fm-design-skills.dispatch.v1", plugin:$plugin, version:$version, + last_updated:$last_updated, + skills:{grilling:$grilling, domain_modeling:$domain_modeling}}') || { + echo "error: the resolved mattpocock design skills could not be serialized into the dispatch brief" >&2 + exit 1 + } + fi fi if [ "$KIND" = ship ] || [ "$KIND" = scout ] || [ "$KIND" = design ]; then if fm_brief_task_placeholders_present "$BRIEF"; then diff --git a/docs/agent-control.md b/docs/agent-control.md index 0bd248ab8a3..645dc8d53f6 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -53,12 +53,12 @@ It is not deterministic across the verified adapters: codex, grok, and gemini re ## Transactional relaunch -`relaunch` is the only verb that changes durable records, so it runs as a transaction with a journal at `state/.control-relaunch`, the prior record preserved beside it, and a ship or scout's prior instructions preserved when a progress note is appended. +`relaunch` is the only verb that changes durable records, so it runs as a transaction with a journal at `state/.control-relaunch`, the prior record preserved beside it, and a ship, design, or scout's prior instructions preserved when a progress note is appended. 1. **Resolve the profile.** An explicit `--harness`, `--model`, or `--effort` wins. Otherwise a `kind=secondmate` task re-resolves its durable `config/secondmate-harness` pin, including that file's optional model and effort tokens, exactly as every other respawn does - so setting the pin and relaunching is the ordinary way to move a secondmate's runtime. - A ship or scout keeps the harness already recorded for it, because that harness comes from firstmate's dispatch-profile judgment at intake and must not be silently re-read from configuration. + A ship, design, or scout keeps the harness already recorded for it, because that harness comes from firstmate's dispatch-profile judgment at intake and must not be silently re-read from configuration. A recorded raw-command basename that differs from its resolved adapter cannot reproduce the command actually running, so relaunch refuses before the checkpoint unless the caller passes an explicit `--harness` to choose the replacement runtime deliberately. A harness change resets model and effort unless they are named too, because a model chosen for one adapter does not transfer to another. 2. **Safe checkpoint.** @@ -66,7 +66,8 @@ It is not deterministic across the verified adapters: codex, grok, and gemini re For a `kind=secondmate` task, the home's identity marker must match and its child records must be readable, so a relaunch can never strand child work behind an unreadable home. A secondmate's own crewmates run in their own endpoints and outlive its relaunch; the relaunched secondmate reconciles them from its home's durable records at startup. 3. **Record the note.** - A ship or scout relaunch requires `--note`, because the replacement inherits the local copy but none of the conversation; the note is appended to the instructions it reads. + A ship, design, or scout relaunch requires `--note`, because the replacement inherits the local copy but none of the conversation; the note is appended to the instructions it reads. + A design relaunch also keeps `kind=design` and the dispatch-pinned skill release already recorded for that task ([`docs/fleet-data-contracts.md`](fleet-data-contracts.md#the-design-tasks-plugin-release)). A secondmate relaunch does not require one and never rewrites its standing charter. 4. **Stop the old agent** through the `exit` verb, with its postcondition. 5. **Launch the replacement** through its single owner, `bin/fm-spawn.sh --relaunch`, which adopts the recorded endpoint and worktree instead of creating either, clears the previous harness's per-task wiring, and arms a fresh busy generation. @@ -121,5 +122,5 @@ The empirical basis for each adapter's value is the `harness-adapters` skill's v ## Verification - `tests/fm-control.test.sh` - the adapter contract for every verified harness, the backend capability matrix, exact-id scoping, the closed verb list, the busy, idle, dead, and idempotent lifecycle cases, and marker non-regression, all against a stubbed session provider. -- `tests/fm-control-relaunch.test.sh` - the relaunch transaction: identity preservation, harness switching, the progress note, checkpoint refusals, and rollback after a failed launch. +- `tests/fm-control-relaunch.test.sh` - the relaunch transaction: identity preservation, including `kind=design` on the supported design runtimes, harness switching, the progress note, checkpoint refusals, and rollback after a failed launch. - `tests/fm-control-herdr-smoke.test.sh` - the second state-verified backend against the real herdr binary, on an isolated throwaway lab session. diff --git a/docs/fleet-data-contracts.md b/docs/fleet-data-contracts.md index 0f49676775b..523141d5d21 100644 --- a/docs/fleet-data-contracts.md +++ b/docs/fleet-data-contracts.md @@ -77,7 +77,7 @@ A home with no brain has nothing to capture, so it never republishes and its sin A design interview reads two skill files from the captain's installed `mattpocock-skills@mattpocock` plugin live, and that plugin auto-updates, so the instructions behind one design result need not be the instructions behind the next. `design_skills` records the `plugin`, `version`, and `last_updated` that [`bin/fm-design-skills.sh`](../bin/fm-design-skills.sh) reported to the one resolve call that gates the dispatch, and the worker-facing brief carries the concrete skill paths from that same result rather than resolving again. The recorded release is therefore definitionally the one whose files informed the interview; if either pinned file moves before it can be read, the task stops loudly instead of falling forward to another release. -A relaunch resolves again, so the value names the release resolved at the task's most recent dispatch. +A relaunch of the same design task reuses that recorded release and those pinned paths rather than resolving again. The key is optional and design-only: a non-design task, a design task dispatched before spawn recorded this, and any manifest published before this contract carry no `design_skills` key at all rather than a guess or an empty string. Its `version` and `last_updated` are provenance copied verbatim from a captain-owned plugin registry, so like `mode` they are bounded by type and length rather than by a closed vocabulary or a strict timestamp shape; refusing an unfamiliar-but-harmless string would block the teardown of the very task the record exists to explain. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 06359464a09..2258b244e27 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -57,9 +57,11 @@ The model names are representative test strings that verify axis transport; they The `mattpocock-skills@mattpocock` plugin a design interview reads auto-updates under the captain's own setting, so the release that informed one design result need not be the release that informs the next. The 2026-08-26 addition makes `bin/fm-spawn.sh --design` resolve that plugin once at dispatch, bind the worker-facing brief to that result's concrete skill paths, and record `design_skills_plugin=`, `design_skills_version=`, and `design_skills_updated=` in the task's metadata, from where the completion manifest carries them past cleanup. +A 2026-09-11 control-plane change makes `bin/fm-control.sh relaunch` keep that recorded release for a live `kind=design` worker instead of resolving again. [`docs/fleet-data-contracts.md`](../fleet-data-contracts.md#the-design-tasks-plugin-release) owns the recorded contract. The regressions are `test_design_dispatch_records_the_release_it_resolved`, `test_design_dispatch_binds_one_resolve_to_metadata_and_brief`, `test_design_dispatch_refuses_a_missing_pinned_path`, `test_ship_dispatch_records_no_release`, and `test_design_dispatch_refuses_an_unresolvable_plugin` in `tests/fm-design-skills.test.sh`, which drive real design and ship spawns against fixture plugin installs, plus `test_design_manifest_carries_the_plugin_release`, `test_manifest_omits_an_unresolved_plugin_release`, and `test_manifest_without_design_skills_stays_valid` in `tests/fm-outcome-manifest.test.sh`. +Design relaunch identity, pin reuse, and refusal controls are `test_design_relaunch_preserves_identity_on_supported_runtimes`, `test_relaunch_requires_a_note_for_a_design_task`, `test_design_relaunch_ignores_the_crew_harness_config`, `test_unknown_kind_relaunch_is_refused_before_stop`, `test_design_relaunch_refuses_a_missing_skill_pin_before_stop`, and the `--design` case in `test_spawn_relaunch_refuses_contradicting_flags` in `tests/fm-control-relaunch.test.sh`. The 2026-08-26 focused command was: @@ -74,6 +76,18 @@ all fm-design-skills tests passed all fm-outcome-manifest tests passed ``` +The 2026-09-11 focused command was: + +```sh +bin/fm-test-run.sh tests/fm-control-relaunch.test.sh +``` + +Its bounded completion marker was: + +```text +all fm-control-relaunch tests passed +``` + ### Focused delivery and compatibility regression The 2026-08-13 focused verification command for the three delivery rows and fifteen compatibility cells is: diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 54211f95661..c85520e030d 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -6,6 +6,10 @@ # real agent): # 1. A same-harness relaunch keeps every identity axis and reuses the SAME # endpoint and worktree - it replaces an agent, it never forks a task. +# kind=design uses that same transaction on the supported design runtimes +# and keeps the dispatch-pinned skill release, pending inbox, and +# uncommitted work. An undefined kind, a missing design pin, and a +# contradicting --design flag are refused rather than relabeled. # 2. A harness switch is one ordinary relaunch: the record follows, the # previous harness's per-task wiring is cleared, and profile axes chosen # for the old harness do not silently carry to the new one. @@ -168,6 +172,62 @@ EOF TASK_TMPS+=("/tmp/fm-$id") } +# add_design_task [harness] +# A live design worker: recorded skill pin, pending inbox, uncommitted work, +# and a dispatch-pinned brief under the recorded tasktmp. No plugin registry +# is installed, so a relaunch that resolved again would refuse. +add_design_task() { + local dir=$1 id=$2 harness=${3:-claude} + local plugin grilling modeling tasktmp binding meta brief + add_ship_task "$dir" "$id" "$harness" + plugin="$dir/plugin" + grilling="$plugin/skills/productivity/grilling/SKILL.md" + modeling="$plugin/skills/engineering/domain-modeling/SKILL.md" + mkdir -p "$(dirname "$grilling")" "$(dirname "$modeling")" + printf 'grilling pin\n' > "$grilling" + printf 'domain pin\n' > "$modeling" + tasktmp="/tmp/fm-$id" + mkdir -p "$tasktmp" + binding=$(jq -cn \ + --arg plugin 'mattpocock-skills@mattpocock' \ + --arg version '1.2.0' \ + --arg last_updated '2026-08-01T00:00:00Z' \ + --arg grilling "$grilling" \ + --arg domain_modeling "$modeling" \ + '{schema:"fm-design-skills.dispatch.v1", plugin:$plugin, version:$version, + last_updated:$last_updated, + skills:{grilling:$grilling, domain_modeling:$domain_modeling}}') + brief="$dir/home/data/$id/brief.md" + printf '%s\n' "" >> "$brief" + { + echo '# Dispatch-pinned design skills' + echo + echo '```json' + printf '%s\n' "$binding" + echo '```' + echo + echo '# Authored task brief' + cat "$brief" + } > "$tasktmp/brief.md" + meta="$dir/home/state/$id.meta" + awk ' + $0 == "kind=ship" { print "kind=design"; next } + { print } + ' "$meta" > "$meta.tmp" + { + echo "branch=fm/$id" + echo "design_skills_plugin=mattpocock-skills@mattpocock" + echo "design_skills_version=1.2.0" + echo "design_skills_updated=2026-08-01T00:00:00Z" + } >> "$meta.tmp" + mv "$meta.tmp" "$meta" + mkdir -p "$dir/home/state/$id.inbox/handled" + printf 'pending steer\n' > "$dir/home/state/$id.inbox/001.msg" + printf 'uncommitted design note\n' > "$dir/wt/wip.md" + printf '%s\n' "$harness" > "$dir/fake/command" + printf '%s\n' "$harness" > "$dir/fake/becomes" +} + run_control() { # local dir=$1; shift # A claude spawn pre-registers workspace trust in the launching user's own @@ -495,6 +555,114 @@ test_relaunch_appends_the_progress_note_to_the_instructions() { pass "fm-control relaunch: the progress note lands in the instructions the replacement reads" } +test_design_relaunch_preserves_identity_on_supported_runtimes() { + local harness dir out rc id + for harness in claude codex pi; do + id="d${harness}" + dir=$(new_case "design-$harness" "$id") + add_design_task "$dir" "$id" "$harness" + out=$(run_control "$dir" "$id" relaunch --note "restart with smaller context"); rc=$? + expect_code 0 "$rc" "a $harness design relaunch should succeed"$'\n'"$out" + assert_contains "$out" "relaunched $id harness=$harness from=$harness" \ + "the $harness design outcome should name the same-harness replacement" + [ "$(meta_field "$dir" "$id" window)" = "fmses:fm-$id" ] \ + || fail "$harness design relaunch must reuse the endpoint" + [ "$(meta_field "$dir" "$id" worktree)" = "$dir/wt" ] \ + || fail "$harness design relaunch must reuse the worktree" + [ "$(meta_field "$dir" "$id" kind)" = design ] \ + || fail "$harness design relaunch must keep kind=design" + [ "$(meta_field "$dir" "$id" branch)" = "fm/$id" ] \ + || fail "$harness design relaunch must keep the recorded branch" + [ "$(meta_field "$dir" "$id" design_skills_plugin)" = "mattpocock-skills@mattpocock" ] \ + || fail "$harness design relaunch must keep the recorded plugin" + [ "$(meta_field "$dir" "$id" design_skills_version)" = "1.2.0" ] \ + || fail "$harness design relaunch must keep the recorded skill version" + [ "$(meta_field "$dir" "$id" design_skills_updated)" = "2026-08-01T00:00:00Z" ] \ + || fail "$harness design relaunch must keep the recorded update stamp" + [ -f "$dir/home/state/$id.inbox/001.msg" ] \ + || fail "$harness design relaunch must leave the pending inbox in place" + assert_grep 'uncommitted design note' "$dir/wt/wip.md" \ + "$harness design relaunch must leave uncommitted work" + [ "$(journal_field "$dir" "$id" kind)" = design ] \ + || fail "$harness design journal must record kind=design" + assert_grep "restart with smaller context" "$dir/home/data/$id/brief.md" \ + "$harness design replacement must receive the progress note" + [ "$(sed -n '/^```json$/{n;p;q;}' "/tmp/fm-$id/brief.md" | jq -r '.version')" = 1.2.0 ] \ + || fail "$harness design replacement brief must keep the dispatch-pinned version" + done + pass "fm-control relaunch: design workers on claude, codex, and pi keep identity, pin, inbox, and uncommitted work" +} + +test_relaunch_requires_a_note_for_a_design_task() { + local dir out rc before + dir=$(new_case design-nonote dn1) + add_design_task "$dir" dn1 claude + before=$(cat "$dir/home/data/dn1/brief.md") + out=$(run_control "$dir" dn1 relaunch); rc=$? + expect_code 1 "$rc" "a design relaunch without a note should refuse" + assert_contains "$out" "requires --note" "the refusal should name the missing note" + [ "$(cat "$dir/home/data/dn1/brief.md")" = "$before" ] \ + || fail "a refused design relaunch must not touch the instructions" + [ -z "$(cat "$dir/fake/literal")" ] || fail "a refused design relaunch must send nothing" + [ "$(cat "$dir/fake/command")" = claude ] || fail "a refused design relaunch must not stop the agent" + pass "fm-control relaunch: a design task requires a progress note and refuses before stop" +} + +test_design_relaunch_ignores_the_crew_harness_config() { + local dir out + dir=$(new_case design-crewcfg dc1) + add_design_task "$dir" dc1 claude + mkdir -p "$dir/home/config" + printf 'codex\n' > "$dir/home/config/crew-harness" + out=$(run_control "$dir" dc1 relaunch --note "same worker, same runtime") + assert_contains "$out" "harness=claude from=claude" \ + "a design relaunch must keep its recorded harness rather than re-reading crew config" + [ "$(meta_field "$dir" dc1 harness)" = claude ] \ + || fail "a design relaunch must not silently move onto the configured crew harness" + pass "fm-control relaunch: a design task keeps its recorded harness instead of re-reading crew config" +} + +test_unknown_kind_relaunch_is_refused_before_stop() { + local dir out rc meta brief + dir=$(new_case kind-gap kg1) + add_ship_task "$dir" kg1 claude + meta="$dir/home/state/kg1.meta" + brief="$dir/home/data/kg1/brief.md" + awk ' + $0 == "kind=ship" { print "kind=audit"; next } + { print } + ' "$meta" > "$meta.tmp" + mv "$meta.tmp" "$meta" + cp "$meta" "$dir/meta.before" + cp "$brief" "$dir/brief.before" + out=$(run_control "$dir" kg1 relaunch --note "should never start"); rc=$? + expect_code 1 "$rc" "an unknown kind should refuse" + assert_contains "$out" "no defined relaunch shape" \ + "the refusal should name the missing relaunch shape" + cmp -s "$meta" "$dir/meta.before" \ + || fail "an unknown-kind refusal must leave metadata byte-identical" + cmp -s "$brief" "$dir/brief.before" \ + || fail "an unknown-kind refusal must leave instructions byte-identical" + [ "$(cat "$dir/fake/command")" = claude ] || fail "an unknown-kind refusal must not stop the agent" + pass "fm-control relaunch: an undefined kind is still refused before the agent is touched" +} + +test_design_relaunch_refuses_a_missing_skill_pin_before_stop() { + local dir out rc + dir=$(new_case design-nopin dp1) + add_design_task "$dir" dp1 claude + rm -f /tmp/fm-dp1/brief.md + cp "$dir/home/state/dp1.meta" "$dir/meta.before" + out=$(run_control "$dir" dp1 relaunch --note "cannot rebind skills"); rc=$? + expect_code 1 "$rc" "a design relaunch without its dispatch pin should refuse" + assert_contains "$out" "dispatch-pinned design brief" \ + "the refusal should name the missing dispatch pin" + cmp -s "$dir/home/state/dp1.meta" "$dir/meta.before" \ + || fail "a missing-pin refusal must leave metadata byte-identical" + [ "$(cat "$dir/fake/command")" = claude ] || fail "a missing-pin refusal must not stop the agent" + pass "fm-control relaunch: a design task with no dispatch pin refuses before stop" +} + test_relaunch_requires_a_note_for_a_ship_task() { local dir out rc before dir=$(new_case nonote rl3) @@ -1494,6 +1662,9 @@ test_spawn_relaunch_refuses_contradicting_flags() { out=$(run_spawn "$dir" rl16 --relaunch --scout); rc=$? expect_code 1 "$rc" "--scout should be refused alongside --relaunch" assert_contains "$out" "recorded kind" "the refusal should name the recorded kind rule" + out=$(run_spawn "$dir" rl16 --relaunch --design); rc=$? + expect_code 1 "$rc" "--design should be refused alongside --relaunch" + assert_contains "$out" "recorded kind" "the --design refusal should name the recorded kind rule" out=$(run_spawn "$dir" rl16 "$dir/proj" --relaunch); rc=$? expect_code 1 "$rc" "a project positional should be refused alongside --relaunch" assert_contains "$out" "takes the task id only" "the refusal should name the positional rule" @@ -1557,6 +1728,11 @@ test_relaunch_moves_a_drifted_item_back_in_flight() { pass "relaunch heals an item that drifted out of In flight while the task stayed live" } +test_design_relaunch_preserves_identity_on_supported_runtimes +test_relaunch_requires_a_note_for_a_design_task +test_design_relaunch_ignores_the_crew_harness_config +test_unknown_kind_relaunch_is_refused_before_stop +test_design_relaunch_refuses_a_missing_skill_pin_before_stop test_same_harness_relaunch_keeps_identity_and_reuses_the_endpoint test_relaunch_from_linked_home_preserves_recorded_worktree test_relaunch_preserves_durable_task_metadata From 99d8109f386267815fc92c05b37a2f6a08b749f7 Mon Sep 17 00:00:00 2001 From: Sungin Kim Date: Fri, 11 Sep 2026 21:15:25 +0000 Subject: [PATCH 2/6] no-mistakes(review): Validate design pins before stopping workers; unify binding serialization --- bin/fm-control.sh | 21 +----- bin/fm-design-skills-lib.sh | 78 +++++++++++++++++++++ bin/fm-spawn.sh | 111 ++++-------------------------- tests/fm-control-relaunch.test.sh | 40 +++++++++++ 4 files changed, 135 insertions(+), 115 deletions(-) create mode 100644 bin/fm-design-skills-lib.sh diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 1bcf995815f..22f9e8c4350 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -765,24 +765,7 @@ safe_checkpoint() { fi } -# require_recorded_design_skill_pin: a design relaunch must keep the dispatch -# pin already recorded for this task. Presence is proved here, before the -# agent is stopped, so a missing pin refuses with nothing changed; spawn -# reuses that same recorded release rather than resolving the plugin again -# (docs/fleet-data-contracts.md "The design task's plugin release"). -require_recorded_design_skill_pin() { - local plugin version updated tasktmp dispatch_brief - plugin=$(fm_meta_get "$META" design_skills_plugin) - version=$(fm_meta_get "$META" design_skills_version) - updated=$(fm_meta_get "$META" design_skills_updated) - [ -n "$plugin" ] && [ -n "$version" ] && [ -n "$updated" ] \ - || die "task $ID has no recorded design-skill release; refusing to relaunch rather than resolving a different plugin pin" - tasktmp=$(fm_meta_get "$META" tasktmp) - [ -n "$tasktmp" ] || tasktmp="/tmp/fm-$ID" - dispatch_brief="$tasktmp/brief.md" - [ -f "$dispatch_brief" ] && [ ! -L "$dispatch_brief" ] \ - || die "task $ID has no dispatch-pinned design brief at $dispatch_brief; refusing to relaunch rather than resolving a different plugin release" -} +. "$SCRIPT_DIR/fm-design-skills-lib.sh" # record_note: put the required progress note somewhere durable, and - for a # ship, design, or scout, whose only record of the interrupted reasoning is the @@ -832,7 +815,7 @@ do_relaunch() { [ "$NOTE_SET" = 1 ] && [ -n "$NOTE" ] \ || die "relaunch of a $KIND task requires --note (or --note-file): the replacement worker inherits the local copy but none of the conversation, so it must be told what happened" if [ "$KIND" = design ]; then - require_recorded_design_skill_pin + adopt_relaunch_design_skills "$META" "$ID" || exit 1 fi ;; secondmate) diff --git a/bin/fm-design-skills-lib.sh b/bin/fm-design-skills-lib.sh new file mode 100644 index 00000000000..85c5edd434c --- /dev/null +++ b/bin/fm-design-skills-lib.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +# Matches the durable manifest's free-text cap (FM_OUTCOME_TEXT_MAX in +# bin/fm-outcome-lib.sh), so a value recorded here can always be published. +DESIGN_SKILLS_FIELD_MAX=240 +# Collapses to the same single-line, trimmed, capped shape the durable manifest +# applies, so the emptiness check below sees exactly what would be published. +design_skills_field() { # -> one meta-safe line + printf '%s\n' "$1" \ + | jq -r --arg field "$2" '.[$field] // ""' \ + | tr -d '\000-\037\177' \ + | sed -e 's/ */ /g' -e 's/^ //' -e 's/ $//' \ + | cut -c "1-$DESIGN_SKILLS_FIELD_MAX" +} +design_skill_path() { # -> exact absolute path + printf '%s\n' "$1" \ + | jq -er --arg skill "$2" '.skills[$skill] | select(type == "string" and length > 0)' +} +design_skill_path_is_safe() { # + case "$1" in + /*) ;; + *) return 1 ;; + esac + [ "$(printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177')" = "$1" ] +} +# adopt_relaunch_design_skills: reuse the dispatch pin already recorded for +# this design task. Never call fm-design-skills.sh resolve here; a later +# plugin auto-update must not silently rebind the interview. +adopt_relaunch_design_skills() { + local RELAUNCH_META=$1 ID=$2 + local recorded_plugin recorded_version recorded_updated tasktmp dispatch_brief + local binding schema + recorded_plugin=$(fm_meta_get "$RELAUNCH_META" design_skills_plugin) + recorded_version=$(fm_meta_get "$RELAUNCH_META" design_skills_version) + recorded_updated=$(fm_meta_get "$RELAUNCH_META" design_skills_updated) + if [ -z "$recorded_plugin" ] || [ -z "$recorded_version" ] \ + || [ -z "$recorded_updated" ]; then + echo "error: task $ID has no recorded design-skill release; refusing to relaunch rather than resolving a different plugin pin" >&2 + return 1 + fi + tasktmp=$(fm_meta_get "$RELAUNCH_META" tasktmp) + [ -n "$tasktmp" ] || tasktmp="/tmp/fm-$ID" + dispatch_brief="$tasktmp/brief.md" + if [ ! -f "$dispatch_brief" ] || [ -L "$dispatch_brief" ] || [ ! -r "$dispatch_brief" ]; then + echo "error: task $ID has no dispatch-pinned design brief at $dispatch_brief; refusing to relaunch rather than resolving a different plugin release" >&2 + return 1 + fi + binding=$(sed -n '/^```json$/{n;p;q;}' "$dispatch_brief") + schema=$(printf '%s\n' "$binding" | jq -r '.schema // empty' 2>/dev/null) || schema= + [ "$schema" = fm-design-skills.dispatch.v1 ] || { + echo "error: task $ID's dispatch-pinned design brief is not a usable skill binding; refusing to relaunch rather than resolving a different plugin release" >&2 + return 1 + } + DESIGN_SKILLS_PLUGIN=$(design_skills_field "$binding" plugin) + DESIGN_SKILLS_VERSION=$(design_skills_field "$binding" version) + DESIGN_SKILLS_UPDATED=$(design_skills_field "$binding" last_updated) + DESIGN_SKILLS_GRILLING=$(design_skill_path "$binding" grilling) || DESIGN_SKILLS_GRILLING= + DESIGN_SKILLS_DOMAIN_MODELING=$(design_skill_path "$binding" domain_modeling) || DESIGN_SKILLS_DOMAIN_MODELING= + if [ "$DESIGN_SKILLS_PLUGIN" != "$recorded_plugin" ] \ + || [ "$DESIGN_SKILLS_VERSION" != "$recorded_version" ] \ + || [ "$DESIGN_SKILLS_UPDATED" != "$recorded_updated" ] \ + || ! design_skill_path_is_safe "$DESIGN_SKILLS_GRILLING" \ + || ! design_skill_path_is_safe "$DESIGN_SKILLS_DOMAIN_MODELING"; then + echo "error: task $ID's dispatch-pinned design skills do not match its recorded release; refusing to relaunch rather than substituting another plugin pin" >&2 + return 1 + fi + design_skill_files_readable +} + +design_skill_files_readable() { + if [ ! -f "$DESIGN_SKILLS_GRILLING" ] || [ -L "$DESIGN_SKILLS_GRILLING" ] \ + || [ ! -r "$DESIGN_SKILLS_GRILLING" ] \ + || [ ! -f "$DESIGN_SKILLS_DOMAIN_MODELING" ] || [ -L "$DESIGN_SKILLS_DOMAIN_MODELING" ] \ + || [ ! -r "$DESIGN_SKILLS_DOMAIN_MODELING" ]; then + echo "error: a dispatch-pinned mattpocock design skill path disappeared or became unreadable after resolution; refusing instead of silently resolving a different plugin release" >&2 + return 1 + fi +} diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index a2cd970956e..e40c5892895 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1992,85 +1992,10 @@ DESIGN_SKILLS_UPDATED= DESIGN_SKILLS_GRILLING= DESIGN_SKILLS_DOMAIN_MODELING= DESIGN_SKILLS_BINDING= -# Matches the durable manifest's free-text cap (FM_OUTCOME_TEXT_MAX in -# bin/fm-outcome-lib.sh), so a value recorded here can always be published. -DESIGN_SKILLS_FIELD_MAX=240 -# Collapses to the same single-line, trimmed, capped shape the durable manifest -# applies, so the emptiness check below sees exactly what would be published. -design_skills_field() { # -> one meta-safe line - printf '%s\n' "$1" \ - | jq -r --arg field "$2" '.[$field] // ""' \ - | tr -d '\000-\037\177' \ - | sed -e 's/ */ /g' -e 's/^ //' -e 's/ $//' \ - | cut -c "1-$DESIGN_SKILLS_FIELD_MAX" -} -design_skill_path() { # -> exact absolute path - printf '%s\n' "$1" \ - | jq -er --arg skill "$2" '.skills[$skill] | select(type == "string" and length > 0)' -} -design_skill_path_is_safe() { # - case "$1" in - /*) ;; - *) return 1 ;; - esac - [ "$(printf '%s' "$1" | LC_ALL=C tr -d '\000-\037\177')" = "$1" ] -} -# adopt_relaunch_design_skills: reuse the dispatch pin already recorded for -# this design task. Never call fm-design-skills.sh resolve here; a later -# plugin auto-update must not silently rebind the interview. -adopt_relaunch_design_skills() { - local recorded_plugin recorded_version recorded_updated tasktmp dispatch_brief - local binding schema - recorded_plugin=$(fm_meta_get "$RELAUNCH_META" design_skills_plugin) - recorded_version=$(fm_meta_get "$RELAUNCH_META" design_skills_version) - recorded_updated=$(fm_meta_get "$RELAUNCH_META" design_skills_updated) - if [ -z "$recorded_plugin" ] || [ -z "$recorded_version" ] \ - || [ -z "$recorded_updated" ]; then - echo "error: task $ID has no recorded design-skill release; refusing to relaunch rather than resolving a different plugin pin" >&2 - return 1 - fi - tasktmp=$(fm_meta_get "$RELAUNCH_META" tasktmp) - [ -n "$tasktmp" ] || tasktmp="/tmp/fm-$ID" - dispatch_brief="$tasktmp/brief.md" - if [ ! -f "$dispatch_brief" ] || [ -L "$dispatch_brief" ]; then - echo "error: task $ID has no dispatch-pinned design brief at $dispatch_brief; refusing to relaunch rather than resolving a different plugin release" >&2 - return 1 - fi - binding=$(sed -n '/^```json$/{n;p;q;}' "$dispatch_brief") - schema=$(printf '%s\n' "$binding" | jq -r '.schema // empty' 2>/dev/null) || schema= - [ "$schema" = fm-design-skills.dispatch.v1 ] || { - echo "error: task $ID's dispatch-pinned design brief is not a usable skill binding; refusing to relaunch rather than resolving a different plugin release" >&2 - return 1 - } - DESIGN_SKILLS_PLUGIN=$(design_skills_field "$binding" plugin) - DESIGN_SKILLS_VERSION=$(design_skills_field "$binding" version) - DESIGN_SKILLS_UPDATED=$(design_skills_field "$binding" last_updated) - DESIGN_SKILLS_GRILLING=$(design_skill_path "$binding" grilling) || DESIGN_SKILLS_GRILLING= - DESIGN_SKILLS_DOMAIN_MODELING=$(design_skill_path "$binding" domain_modeling) || DESIGN_SKILLS_DOMAIN_MODELING= - if [ "$DESIGN_SKILLS_PLUGIN" != "$recorded_plugin" ] \ - || [ "$DESIGN_SKILLS_VERSION" != "$recorded_version" ] \ - || [ "$DESIGN_SKILLS_UPDATED" != "$recorded_updated" ] \ - || ! design_skill_path_is_safe "$DESIGN_SKILLS_GRILLING" \ - || ! design_skill_path_is_safe "$DESIGN_SKILLS_DOMAIN_MODELING"; then - echo "error: task $ID's dispatch-pinned design skills do not match its recorded release; refusing to relaunch rather than substituting another plugin pin" >&2 - return 1 - fi - DESIGN_SKILLS_BINDING=$(jq -cn \ - --arg plugin "$DESIGN_SKILLS_PLUGIN" \ - --arg version "$DESIGN_SKILLS_VERSION" \ - --arg last_updated "$DESIGN_SKILLS_UPDATED" \ - --arg grilling "$DESIGN_SKILLS_GRILLING" \ - --arg domain_modeling "$DESIGN_SKILLS_DOMAIN_MODELING" \ - '{schema:"fm-design-skills.dispatch.v1", plugin:$plugin, version:$version, - last_updated:$last_updated, - skills:{grilling:$grilling, domain_modeling:$domain_modeling}}') || { - echo "error: task $ID's recorded design-skill pin could not be serialized into the replacement dispatch brief" >&2 - return 1 - } -} +. "$SCRIPT_DIR/fm-design-skills-lib.sh" if [ "$KIND" = design ]; then if [ "$RELAUNCH" -eq 1 ]; then - adopt_relaunch_design_skills || exit 1 + adopt_relaunch_design_skills "$RELAUNCH_META" "$ID" || exit 1 else DESIGN_SKILLS_RECORD=$("$FM_ROOT/bin/fm-design-skills.sh" resolve) || { echo "error: design spawn requires the captain-installed mattpocock design skills; do not install or copy them from a worker" >&2 @@ -2088,19 +2013,19 @@ if [ "$KIND" = design ]; then echo "error: the installed mattpocock plugin resolved without a usable identity, version, update stamp, and absolute skill paths, so this design task's inputs could not be pinned and recorded; refusing rather than dispatching an untraceable design" >&2 exit 1 fi - DESIGN_SKILLS_BINDING=$(jq -cn \ - --arg plugin "$DESIGN_SKILLS_PLUGIN" \ - --arg version "$DESIGN_SKILLS_VERSION" \ - --arg last_updated "$DESIGN_SKILLS_UPDATED" \ - --arg grilling "$DESIGN_SKILLS_GRILLING" \ - --arg domain_modeling "$DESIGN_SKILLS_DOMAIN_MODELING" \ - '{schema:"fm-design-skills.dispatch.v1", plugin:$plugin, version:$version, - last_updated:$last_updated, - skills:{grilling:$grilling, domain_modeling:$domain_modeling}}') || { - echo "error: the resolved mattpocock design skills could not be serialized into the dispatch brief" >&2 - exit 1 - } fi + DESIGN_SKILLS_BINDING=$(jq -cn \ + --arg plugin "$DESIGN_SKILLS_PLUGIN" \ + --arg version "$DESIGN_SKILLS_VERSION" \ + --arg last_updated "$DESIGN_SKILLS_UPDATED" \ + --arg grilling "$DESIGN_SKILLS_GRILLING" \ + --arg domain_modeling "$DESIGN_SKILLS_DOMAIN_MODELING" \ + '{schema:"fm-design-skills.dispatch.v1", plugin:$plugin, version:$version, + last_updated:$last_updated, + skills:{grilling:$grilling, domain_modeling:$domain_modeling}}') || { + echo "error: the resolved mattpocock design skills could not be serialized into the dispatch brief" >&2 + exit 1 + } fi if [ "$KIND" = ship ] || [ "$KIND" = scout ] || [ "$KIND" = design ]; then if fm_brief_task_placeholders_present "$BRIEF"; then @@ -3185,13 +3110,7 @@ TASK_TMP="/tmp/fm-$ID" mkdir -p "$TASK_TMP/gotmp" if [ "$KIND" = design ]; then - if [ ! -f "$DESIGN_SKILLS_GRILLING" ] || [ -L "$DESIGN_SKILLS_GRILLING" ] \ - || [ ! -r "$DESIGN_SKILLS_GRILLING" ] \ - || [ ! -f "$DESIGN_SKILLS_DOMAIN_MODELING" ] || [ -L "$DESIGN_SKILLS_DOMAIN_MODELING" ] \ - || [ ! -r "$DESIGN_SKILLS_DOMAIN_MODELING" ]; then - echo "error: a dispatch-pinned mattpocock design skill path disappeared or became unreadable after resolution; refusing instead of silently resolving a different plugin release" >&2 - exit 1 - fi + design_skill_files_readable || exit 1 DESIGN_DISPATCH_BRIEF="$TASK_TMP/brief.md" DESIGN_DISPATCH_BRIEF_TMP="$TASK_TMP/.brief.${BASHPID:-$$}" { diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index c85520e030d..d606b6d050f 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -663,6 +663,45 @@ test_design_relaunch_refuses_a_missing_skill_pin_before_stop() { pass "fm-control relaunch: a design task with no dispatch pin refuses before stop" } +test_design_relaunch_refuses_invalid_skill_pins_before_stop() { + local dir out rc defect skill binding filter + for defect in schema plugin version updated relative control missing directory symlink; do + dir=$(new_case "design-invalid-$defect" dip1) + add_design_task "$dir" dip1 claude + skill="$dir/plugin/skills/productivity/grilling/SKILL.md" + filter=. + case "$defect" in + schema) filter='.schema = "invalid"' ;; + plugin) filter='.plugin = "other-plugin"' ;; + version) filter='.version = "2.0.0"' ;; + updated) filter='.last_updated = "2026-09-01T00:00:00Z"' ;; + relative) filter='.skills.domain_modeling = "relative/SKILL.md"' ;; + control) filter='.skills.domain_modeling += "\t"' ;; + missing) rm "$skill" ;; + directory) rm "$skill"; mkdir "$skill" ;; + symlink) mv "$skill" "$skill.target"; ln -s "$skill.target" "$skill" ;; + esac + binding=$(sed -n '/^```json$/{n;p;q;}' /tmp/fm-dip1/brief.md | jq -c "$filter") + printf '```json\n%s\n```\n' "$binding" > /tmp/fm-dip1/brief.md + cp "$dir/home/state/dip1.meta" "$dir/meta.before" + cp "$dir/home/data/dip1/brief.md" "$dir/brief.before" + out=$(run_control "$dir" dip1 relaunch --note "preserve ADR decisions"); rc=$? + expect_code 1 "$rc" "$defect pin should refuse before stop"$'\n'"$out" + [ "$(cat "$dir/fake/command")" = claude ] || fail "$defect pin stopped the agent" + [ ! -s "$dir/fake/literal" ] || fail "$defect pin sent agent instructions" + cmp -s "$dir/home/state/dip1.meta" "$dir/meta.before" || fail "$defect pin changed metadata" + cmp -s "$dir/home/data/dip1/brief.md" "$dir/brief.before" || fail "$defect pin changed the authored brief" + [ -f "$dir/home/state/dip1.inbox/001.msg" ] || fail "$defect pin lost a pending decision" + [ ! -e "$dir/home/state/dip1.control-relaunch" ] || fail "$defect pin began the relaunch transaction" + printf zsh > "$dir/fake/command" + out=$(run_spawn "$dir" dip1 --relaunch); rc=$? + expect_code 1 "$rc" "direct spawn should refuse $defect pin"$'\n'"$out" + [ ! -s "$dir/fake/literal" ] || fail "direct spawn launched with $defect pin" + cmp -s "$dir/home/state/dip1.meta" "$dir/meta.before" || fail "direct spawn changed metadata for $defect pin" + done + pass "fm-control relaunch: invalid design bindings and unavailable skills refuse before stop" +} + test_relaunch_requires_a_note_for_a_ship_task() { local dir out rc before dir=$(new_case nonote rl3) @@ -1733,6 +1772,7 @@ test_relaunch_requires_a_note_for_a_design_task test_design_relaunch_ignores_the_crew_harness_config test_unknown_kind_relaunch_is_refused_before_stop test_design_relaunch_refuses_a_missing_skill_pin_before_stop +test_design_relaunch_refuses_invalid_skill_pins_before_stop test_same_harness_relaunch_keeps_identity_and_reuses_the_endpoint test_relaunch_from_linked_home_preserves_recorded_worktree test_relaunch_preserves_durable_task_metadata From a6f50c30bfea7c969f96bf5e1165b01e561e7c79 Mon Sep 17 00:00:00 2001 From: Sungin Kim Date: Fri, 11 Sep 2026 21:19:24 +0000 Subject: [PATCH 3/6] no-mistakes(test): Stop duplicating branch= meta line on relaunch --- bin/fm-spawn.sh | 2 +- tests/fm-control-relaunch.test.sh | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index e40c5892895..06f5e4cb56d 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -3703,7 +3703,7 @@ SPAWN_META_PATH=$SPAWN_META_TMP preserve_relaunch_meta() { awk -F= ' BEGIN { - split("window endpoint_task_id worktree project harness kind mode yolo design_skills_plugin design_skills_version design_skills_updated tasktmp model effort busy_gen spawn_gen traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id home projects control_relaunch_tx", keys, " ") + split("window endpoint_task_id worktree branch project harness kind mode yolo design_skills_plugin design_skills_version design_skills_updated tasktmp model effort busy_gen spawn_gen traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id home projects control_relaunch_tx", keys, " ") for (i in keys) owned[keys[i]] = 1 } !($1 in owned) diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index d606b6d050f..6326b284ea9 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -573,6 +573,8 @@ test_design_relaunch_preserves_identity_on_supported_runtimes() { || fail "$harness design relaunch must keep kind=design" [ "$(meta_field "$dir" "$id" branch)" = "fm/$id" ] \ || fail "$harness design relaunch must keep the recorded branch" + [ "$(grep -c '^branch=' "$dir/home/state/$id.meta")" = 1 ] \ + || fail "$harness design relaunch must record the branch exactly once" [ "$(meta_field "$dir" "$id" design_skills_plugin)" = "mattpocock-skills@mattpocock" ] \ || fail "$harness design relaunch must keep the recorded plugin" [ "$(meta_field "$dir" "$id" design_skills_version)" = "1.2.0" ] \ From 92241d170322b68e59551ab089603223669cf278 Mon Sep 17 00:00:00 2001 From: Sungin Kim Date: Fri, 11 Sep 2026 21:21:40 +0000 Subject: [PATCH 4/6] no-mistakes(document): Document design relaunch pre-stop pin refusal --- docs/agent-control.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agent-control.md b/docs/agent-control.md index 645dc8d53f6..532767e6aa6 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -67,7 +67,7 @@ It is not deterministic across the verified adapters: codex, grok, and gemini re A secondmate's own crewmates run in their own endpoints and outlive its relaunch; the relaunched secondmate reconciles them from its home's durable records at startup. 3. **Record the note.** A ship, design, or scout relaunch requires `--note`, because the replacement inherits the local copy but none of the conversation; the note is appended to the instructions it reads. - A design relaunch also keeps `kind=design` and the dispatch-pinned skill release already recorded for that task ([`docs/fleet-data-contracts.md`](fleet-data-contracts.md#the-design-tasks-plugin-release)). + A design relaunch also keeps `kind=design` and the dispatch-pinned skill release already recorded for that task ([`docs/fleet-data-contracts.md`](fleet-data-contracts.md#the-design-tasks-plugin-release)), and refuses before the old agent is stopped if that pin is missing, mismatched, or no longer readable. A secondmate relaunch does not require one and never rewrites its standing charter. 4. **Stop the old agent** through the `exit` verb, with its postcondition. 5. **Launch the replacement** through its single owner, `bin/fm-spawn.sh --relaunch`, which adopts the recorded endpoint and worktree instead of creating either, clears the previous harness's per-task wiring, and arms a fresh busy generation. From 89ce6388d5c5781ecb3a9e5f7e930189619903c2 Mon Sep 17 00:00:00 2001 From: Sungin Kim Date: Fri, 11 Sep 2026 21:22:35 +0000 Subject: [PATCH 5/6] no-mistakes(lint): Pass json fence lines as printf arguments in relaunch test --- tests/fm-control-relaunch.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 6326b284ea9..dfeb4f051f1 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -684,7 +684,7 @@ test_design_relaunch_refuses_invalid_skill_pins_before_stop() { symlink) mv "$skill" "$skill.target"; ln -s "$skill.target" "$skill" ;; esac binding=$(sed -n '/^```json$/{n;p;q;}' /tmp/fm-dip1/brief.md | jq -c "$filter") - printf '```json\n%s\n```\n' "$binding" > /tmp/fm-dip1/brief.md + printf '%s\n' '```json' "$binding" '```' > /tmp/fm-dip1/brief.md cp "$dir/home/state/dip1.meta" "$dir/meta.before" cp "$dir/home/data/dip1/brief.md" "$dir/brief.before" out=$(run_control "$dir" dip1 relaunch --note "preserve ADR decisions"); rc=$? From 188fc41e79a60c41ab810b6be5ea138515bad74c Mon Sep 17 00:00:00 2001 From: Sungin Kim Date: Fri, 11 Sep 2026 22:02:24 +0000 Subject: [PATCH 6/6] no-mistakes(ci): Fixed both failing checks. 1) Behavior portable serial 6 (tests/fm-control-relaunch.test.sh): the pi design relaunch case failed because no `pi` binary is on PATH in CI. Added an executable `pi` stub to the per-case fakebin in make_tmux_stub (PATH already prefers fakebin), next to the existing tmux/sleep stubs. fm-spawn resolves pi via `type -P` and probes `--help` for `--tui-mode`; the stub exits 0 so the probe simply omits the flag. Supported-runtime coverage (claude, codex, pi) is unchanged. 2) Lint SC1091 (bin/fm-control.sh:768, bin/fm-spawn.sh:1995): added `# shellcheck source=bin/fm-design-skills-lib.sh` immediately before each `. "$SCRIPT_DIR/fm-design-skills-lib.sh"`, matching the directive used for other bin libs. Verification: - Reproduced failure 1 with a PATH mirror containing no `pi`: pre-fix run gave `not ok - a pi design relaunch should succeed` / `error: pi executable not found on PATH`, exit 1. With the stub the same run passes, exit 0. - Full suite also passes with a normal PATH. - `CI=true bin/fm-lint.sh` (full extended analysis with source following, matching CI) exits 0 with no SC1091. Note: while reproducing failure 1 under a deliberately minimal PATH that lacked `mktemp`, the test harness deleted its own working directory, wiping this worktree checkout. I restored the worktree from the gate repo (worktree metadata was intact) and reapplied the changes; the final diff is exactly the 10 added lines described above. The suite appears to run an unguarded `rm` against a temp root it failed to create, but that is outside the two reported checks so I left it alone --- bin/fm-control.sh | 1 + bin/fm-spawn.sh | 1 + tests/fm-control-relaunch.test.sh | 8 ++++++++ 3 files changed, 10 insertions(+) diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 22f9e8c4350..a1e435b3b31 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -765,6 +765,7 @@ safe_checkpoint() { fi } +# shellcheck source=bin/fm-design-skills-lib.sh . "$SCRIPT_DIR/fm-design-skills-lib.sh" # record_note: put the required progress note somewhere durable, and - for a diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 06f5e4cb56d..4d308240893 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1992,6 +1992,7 @@ DESIGN_SKILLS_UPDATED= DESIGN_SKILLS_GRILLING= DESIGN_SKILLS_DOMAIN_MODELING= DESIGN_SKILLS_BINDING= +# shellcheck source=bin/fm-design-skills-lib.sh . "$SCRIPT_DIR/fm-design-skills-lib.sh" if [ "$KIND" = design ]; then if [ "$RELAUNCH" -eq 1 ]; then diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index dfeb4f051f1..8bb0796ec52 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -125,6 +125,14 @@ SH exit 0 SH chmod +x "$fb/sleep" + # A pi spawn resolves the `pi` executable on PATH and probes its --help for + # --tui-mode, so a pi case needs a binary to resolve. CI runners have no Pi + # install; this stub answers the probe without one. + cat > "$fb/pi" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fb/pi" } # new_case [id] -> echoes a case dir with a live claude ship task.