Skip to content

fix(bin): select and verify the OpenCode model the installed build accepts - #11

Merged
kesslerio merged 16 commits into
mainfrom
fm/opencode-launcher-fix
Nov 3, 2026
Merged

kesslerio merged 16 commits into
mainfrom
fm/opencode-launcher-fix

Conversation

@kesslerio

@kesslerio kesslerio commented Nov 2, 2026 •

Copy link
Copy Markdown
Owner

What Changed

  • bin/fm-spawn.sh now resolves the OpenCode model axis against the installed build instead of an assumed version: it probes --version/--help and passes --model only when that build advertises the top-level flag, otherwise it writes .opencode/opencode.jsonc in the worker's worktree, refuses builds whose version or help surface is unreadable, rejects inline provider/model#variant, and keeps the effort variant beside the model (project config) or in the launch config (flag-based builds).
  • Every model-pinned launch is now proven before and after start: the pin is read back through opencode debug config, and the worker's first recorded assistant message after the launch timestamp must show the requested model or the spawn refuses and closes the endpoint. bin/fm-control-lib.sh and bin/fm-teardown.sh retire the pin only when this task's own ownership record names it, so a project's config is never removed and a reused pool slot cannot inherit a stale pin.
  • Tests and CI: new tests/fm-spawn-opencode-model.test.sh suite plus an opt-in tests/fm-opencode-model-live-e2e.test.sh guard, updated dispatch-profile OpenCode assertions, supervision-host fixture waits converted to a wall-clock wait_until_deadline helper with hand-back diagnostics, timing-agnostic watcher checkpoint assertions, and portable serial shards raised from 10 to 12 in bin/fm-test-run.sh with matching CI matrix and shard-packing docs.

Risk Assessment

⚠️ Medium: No actionable defects were substantiated, but version-specific OpenCode integration and failure cleanup retain uncertainty while the four live scenarios remain explicitly owed.

Testing

Live-driven: the real launcher pinned and verified a requested OpenCode model, refused an inline provider/model#variant while keeping --effort as the only effort interface, mapped --effort high to the openai variant that the installed build then recorded on the worker's first message, refused and stopped a worker that ran a different model than requested, delivered the launch brief into a real OpenCode worker that started its turn on the pinned model, and passed the opt-in installed-build live guard. The portable serial repack was exercised through bin/fm-test-run.sh --check-coverage (12 lanes, 1,092,777 ms max under the unchanged 1,200,000 ms budget). The focused tests/fm-spawn-opencode-model.test.sh regression passed; tests/fm-spawn-dispatch-profile.test.sh still stops in its inherited worker-environment case because the fixture's synthetic /usr/bin:/bin PATH has no sed on this host, which is the already-recorded and declined environmental limitation rather than a product failure, so it is not re-filed. All disposable labs, secondmate homes, tmux servers, and the fake upstream were removed and the git worktree is clean.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Request a supported OpenCode model through the real launcher; it pins the model in the worker worktree, verifies the installed build's own readback, launches, and asserts the worker session model — wi… ✅ pass live live-spawn-supported-model.log shows the project-config notice, 'confirmed the model pin: requested=john-remote/qwen3.8-flash-next observed=john-remote/qwen3.8-flash-next', '2.2.2 model assertion pass…
Request an unsupported inline provider/model#variant while also passing --effort; the launcher refuses with an actionable message and starts no worker instead of silently overriding --effort ✅ pass live live-inline-variant-refusal.log: 'error: inline OpenCode model variants are unsupported: 'john-remote/qwen3.8-flash-next#high'; use provider/model with --effort instead', SPAWN_EXIT:1, 0 task records,…
Request a provider-mapped effort (openai provider with --effort high); the variant rides the model selection, the real worker's own session record reports #high, and a project-owned .opencode/opencode… ✅ pass live live-spawn-effort-variant.log (launcher: 'the project's own ... already selects 'openai/m1#high'; keeping it as it is', 'model assertion passed ... observed=openai/m1#high', SPAWN_EXIT:0, no ownership…
Launch OpenCode against the installed build through the opt-in live guard and confirm the observed session model matches the pin ✅ pass live live-opencode-model-live-e2e.txt: 'ok - opencode (opencode v2.2.2): a real TUI launch ran the requested model (requested=john-remote/qwen3.8-flash-next observed=john-remote/qwen3.8-flash-next)', exit=…
Dispatch the launch brief into a real OpenCode worker environment and observe the expected dispatch behaviour: the brief is delivered, the turn starts, and the worker replies on the pinned model ✅ pass live live-spawn-worker-pane.txt: the spawned worker pane shows the FIRSTMATE_OP v1 launch brief as delivered task text, its ~44k-token context, the started turn, and the 'Build · Qwen3.8 Flash Next' model…
Launch a model-pinned worker whose build actually runs a different model; the launcher refuses the launch, stops the task endpoint, and leaves the parent home intact ✅ pass live live-wrong-model-refusal.log: 'error: OpenCode ran sm-fail's first message on model 'opencode/pulse-alpha-free#default', not the requested 'openai/m1' ... stopped task endpoint driver:fm-sm-fail', SPA…
Repack the portable serial shards and observe the coverage guard keep every shard inside the unchanged 20-minute budget with 12 disjoint lanes ✅ pass live live-shard-coverage.txt: 'FM_TEST_COVERAGE ok total=310 ... serial=268 serial_shards=12 ... serial_max_ms=1092777 serial_budget_ms=1200000' exit 0, and --list-lanes prints portable-serial-1of12 thro…
Evidence: Live launcher run: supported OpenCode model pinned, verified, launched, and asserted

Source: Live launcher run: supported OpenCode model pinned, verified, launched, and asserted

