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..a1e435b3b31 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,11 @@ 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 -# 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 +780,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 +809,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 + adopt_relaunch_design_skills "$META" "$ID" || exit 1 + fi ;; secondmate) # The charter in the secondmate's own home is its instruction source and 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 4e23dba41b1..4d308240893 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= @@ -1987,45 +1992,28 @@ 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" ] -} +# shellcheck source=bin/fm-design-skills-lib.sh +. "$SCRIPT_DIR/fm-design-skills-lib.sh" 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 - } - 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 + if [ "$RELAUNCH" -eq 1 ]; then + 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 + 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 fi DESIGN_SKILLS_BINDING=$(jq -cn \ --arg plugin "$DESIGN_SKILLS_PLUGIN" \ @@ -3123,13 +3111,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:-$$}" { @@ -3722,7 +3704,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/docs/agent-control.md b/docs/agent-control.md index 0bd248ab8a3..532767e6aa6 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)), 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. @@ -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..8bb0796ec52 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. @@ -121,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. @@ -168,6 +180,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 +563,155 @@ 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" + [ "$(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" ] \ + || 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_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 '%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=$? + 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) @@ -1494,6 +1711,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 +1777,12 @@ 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_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