# Live drive: supported OpenCode model + effort through the real launcher (installed opencode v2.2.2, disposable lab home)
# date: 2026-11-02T15:35:02-08:00
# command: FM_HOME=<lab> bin/fm-spawn.sh sm-live <seeded-secondmate-home> --secondmate --harness opencode --model john-remote/qwen3.8-flash-next --effort high
# worktree under test: ~/.no-mistakes/worktrees/215a10a6db23/01M6ESPRBW0YANDGK1B4PY2YPT (commit 70394f88)

2026-11-02T15:33:51-08:00
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-lab.z4MTqf
notice: opencode v2.2.2 has no top-level --model flag; model 'john-remote/qwen3.8-flash-next' rides the worktree's .opencode/opencode.jsonc, which this launch writes and verifies
warning: secondmate sm-live sync skipped before launch: primary default-branch commit cannot be resolved
notice: opencode v2.2.2 confirmed the model pin: requested=john-remote/qwen3.8-flash-next observed=john-remote/qwen3.8-flash-next
notice: 2.2.2 model assertion passed for sm-live: requested=john-remote/qwen3.8-flash-next observed=john-remote/qwen3.8-flash-next#default
spawned sm-live harness=opencode kind=secondmate mode=secondmate yolo=off window=driver:fm-sm-live worktree=/tmp/fm-sm-live.LDuAdD/secondmate
SPAWN_EXIT:0
2026-11-02T15:34:33-08:00
== pin written by the launcher:
{"model":{"providerID":"john-remote","model":"qwen3.8-flash-next"}}
== ownership record:
/tmp/fm-lab.z4MTqf/state/sm-live.opencode-model-config
/tmp/fm-sm-live.LDuAdD/secondmate/.opencode/opencode.jsonc
== task meta:
window=driver:fm-sm-live
worktree=/tmp/fm-sm-live.LDuAdD/secondmate
harness=opencode
kind=secondmate
model=john-remote/qwen3.8-flash-next
effort=high
opencode_version=2.2.2
Evidence: Spawned OpenCode worker pane: launch brief delivered, Build · Qwen3.8 Flash Next

Source: Spawned OpenCode worker pane: launch brief delivered, Build · Qwen3.8 Flash Next

 ⠸ FIRSTMATE_OP v1 launch brief  +
  ┃  The main firstmate's answer normally writes that closing line at answer time; when a blocker or wait clears WITHOUT an answer from
  ┃  the main firstmate, append `resolved [at=<epoch>]: {how it cleared}` yourself (keyed with `[key=<slug>]` if you opened it with one)    FIRSTMATE_OP v1 launch brief
  ┃  as your domain resumes.
  ┃  Routine internal supervision, heartbeats, retries, and crewmate churn stay inside your own home and must not touch that status         Context
  ┃  file.                                                                                                                                  44,463 tokens
  ┃                                                                                                                                         17% used
  ┃  # Definition of done
  ┃  You are persistent by default. Do not exit just because your queue is empty.
  ┃  On startup and restart, run normal firstmate bootstrap and recovery through `bin/fm-session-start.sh` for your own home, but only
  ┃  to RECONCILE work that is already yours: in-flight crewmates, tracked backlog items, and durable watches recorded in this home.
  ┃  When you have no assigned or in-flight work after that reconciliation, go idle and wait silently for the main firstmate to route
  ┃  you a task.
  ┃  An empty queue is a healthy resting state, not a cue to invent work: never spawn a survey, audit, or any self-directed "find work"
  ┃  task on your own initiative.
  ┃  If this charter cannot be carried out, append `blocked [at=<epoch>]: {why}` or `failed [at=<epoch>]: {why}` to the main status file
  ┃  and stop.
  ┃

     + Thought · 14.2s

  ┃
  ┃  $ bin/fm-session-start.sh 2>&1 | head -100
  ┃
  ┃  (92 earlier lines)
  ┃  absent
  ┃
  ┃  ================================================================================
  ┃  NETWORK CHECKS
  ┃  ================================================================================
  ┃  IN PROGRESS - the deferred network checks have not finished yet.
  ┃  NOT yet confirmed: GitHub authentication, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, project clone
  ┃  refresh with its drift reporting, and inactive terminal-outcome reconciliation.
  ┃

  ┃
  ┃  $ find '/tmp/fm-lab.z4MTqf/state/sm-live.inbox' -maxdepth 1 -name '*.msg' 2>/dev/null | sort -V
  ┃
  ┃  (no output)
  ┃

     ⠇ Thinking

  ┃
  ┃
  ┃
  ┃  Build · Qwen3.8 Flash Next FP4-Local v5.3.1 JohnOfUs DeepSeek V4 Flash (Remote)
  ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
   ⬝⬝⬝⬝⬝⬝⬝⬝ esc interrupt                                                                                   44.5K (17%)  ctrl+p commands    /tmp/fm-sm-live.LDuAdD/secondmate
Evidence: Inline provider/model#variant refused with no worker started

Source: Inline provider/model#variant refused with no worker started

# Live drive: unsupported inline provider/model#variant refused, keeping --effort as the only effort interface
# date: 2026-11-02T15:35:02-08:00
# command: bin/fm-spawn.sh sm-inline-variant <seeded-secondmate-home> --secondmate --harness opencode --model 'john-remote/qwen3.8-flash-next#high' --effort low

fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-lab.z4MTqf
error: inline OpenCode model variants are unsupported: 'john-remote/qwen3.8-flash-next#high'; use provider/model with --effort instead
SPAWN_EXIT:1
task records written: 0
pin files present: 0

# tmux windows on the private lab server after the refusal (no worker window was created):
driver:1: bash* (1 panes) [180x50] 
driver:2: fm-sm-live (1 panes) [180x50] 
Evidence: Effort high mapped to the openai variant and kept project-owned config untouched

Source: Effort high mapped to the openai variant and kept project-owned config untouched

# Live drive: provider effort mapped to the OpenCode model variant, end-to-end on the installed build
# date: 2026-11-02T15:46:30-08:00
# installed build: opencode v2.2.2 (~/.opencode/bin/opencode)
# command: bin/fm-spawn.sh sm-effort <seeded-secondmate-home> --secondmate --harness opencode --model openai/m1 --effort high
# upstream: disposable OpenAI-compatible endpoint at 127.0.0.1:49391 (fake, answers MODEL_PIN_EFFORT_OK); the worker's project config carries provider openai -> that endpoint
# worktree under test: ~/.no-mistakes/worktrees/215a10a6db23/01M6ESPRBW0YANDGK1B4PY2YPT (commit 70394f88)

2026-11-02T15:43:58-08:00
fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-lab.ePLMhG
notice: opencode v2.2.2 has no top-level --model flag; model 'openai/m1' rides the worktree's .opencode/opencode.jsonc, which this launch writes and verifies
warning: secondmate sm-effort sync skipped before launch: primary default-branch commit cannot be resolved
notice: the project's own /tmp/fm-sm2.wlb17t/secondmate/.opencode/opencode.jsonc already selects 'openai/m1#high'; keeping it as it is
notice: 2.2.2 model assertion passed for sm-effort: requested=openai/m1 observed=openai/m1#high
spawned sm-effort harness=opencode kind=secondmate mode=secondmate yolo=off window=driver:fm-sm-effort worktree=/tmp/fm-sm2.wlb17t/secondmate
SPAWN_EXIT:0
== project-owned config left in place:
{
  "model": {"providerID": "openai", "model": "m1", "variant": "high"},
  "provider": {
    "openai": {
      "options": { "baseURL": "http://127.0.0.1:49391/v1", "apiKey": "lab-key" },
      "models": { "m1": { "name": "Lab m1" } }
    }
  }
}
== ownership record (absent = the file is the project's):
none
== task meta:
window=driver:fm-sm-effort
worktree=/tmp/fm-sm2.wlb17t/secondmate
harness=opencode
kind=secondmate
model=openai/m1
effort=high
opencode_version=2.2.2
2026-11-02T15:45:27-08:00

== live opencode TUI footer in the spawned worker pane (model + variant):
     Build · Lab m1 · 1m 25s
  ┃  Build · Lab m1 OpenAI · high

== the build's own session record for the worker's first message:
{"id":"m1","providerID":"openai","variant":"high"}

== requests the real build made to the disposable upstream:
      1 listening on 49391
      1 POST /v1/responses model=m1 stream=True
Evidence: Worker TUI footer showing the requested model and variant (Build · Lab m1 OpenAI · high)

Source: Worker TUI footer showing the requested model and variant (Build · Lab m1 OpenAI · high)

 • New session                   +
  ┃  States: working, needs-decision, blocked, paused, done, failed.
  ┃  Substitute `<epoch>` with the current Unix time in seconds - run `date +%s` and write the number it printed; a stamp that is not       New session - 2026-11-02T23:44:02.
  ┃  plain digits records no time at all.                                                                                                   016Z
  ┃  Use `paused: {why}` (distinct from `blocked:`) only when your domain is deliberately idling on a known external wait you expect to
  ┃  clear on its own, naming when it clears with `until <YYYY-MM-DDTHH:MMZ>` (UTC) when you know; use `blocked:` when you are stuck and
  ┃  need firstmate to act.
  ┃  Use this only for material phase changes, a captain decision, a real blocker, a failure, work ready for review, or work you landed.
  ┃  Work you landed includes a merge you performed yourself under standing merge authority and one the captain merged on the forge:
  ┃  under that authority nothing is ever \"ready for review\", so a landed merge that goes unreported reaches the captain as silence.
  ┃  This is also how you return the answer to a marked from-firstmate request above.
  ┃  A marked request requires one correlated answer after the work; it does not require a separate receipt or start acknowledgement.
  ┃  Never append `working:` merely to acknowledge receipt or announce that a marked request has started.
  ┃  When a routed-work phase has a supervisor-actionable material change worth reporting under the rule above, give that reported phase
  ┃  a stable key.
  ┃  If its first reportable event is `working [key=<work-slug>]: {material phase}`, use the same key on its later `paused`, `done`,
  ┃  `failed`, `needs-decision`, or `blocked` event so the earlier working phase is superseded.
  ┃  When a keyed phase ends without another reportable state, append `resolved [key=<work-slug>] [at=<epoch>]: {why it is no longer
  ┃  active}`.
  ┃  `resolved` separately closes an escalated decision or blocker, and only a `resolved` line carrying that decision's exact key closes
  ┃  it: a later `done` or `working` event never does, even when the answer is what started that work.
  ┃  The main firstmate's answer normally writes that closing line at answer time; when a blocker or wait clears WITHOUT an answer from
  ┃  the main firstmate, append `resolved [at=<epoch>]: {how it cleared}` yourself (keyed with `[key=<slug>]` if you opened it with one)
  ┃  as your domain resumes.
  ┃  Routine internal supervision, heartbeats, retries, and crewmate churn stay inside your own home and must not touch that status
  ┃  file.
  ┃
  ┃  # Definition of done
  ┃  You are persistent by default. Do not exit just because your queue is empty.
  ┃  On startup and restart, run normal firstmate bootstrap and recovery through `bin/fm-session-start.sh` for your own home, but only
  ┃  to RECONCILE work that is already yours: in-flight crewmates, tracked backlog items, and durable watches recorded in this home.
  ┃  When you have no assigned or in-flight work after that reconciliation, go idle and wait silently for the main firstmate to route
  ┃  you a task.
  ┃  An empty queue is a healthy resting state, not a cue to invent work: never spawn a survey, audit, or any self-directed "find work"
  ┃  task on your own initiative.
  ┃  If this charter cannot be carried out, append `blocked [at=<epoch>]: {why}` or `failed [at=<epoch>]: {why}` to the main status file
  ┃  and stop.
  ┃

     Error: The provider response ended unexpectedly.

     Build · Lab m1 · 1m 25s

  ┃
  ┃
  ┃
  ┃  Build · Lab m1 OpenAI · high
  ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
  /tmp/fm-sm2.wlb17t/secondmate                                                                        shift+tab agents  ctrl+p commands    /tmp/fm-sm2.wlb17t/secondmate
Evidence: OpenCode's own session record for the worker's first message

Source: OpenCode's own session record for the worker's first message

{"id":"m1","providerID":"openai","variant":"high"}
Evidence: Installed-build live guard: real TUI launch ran the requested model

Source: Installed-build live guard: real TUI launch ran the requested model

FM_TEST_BEGIN 2026-11-02T23:48:48Z tests/fm-opencode-model-live-e2e.test.sh family=live-harness-optin expected_gate_skip=live-capability
ok - installed opencode (opencode v2.2.2): ~/.opencode/bin/opencode
ok - opencode (opencode v2.2.2): the build parses the pinned model selection (requested=john-remote/qwen3.8-flash-next)
ok - opencode (opencode v2.2.2): a real TUI launch ran the requested model (requested=john-remote/qwen3.8-flash-next observed=john-remote/qwen3.8-flash-next)
FM_TEST_END 2026-11-02T23:49:08Z tests/fm-opencode-model-live-e2e.test.sh exit=0 duration_ms=20364 gate_skip=false
FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0 duration_ms=20424
FM_TEST_SUMMARY_FAMILY family=live-harness-optin count=1 duration_ms=20364 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-opencode-model-live-e2e.test.sh duration_ms=20364
Evidence: Wrong-model guard: worker on a different model refused and stopped, home intact

Source: Wrong-model guard: worker on a different model refused and stopped, home intact

# Live drive: a worker that does not run the requested model is refused and stopped, and the parent home is left intact
# date: 2026-11-02T15:52:04-08:00  commit: 70394f88
# command: bin/fm-spawn.sh sm-fail <seeded-secondmate-home> --secondmate --harness opencode --model openai/m1 --effort high
# setup: the disposable lab has no provider named 'openai', so the build runs its own default model

fm-gate-refuse: gate agent lifecycle permitted only against lab home /tmp/fm-lab.1qbaYS
notice: opencode v2.2.2 has no top-level --model flag; model 'openai/m1' rides the worktree's .opencode/opencode.jsonc, which this launch writes and verifies
warning: secondmate sm-fail sync skipped before launch: primary default-branch commit cannot be resolved
notice: opencode v2.2.2 confirmed the model pin: requested=openai/m1#high observed=openai/m1#high
error: OpenCode ran sm-fail's first message on model 'opencode/pulse-alpha-free#default', not the requested 'openai/m1' (2.2.2); refusing the launch rather than leaving a wrong-model worker running; stopped task endpoint driver:fm-sm-fail
SPAWN_EXIT:1
== lab home after the failed launch:
. .. config data .fm-lab-home projects state tmux 
== lab state:
home-summary.json sm-fail.status 

== tmux windows on the private lab server after the refusal (worker endpoint closed):
driver:1: bash* (1 panes) [180x50] 
Evidence: Coverage guard: 12 serial shards, 1,092,777 ms max under the 1,200,000 ms budget

Source: Coverage guard: 12 serial shards, 1,092,777 ms max under the 1,200,000 ms budget

# Live drive: portable serial shard repack keeps every shard inside the unchanged 20-minute budget
# date: 2026-11-02T15:50:32-08:00  commit: 70394f88
# $ bin/fm-test-run.sh --check-coverage
FM_TEST_COVERAGE ok total=310 parallel=26 parallel_max_ms=814249 parallel_imbalance_ms=552 parallel_unhinted=0 serial=268 serial_shards=12 serial_unhinted=30 serial_max_ms=1092777 serial_budget_ms=1200000 herdr=16
exit=0

# $ bin/fm-test-run.sh --list-lanes | grep portable-serial
portable-serial
portable-serial-1of12
portable-serial-2of12
portable-serial-3of12
portable-serial-4of12
portable-serial-5of12
portable-serial-6of12
portable-serial-7of12
portable-serial-8of12
portable-serial-9of12
portable-serial-10of12
portable-serial-11of12
portable-serial-12of12
- Outcome: 🔧 1 issue found → auto-fixed ✅ across 3 runs (1h28m38s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - medium risk

✅ No issues found.

✅ No issues found.

🔧 **Test** - 1 issue found → auto-fixed ✅
  • ⚠️ bin/fm-spawn.sh:2696 - A live model-pinned OpenCode launch prints bin/fm-spawn.sh: line 2696: json_escape: command not found twice on stderr. opencode_model_selection_json calls json_escape, but that helper is only defined at bin/fm-spawn.sh:3893, and the early mechanism probe at bin/fm-spawn.sh:3349 runs before the definition is evaluated, so the first validation call executes a missing command (its output is discarded, so the launch continues). I reproduced this on every real --harness opencode --model ... launch against installed OpenCode 2.2.2, including the one that succeeded, so the operator sees shell-error noise in normal spawn output and the early selection validation is silently a no-op. Define json_escape before its first use (or move the OpenCode helper block after the definition) and add a behavioral assertion that a supported OpenCode launch's captured stderr contains no shell error.
  • Live validation: ✅ go - 8 of 10 scenarios driven live against the product
Scenario Result Live Evidence
A quiet 12-second watcher checkpoint parks for its full bound, prints 'checkpoint: no actionable wake within 12s', exits 124, and releases the watch lock ✅ pass live live-watcher-checkpoint.txt: exit=124 elapsed_ms=12069, stdout checkpoint: no actionable wake within 12s, state/.watch.lock/pid absent; also tests/fm-watch-checkpoint.test.sh
The quiet-checkpoint assertion no longer depends on a specific timeout string: the same assertion holds for a 12s and a 1s bound ✅ pass live live-watcher-checkpoint.txt: 12s run prints ...within 12s, 1s run prints ...within 1s, both exit 124; the changed tests assert only checkpoint: no actionable wake within
An actionable durable wake is surfaced by a 12-second checkpoint and the checkpoint exits 0 well before its bound instead of being read as a quiet timeout ✅ pass live live-watcher-checkpoint.txt: queued check: unacknowledged durable wake, checkpoint printed check: rearm-resurface and exited 0 in 301ms; tests/fm-pr-health.test.sh and `tests/fm-watch-checkpoint…
An invalid checkpoint bound is refused rather than silently clamped or interpreted as a default ✅ pass live live-watcher-checkpoint.txt: --seconds 0 → exit 2, error: --seconds must be greater than zero
The changed timing-sensitive suites pass: fm-watch-checkpoint, fm-pr-health (durable wake stays quiet), fm-fleet-ledger, fm-herdr-groups, and the modified contributions watcher case ⏸️ untested no The prior payload only recorded local fixture-based test-harness exit statuses (targeted-suite-tails.txt: fm-watch-checkpoint, fm-pr-health, fm-fleet-ledger, fm-herdr-groups, fm-spawn-opencode-model,…
Requesting an inline provider/model#variant on the installed OpenCode build is refused with an actionable message and no worker, pin, or pane is created ✅ pass live live-opencode-launch-surface.txt: real bin/fm-spawn.sh ... --harness opencode --model &#39;ollama-cloud/glm-5.3#high&#39; against OpenCode 2.2.2 → exit 1, `error: inline OpenCode model variants are unsuppor…
Requesting a supported model with --effort on the installed OpenCode build writes the worktree pin, verifies the build's own readback, launches the worker, and the observed session model matches the p… ✅ pass live live-opencode-launch-surface.txt: confirmed the model pin: requested=labfake/m1 observed=labfake/m1, 2.2.2 model assertion passed ... observed=labfake/m1#default, spawned into a real tmux window w…
When the installed build cannot confirm the pin, the launch refuses instead of starting a worker on the build's default model ✅ pass live live-opencode-launch-surface.txt: on the host whose default managed-service port is occupied, the real launch exited 1 with `did not report the pinned model back ... refusing to launch a worker that w…
After a failed pinned launch, the owned pin is gone from the pool slot so a reused slot cannot silently inherit the model ✅ pass live in-run observation of the real product: while the launch ran, &lt;slot&gt;/.opencode/opencode.jsonc contained the pin; after each failed launch cat &lt;slot&gt;/.opencode/opencode.jsonc returned `No such file…
Run the repository's opt-in OpenCode live guard end-to-end with FM_OPENCODE_MODEL_LIVE=1 ⏸️ untested no The guard probes every candidate from opencode models with a plain opencode debug config, and on this host that command blocks ~20s and fails because the operator's resident opencode serve (pid…
  • tests/fm-watch-checkpoint.test.sh (pass; includes the renamed test_quiet_checkpoint_reports_completion_and_releases_lock)
  • tests/fm-pr-health.test.sh (pass, including the timing-agnostic test_watcher_surfaces_one_wake)
  • tests/fm-fleet-ledger.test.sh (pass, 12s checkpoint budget with exit-code tolerance)
  • tests/fm-herdr-groups.test.sh (pass, 12s watcher poll with exit-code tolerance)
  • tests/fm-spawn-opencode-model.test.sh (pass, full fake-build behavior matrix including pin ownership/retirement faults)
  • tests/fm-contributions.test.sh — the modified case test_watcher_surfaces_new_contribution_once passed; the file's other cases fail on this host because the fixture's fake date hardcodes /bin/date, which NixOS lacks
  • tests/fm-spawn-dispatch-profile.test.sh — blocked on this host: the synthetic worker PATH /usr/bin:/bin has no sed, so bin/fm-git-strip-ai-trailers.sh fails
  • live: FM_HOME=&lt;disposable&gt; bin/fm-watch-checkpoint.sh --seconds 12 → exit 124 at 12.07s, checkpoint: no actionable wake within 12s, lock released
  • live: bin/fm-watch-checkpoint.sh --seconds 1 in a sibling disposable home → same generic line, exit 124 at 1.07s
  • live: fm_wake_append check buried ... then bin/fm-watch-checkpoint.sh --seconds 12 → wake surfaced (check: rearm-resurface), exit 0 in 301ms
  • live: bin/fm-watch-checkpoint.sh --seconds 0 → exit 2, refused
  • live: bin/fm-spawn.sh &lt;task&gt; &lt;tmp-project&gt; --harness opencode --model &#39;ollama-cloud/glm-5.3#high&#39; against real OpenCode 2.2.2 → exit 1 with the inline-variant refusal, no launch
  • live: bin/fm-spawn.sh &lt;task&gt; &lt;tmp-project&gt; --harness opencode --model labfake/m1 --effort high in a marked lab home with XDG config/state/data isolated and a disposable local OpenAI-compatible endpoint → pin written {&#34;model&#34;:{&#34;providerID&#34;:&#34;labfake&#34;,&#34;model&#34;:&#34;m1&#34;}}, confirmed the model pin: requested=labfake/m1 observed=labfake/m1, session assertion observed=labfake/m1#default, spawn succeeded, worker pane ran the real OpenCode TUI on that model
  • live: same launch on a host-occupied managed-service port → exit 1 refusing to launch a worker on the build's default model (fail-safe path)
  • live: while a pinned launch ran, &lt;slot&gt;/.opencode/opencode.jsonc held the pin; after each failed launch cat &lt;slot&gt;/.opencode/opencode.jsonc returned No such file or directory for both slots
  • teardown: killed the lab worker pane, returned both treehouse slots, removed the lab home, temp project, temp XDG dirs and local model server; working tree clean

🔧 Fix applied.
✅ Re-checked - no issues remain.

  • Live validation: ✅ go - 7 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Request a supported OpenCode model plus --effort through bin/fm-spawn.sh; the launcher writes and verifies the worktree pin, launches the worker, reports the observed model, and emits no shell error ✅ pass live spawn-live-secondmate.log (spawn exit 0: 'confirmed the model pin: requested=john-remote/qwen3.8-flash-next observed=john-remote/qwen3.8-flash-next', 'model assertion passed', no 'command not found');…
Request an unsupported inline provider/model#variant; the launcher refuses with the accepted interface instead of overriding the explicit --effort ✅ pass live model-pin-refusals.log: error: inline OpenCode model variants are unsupported: &#39;john-remote/qwen3.8-flash-next#high&#39;; use provider/model with --effort instead, exit 1, zero task records written
Launch the installed OpenCode 2.2.2 build against a pinned worktree configuration and confirm the session's recorded model matches the requested pin ✅ pass live live-guard.log (requested=john-remote/qwen3.8-flash-next observed=john-remote/qwen3.8-flash-next) plus opencode-session-recorded-model.json read independently from the worker's session export
Run the pinned OpenCode worker through the real tmux worker environment and observe the launch brief processed on the requested model ✅ pass live worker-pane-opencode-tui.txt: real OpenCode TUI in the spawned pane shows FIRSTMATE_OP launch-brief, the reply MODEL_PIN_OK, and 'Build · Qwen3.8 Flash Next ... JohnOfUs DeepSeek V4 Flash (Remote)'
A pre-existing project-owned .opencode/opencode.jsonc is never overwritten by the launcher's model pin ✅ pass live model-pin-refusals.log: refusal names the file and its model, and the project file still reads {"model":{"providerID":"qualitycorp-glm","model":"glm-5.3"}} afterwards with zero task records written
A Codex-primary watcher checkpoint still honours its 12-second bound, reporting observable quiet completion and releasing its lock ✅ pass live checkpoint-quiet-12s.timing / checkpoint-quiet-12s.out: exit=124 elapsed_ms=12079, 'checkpoint: no actionable wake within 12s', no state/.watch.lock left
The timing-sensitive suites keep the 12-second checkpoint bound while asserting observable behavior (exit status, quiet line, wake counts) rather than assuming timeout timing ✅ pass live tests/fm-fleet-ledger.test.sh, tests/fm-pr-health.test.sh, tests/fm-herdr-groups.test.sh all exit 0, and the changed `watcher surfaces one newly durable contribution signal without re-ringing it…
Run the OpenCode dispatch-profile suite end to end through the worker environment ⏸️ untested no I ran the suite; it stops at the launch-environment-allowlist case, which re-executes the emitted launch under a hard-coded PATH=/usr/bin:/bin, and the emitted command needs sed there. On this Nix…
  • tests/fm-spawn-opencode-model.test.sh (all cases pass, including the new command not found assertion on the supported 2.x launch)
  • tests/fm-watch-checkpoint.test.sh (all cases pass, including the renamed observable-behavior quiet checkpoint case and the real-host bound case)
  • tests/fm-fleet-ledger.test.sh, tests/fm-pr-health.test.sh, tests/fm-herdr-groups.test.sh (all pass with the 12-second checkpoint bound and observable assertions)
  • tests/fm-contributions.test.sh — the changed case watcher surfaces one newly durable contribution signal without re-ringing it passes; the suite's remaining failures are pre-existing /bin/date fakebin shims that do not exist on this NixOS host (unchanged by this diff)
  • XDG_CONFIG_HOME=&lt;disposable&gt; FM_OPENCODE_MODEL_LIVE=1 tests/fm-opencode-model-live-e2e.test.sh (real installed opencode v2.2.2: pin parsed, real TUI launch ran the requested model)
  • Live bin/fm-spawn.sh &lt;id&gt; &lt;secondmate-home&gt; --secondmate --harness opencode --model john-remote/qwen3.8-flash-next --effort high from a private fm-lab tmux socket in a marked lab home (spawn exit 0, pin confirmed, model assertion passed, no shell errors)
  • Independent read of the launched worker's own opencode session export --standalone record and the real TUI pane capture showing the pinned model
  • Live refusal: bin/fm-spawn.sh ... --scout --harness opencode --model &#39;john-remote/qwen3.8-flash-next#high&#39; --effort low
  • Live refusal + retry: secondmate spawn against a project-owned .opencode/opencode.jsonc selecting a different model, twice
  • Live bin/fm-watch-checkpoint.sh --seconds 12 in a fresh marked lab home (exit 124 at 12079ms, quiet line, no watch lock left)

✅ No issues found.

  • Live validation: ✅ go - 7 of 7 scenarios driven live against the product
Scenario Result Live Evidence
Request a supported OpenCode model through the real launcher; it pins the model in the worker worktree, verifies the installed build's own readback, launches, and asserts the worker session model — wi… ✅ pass live live-spawn-supported-model.log shows the project-config notice, 'confirmed the model pin: requested=john-remote/qwen3.8-flash-next observed=john-remote/qwen3.8-flash-next', '2.2.2 model assertion pass…
Request an unsupported inline provider/model#variant while also passing --effort; the launcher refuses with an actionable message and starts no worker instead of silently overriding --effort ✅ pass live live-inline-variant-refusal.log: 'error: inline OpenCode model variants are unsupported: 'john-remote/qwen3.8-flash-next#high'; use provider/model with --effort instead', SPAWN_EXIT:1, 0 task records,…
Request a provider-mapped effort (openai provider with --effort high); the variant rides the model selection, the real worker's own session record reports #high, and a project-owned .opencode/opencode… ✅ pass live live-spawn-effort-variant.log (launcher: 'the project's own ... already selects 'openai/m1#high'; keeping it as it is', 'model assertion passed ... observed=openai/m1#high', SPAWN_EXIT:0, no ownership…
Launch OpenCode against the installed build through the opt-in live guard and confirm the observed session model matches the pin ✅ pass live live-opencode-model-live-e2e.txt: 'ok - opencode (opencode v2.2.2): a real TUI launch ran the requested model (requested=john-remote/qwen3.8-flash-next observed=john-remote/qwen3.8-flash-next)', exit=…
Dispatch the launch brief into a real OpenCode worker environment and observe the expected dispatch behaviour: the brief is delivered, the turn starts, and the worker replies on the pinned model ✅ pass live live-spawn-worker-pane.txt: the spawned worker pane shows the FIRSTMATE_OP v1 launch brief as delivered task text, its ~44k-token context, the started turn, and the 'Build · Qwen3.8 Flash Next' model…
Launch a model-pinned worker whose build actually runs a different model; the launcher refuses the launch, stops the task endpoint, and leaves the parent home intact ✅ pass live live-wrong-model-refusal.log: 'error: OpenCode ran sm-fail's first message on model 'opencode/pulse-alpha-free#default', not the requested 'openai/m1' ... stopped task endpoint driver:fm-sm-fail', SPA…
Repack the portable serial shards and observe the coverage guard keep every shard inside the unchanged 20-minute budget with 12 disjoint lanes ✅ pass live live-shard-coverage.txt: 'FM_TEST_COVERAGE ok total=310 ... serial=268 serial_shards=12 ... serial_max_ms=1092777 serial_budget_ms=1200000' exit 0, and --list-lanes prints portable-serial-1of12 thro…
  • bin/fm-lab-home.sh create + bin/fm-home-seed.sh sm-live &lt;home&gt; --no-projects (disposable marked lab home and seeded secondmate)
  • bin/fm-spawn.sh sm-live &lt;home&gt; --secondmate --harness opencode --model john-remote/qwen3.8-flash-next --effort high in a private tmux -L fm-lab pane
  • bin/fm-spawn.sh sm-inline-variant <home> --secondmate --harness opencode --model 'john-remote/qwen3.8-flash-next#high' --effort low
  • bin/fm-spawn.sh sm-effort &lt;home&gt; --secondmate --harness opencode --model openai/m1 --effort high with a disposable OpenAI-compatible endpoint at 127.0.0.1:49391 serving /v1/responses
  • opencode session list --standalone --format json + opencode session export &lt;sid&gt; --standalone on the spawned worker directory
  • bin/fm-spawn.sh sm-fail &lt;home&gt; --secondmate --harness opencode --model openai/m1 --effort high with no openai provider configured (wrong-model guard)
  • FM_OPENCODE_MODEL_LIVE=1 bin/fm-test-run.sh tests/fm-opencode-model-live-e2e.test.sh (run twice, exit 0 both times)
  • bin/fm-test-run.sh tests/fm-spawn-opencode-model.test.sh (exit 0)
  • bin/fm-test-run.sh --check-coverage and bin/fm-test-run.sh --list-lanes
  • tmux -L fm-lab capture-pane -p -t driver:&lt;window&gt; on the live OpenCode worker panes
🔧 **Document** - 1 issue found ✅
  • ℹ️ docs/configuration.md - The environment-variable reference in docs/configuration.md does not list the OpenCode launch bounds this change adds (FM_OPENCODE_PROBE_TIMEOUT, FM_OPENCODE_READ_TIMEOUT, FM_OPENCODE_MODEL_ASSERT_POLLS, FM_OPENCODE_MODEL_ASSERT_INTERVAL), and it already omits the pre-existing FM_OPENCODE_DELIVERY_POLLS/FM_OPENCODE_POLL_INTERVAL/FM_OPENCODE_SUBMIT_* knobs, so those bounds stay discoverable only from bin/fm-spawn.sh. I left the reference untouched rather than expanding this focused change into a general OpenCode tuning-knob documentation pass; a later pass could either document that whole family or state in the reference that harness launch bounds stay with the script header.

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

✅ No issues found.

The two-second checkpoint budget INT-kills the watcher before its first
poll completes fm-herdr-groups.sh sync under load, producing intermittent
serial-shard failures. Validated 2/2 loaded runs at 12s.
…nts now retain the documented exit-124 assertion, and sibling checkpoint callers no longer swallow failures without checking output. `fm-watch-checkpoint`, `fm-pr-health`, `fm-fleet-ledger`, and `fm-herdr-groups` passed locally; the affected contribution test passed, but its full suite could not complete because this environment lacks `/bin/date`. Other CI failures were in unrelated supervision, follow-up, and transport tests and did not establish a code cause in this change
# Conflicts:
#	bin/fm-spawn.sh
#	tests/fm-watch-checkpoint.test.sh
…: the portable serial remainder outgrew a ten-shard split, so the modeled largest shard (1,302,306 ms) exceeded the unchanged 1,200,000 ms packing budget. tests/fm-test-run.test.sh's coverage assertion runs in portable parallel 2, which is why that job failed with the same guard error. Change (three files): - bin/fm-test-run.sh: PORTABLE_SERIAL_SHARDS 10 -> 12, the single owner of the shard count/lane names; and refreshed the grew suite's balance hint tests/fm-spawn-opencode-model.test.sh 31794 -> 122124 ms, verified against its successful timing record (122124 ms, exit 0) in the PR's CI run artifact rather than invented. - .github/workflows/ci.yml: serial shard matrix 10 -> 12 entries (lanes still derive `of<n>` from strategy.job-total, so a future mismatch is refused loudly, not silently truncated). No timeout changed; the 30-minute normal tier, 5-minute fast tier and Herdr backstop are untouched. - docs/fm-test-portable-shards.md: job inventory 15 -> 17 Linux jobs (twelve serial), and the packing account now states the 13113156 ms serial total, why ten shards could not hold it, and the measured 1092777 ms largest shard. Invariant: every portable serial shard's modeled duration must stay within the 1,200,000 ms budget, and the CI matrix, runner constant, lane names and docs must agree. It is enforced at every place that owns it: the runner constant (lane name acceptance, per-shard assignment, coverage guard), the matrix that drives job-total and artifact names, and the docs. Tests already derive the count from the runner (`--list-lanes`) and from the parsed matrix, so no test literal needed changing — verified 12 non-empty shards covering the 268-script lane exactly once. Verification: reproduced the CI failure exactly by stashing the change (`coverage guard: largest portable serial shard packs 1302306ms above the 1200000ms target`, exit 1) and re-ran with the fix: `FM_TEST_COVERAGE ok ... serial_shards=12 serial_max_ms=1092777 serial_budget_ms=1200000`, exit 0. tests/fm-test-run.test.sh: all coverage/sharding assertions pass, including 'coverage guard bounds the unmeasured share and serial packing within twenty minutes', 'portable serial shards are a deterministic disjoint cover of the serial lane', and the exact-budget boundary test. Its two remaining local failures are environmental and unrelated to this change: tests/fm-session-lock-ancestry.test.sh fails on this host at 'the pty-host was not reparented to init after the daemon ended' (that file is not touched by this PR; it is a pid-1/subreaper property of this container), and the derived `--jobs` assertion fails only because of that nested failure. tests/fm-ci-workflow.test.sh cannot run here (no ruby for its YAML parse), so I replicated its matrix-vs-runner comparison with sed/awk: matrix shard list expands to exactly portable-serial-1of12..12of12, matching --list-lanes. Scope: I reverted the timed-out agent's uncommitted edits to tests/fm-supervision-host-fixture.sh, since this round is explicitly limited to the deterministic shard correction and must not change or rerun tests/fm-supervision-host.test.sh; that handoff finding (ci-2) remains for a later focused decision, as do the four owed live OpenCode scenarios. No checks were waived, no protection weakened, no timeout raised
…both failed only at tests/fm-supervision-host.test.sh: 'return-fail: the host did not hand the wake to main'. The CI log shows that test is the sole failure in the shard (suite ran ~23 min); the Full suite check only aggregates the same lane failure. Reproduction and evidence: the exact case passes locally in ~4s (8 consecutive runs), so no behavior defect was found; the old assertion was a poll-count wait, wait_until 250 host_exited, whose real wall-clock duration can far exceed 25s on a slow runner, and upstream Firstmate already replaced these exact waits with a wall-clock wait_until_deadline 60 helper, documenting the same flake. To establish the race, I ran the exact test with a 30s engine stub: the old 25s poll-count wait necessarily expires while the host still correctly hands the wake off; with the fix the same test passes at ~35s. Fix: in tests/fm-supervision-host-fixture.sh, added wait_until_deadline (same 0.1s polling, wall-clock deadline, with hand-back diagnostics) and converted all 74 host/hook hand-back waits (host_exited and hook_exited) to a 60s deadline. This closes every sibling site in the duplicate fixture-based suite, including handback, TERM-stop, latch, mirror, and stream cases; the newer split suites already use the deadline helper. Waits that intentionally test a timing bound or a negative/trigger condition were left unchanged (park boundary 150/400/450, boundary-late 300, bounded turn 300, kill-KILL 100, negative 20, stream trigger 200). The wake-handoff assertions themselves are unchanged, as is all production behavior. Verification: the exact failing return-fail test passes; the whole tests/fm-supervision-host.test.sh suite passes (rc 0, 8m42s) after a first transient failure in an unrelated, unchanged handled_at_least wait that passed on rerun; shellcheck -x is clean; bin/fm-test-run.sh --check-coverage reports ok with 12 serial shards and serial_max_ms=1092777 under the unchanged 1200000ms budget. The pre-existing local latch-park 'tagged' failure (this session does not own supervision) reproduces on unmodified HEAD and is environmental, not caused by this change. The earlier serial-shard coverage correction (12 shards, matrix, hints, docs) is untouched; the 12-second checkpoint behavior, OpenCode model-pin behavior/tests, and the four live OpenCode scenarios remain as before, with the live scenarios still owed rather than claimed passed. No checks were waived or skipped, no timeout or full-suite guard was changed, and the PR remains open for the pipeline to push and rerun required CI
@kesslerio kesslerio changed the title fix(bin): pin OpenCode worker models to the installed build's supported surface fix(bin): select and verify the OpenCode model the installed build accepts Nov 2, 2026
@kesslerio
kesslerio merged commit eeb59bc into main Nov 3, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant