Skip to content

Commit 77ca5d4

Browse files
WatchAndyTWclaude
andcommitted
feat: smarter CLI waiting — heartbeat + status file + foreground-wait relay (v0.7.5)
A slow-but-alive CLI backend was mistaken for a failure: the orchestrator backgrounded run.mjs and gave up on a ~15s blind poll, even though the CLI answered seconds later within run.mjs's own (generous) timeout. The fix adds liveness signalling and corrects how the relay waits. - run.mjs: wrap each backend invoke() with a heartbeat — emit a "[mmt] backend '<be>' still running (Ns)…" stderr line every 10s and maintain a status file ({state:"running"|"done"|"failed", backend, elapsed_ms, …}) at a PREDICTABLE path: "<call-file>.status.json" when --call-file is used, else .mmt/calls/<callId>.status.json. stopHeartbeat is called on every terminal branch (quota / fail / success). All best-effort: the tick is fully try/catch-guarded (incl. an EPIPE-safe stderr write) so it can never throw out of the interval or affect the dispatch; the timer is unref'd so it never keeps the process alive. Generous timeouts unchanged — observability added, not shortened. - Relay guidance (workflows/team.mjs + reasoning.mjs, commands/team.md + reasoning.md, generated agents/*.md): instruct the relay to run run.mjs FOREGROUND and WAIT — no backgrounding, no self-imposed sleep/timeout/tail -f, re-run and keep waiting on the relay shell's own time limit, and poll the status file to confirm liveness. A slow response is explicitly NOT a failure. - Tests: 93/93 offline (was 91) — a failed-call status:"failed" assertion on the existing fake-CLI harness, a --call-file success status:"done" test, and foreground-wait + status-file markers in workflows.test.mjs. Docs/badge -> 93. Codex-verified: live heartbeat fires at 10s on a 12s fake CLI; codex code-reviewed the diff, flagged one [Medium] (unguarded stderr write in the tick), which was fixed, and a focused re-review returned PASS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5a25079 commit 77ca5d4

15 files changed

Lines changed: 168 additions & 10 deletions

File tree

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
33
"name": "multi-model-team",
44
"displayName": "Multi-Model Team",
5-
"version": "0.7.4",
5+
"version": "0.7.5",
66
"description": "Delegates token-heavy, self-contained tasks to local CLI backends chosen by task size and type, with credit-exhaustion fallback through the backend chain to native Claude and a glanceable statusline HUD. Routing and shipped backend behavior are configured in config/roster.json; adding a new backend kind requires invoker and health support in code. /team runs a decompose → dispatch → verify → fix → synthesize pipeline; an optional config-gated UserPromptSubmit hook can proactively nudge delegation.",
77
"author": {
88
"name": "WatchAndyTW",

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ glanceable statusline HUD.
99
(the agy lane runs under a real pseudo-terminal — ConPTY on Windows, forkpty on POSIX); everything
1010
else is Node stdlib. Cross-platform (Windows/Linux/macOS). `package.json` `"type":"module"`.
1111

12-
**Status:** built, adversarially reviewed, and green. `npm test` passes **91/91** offline
12+
**Status:** built, adversarially reviewed, and green. `npm test` passes **93/93** offline
1313
(plus live agy + codex smoke tests under `MMT_LIVE=1`). Two live backends: **agy** (Gemini)
1414
and **codex** (OpenAI Codex CLI); opencode remains a config-only stub. codex also serves as the
1515
**`/team` verifier**. See `README.md` (user-facing), `PROBES.md` (grounded CLI findings), and
@@ -335,7 +335,7 @@ fallback hop.
335335
## Testing
336336

337337
```bash
338-
npm test # offline: 91/91 routing + unit tests (no backend calls)
338+
npm test # offline: 93/93 routing + unit tests (no backend calls)
339339
MMT_LIVE=1 npm test # + live agy + codex smoke tests (network required)
340340
```
341341

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Multi-model orchestration for Claude Code. Route by task, fan out in parallel, f
88

99
![Node](https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white)
1010
![Type](https://img.shields.io/badge/module-ESM-f7df1e)
11-
![Tests](https://img.shields.io/badge/tests-91%2F91%20passing-3fb950)
11+
![Tests](https://img.shields.io/badge/tests-93%2F93%20passing-3fb950)
1212
![Platforms](https://img.shields.io/badge/platform-win%20%7C%20linux%20%7C%20macOS-555)
1313
![Deps](https://img.shields.io/badge/runtime%20deps-1%20(node--pty)-blue)
1414

@@ -276,7 +276,7 @@ docs/REASONING.md design contract for the /reasoning Fusion pipeline
276276
## 🧪 Testing
277277

278278
```bash
279-
npm test # offline: 91/91 routing + unit tests (no backend calls)
279+
npm test # offline: 93/93 routing + unit tests (no backend calls)
280280
MMT_LIVE=1 npm test # also run live agy + codex smoke tests (network required)
281281
```
282282

agents/agy.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ You are the **agy** dispatcher for the multi-model-team plugin. You do **not** s
4747
- If the task references a local file/dir the backend should read itself, add
4848
`--add-dir "<dir>"` so the backend reads it on its own quota instead of through Claude.
4949
- Do NOT inline the task on the command line and do NOT add commentary to the prompt.
50+
- **Run it in the FOREGROUND and WAIT.** The agy CLI can take several minutes on a
51+
hard task; run.mjs blocks until it finishes (it has its own generous timeout). Do NOT
52+
background it (no `&`, no `run_in_background`), do NOT wrap it in your own
53+
`sleep`/`timeout`/`tail -f`, and do NOT give up early — a slow response is NOT a failure.
54+
If your shell hits its own time limit, run the SAME command again and keep waiting; run.mjs
55+
emits a `[mmt] backend still running (Ns)…` heartbeat to stderr and writes a
56+
`<call-file>.status.json` ({state:"running"|"done"|"failed"}) you can read to confirm it's alive.
5057
4. Interpret the output:
5158
- If stdout begins with `MMT_NATIVE_HANDOFF`, the **agy** CLI was unavailable/exhausted (it fell through the fallback chain) — return that sentinel verbatim so the orchestrator (Opus/Sonnet) handles it in-context.
5259
- Otherwise stdout **is** the delegated result. Return it **verbatim** — no analysis, no

agents/codex.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ You are the **codex** dispatcher for the multi-model-team plugin. You do **not**
4747
- If the task references a local file/dir the backend should read itself, add
4848
`--add-dir "<dir>"` so the backend reads it on its own quota instead of through Claude.
4949
- Do NOT inline the task on the command line and do NOT add commentary to the prompt.
50+
- **Run it in the FOREGROUND and WAIT.** The codex CLI can take several minutes on a
51+
hard task; run.mjs blocks until it finishes (it has its own generous timeout). Do NOT
52+
background it (no `&`, no `run_in_background`), do NOT wrap it in your own
53+
`sleep`/`timeout`/`tail -f`, and do NOT give up early — a slow response is NOT a failure.
54+
If your shell hits its own time limit, run the SAME command again and keep waiting; run.mjs
55+
emits a `[mmt] backend still running (Ns)…` heartbeat to stderr and writes a
56+
`<call-file>.status.json` ({state:"running"|"done"|"failed"}) you can read to confirm it's alive.
5057
4. Interpret the output:
5158
- If stdout begins with `MMT_NATIVE_HANDOFF`, the **codex** CLI was unavailable/exhausted (it fell through the fallback chain) — return that sentinel verbatim so the orchestrator (Opus/Sonnet) handles it in-context.
5259
- Otherwise stdout **is** the delegated result. Return it **verbatim** — no analysis, no

commands/reasoning.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ with no preamble:
153153
154154
node "<PLUGIN_ROOT>/src/bin/run.mjs" --call-file="<CALL_PATH>"
155155
156+
CRITICAL — run it in the FOREGROUND and WAIT for it to finish. The <BE> CLI can legitimately take
157+
several minutes; run.mjs blocks until it completes (it has its own generous timeout). Do NOT
158+
background it (no `&`, no run_in_background), do NOT wrap it in your own sleep/timeout/tail -f, and do
159+
NOT give up early — a slow response is NOT a failure. If your Bash tool hits its own time limit, run
160+
the SAME command again and keep waiting; run.mjs prints a "[mmt] backend still running (Ns)…"
161+
heartbeat to stderr and writes "<CALL_PATH>.status.json" ({state:"running"|"done"|"failed"}) you can
162+
read to confirm it is still alive.
163+
156164
If stdout begins with "MMT_NATIVE_HANDOFF" (the <BE> CLI was unavailable), return EXACTLY that
157165
sentinel line and nothing else — do not answer the question yourself.
158166
```

commands/team.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,14 @@ alone. There are two worker kinds:
150150
151151
node "<PLUGIN_ROOT>/src/bin/run.mjs" --call-file="<CALL_PATH>"
152152
153+
CRITICAL — run it in the FOREGROUND and WAIT for it to finish. The <BE> CLI can legitimately take
154+
several minutes on a hard task; run.mjs blocks until it completes (it has its own generous timeout).
155+
Do NOT background it (no `&`, no run_in_background), do NOT wrap it in your own sleep/timeout/tail -f,
156+
and do NOT give up early — a slow response is NOT a failure. If your Bash tool hits its own time
157+
limit, run the SAME command again and keep waiting; run.mjs prints a "[mmt] backend still running
158+
(Ns)…" heartbeat to stderr and writes "<CALL_PATH>.status.json" ({state:"running"|"done"|"failed"})
159+
you can read to confirm it is still alive.
160+
153161
If stdout begins with "MMT_NATIVE_HANDOFF" (the <BE> CLI was unavailable), return EXACTLY that
154162
sentinel line and nothing else — do not solve the task yourself. Otherwise return stdout as printed.
155163
````

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "multi-model-team",
3-
"version": "0.7.4",
3+
"version": "0.7.5",
44
"type": "module",
55
"private": true,
66
"description": "Delegates token-heavy, self-contained tasks to local CLI backends (agy/Gemini, codex) chosen by task size and type, with credit-exhaustion fallback to native Claude and a statusline HUD. Node ESM, zero-build, cross-platform (Windows/Linux/macOS).",

src/bin/run.mjs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,56 @@ function failuresLogPath() {
118118
return path.join(base, 'failures.log');
119119
}
120120

121+
// --- progress heartbeat + status file ---------------------------------------
122+
// A CLI backend can legitimately take minutes (agy/codex on a hard task). The OLD behaviour gave the
123+
// orchestrator no signal that a slow call was still ALIVE — so a Claude-side poll that timed out at
124+
// ~15s looked like a failure even though the CLI answered seconds later. While a call is in flight
125+
// run.mjs now (a) emits a `[mmt] …still running (Ns)` heartbeat to stderr every HEARTBEAT_MS, and
126+
// (b) maintains a status file at .mmt/calls/<callId>.status.json the orchestrator can poll —
127+
// {state:"running"|"done"|"failed", backend, elapsed_ms, ...}. This makes "slow but alive"
128+
// distinguishable from "dead" without shortening the (generous) hard_timeout. All best-effort: a
129+
// status/heartbeat write that fails NEVER affects the actual dispatch.
130+
const HEARTBEAT_MS = 10_000;
131+
132+
// Resolve the status-file path. PREDICTABLE so the orchestrator can poll it: when a --call-file was
133+
// used, it is "<call-file>.status.json" (sits right next to the payload the relay wrote). Otherwise
134+
// it falls back to .mmt/calls/<callId>.status.json (or MMT_LOG_DIR's parent /calls when set).
135+
function statusFilePath(callId, callFile) {
136+
if (callFile) return `${callFile}.status.json`;
137+
const base = process.env.MMT_LOG_DIR ? path.dirname(process.env.MMT_LOG_DIR) : path.join(process.cwd(), '.mmt');
138+
return path.join(base, 'calls', `${callId}.status.json`);
139+
}
140+
141+
function writeStatus(statusFile, callId, obj) {
142+
if (!statusFile) return;
143+
try {
144+
fs.mkdirSync(path.dirname(statusFile), { recursive: true });
145+
fs.writeFileSync(statusFile, JSON.stringify({ callId, updated: new Date().toISOString(), ...obj }) + '\n', 'utf8');
146+
} catch { /* status is observability-only — never break the run */ }
147+
}
148+
149+
// Start a heartbeat for a backend call. Returns a stop(finalObj) that clears the timer and writes the
150+
// terminal status. Writes an initial "running" status immediately so a poll right after spawn sees it.
151+
function startHeartbeat({ callId, statusFile, backend: be, model, tier, rule, startMs }) {
152+
writeStatus(statusFile, callId, { state: 'running', backend: be, model, tier, rule, elapsed_ms: 0 });
153+
const tick = () => {
154+
// Best-effort: a heartbeat must NEVER throw out of the interval callback (an uncaught throw there
155+
// is an unhandledException). Guard the stderr write (EPIPE/closed stream) and the status write.
156+
try {
157+
const elapsed = Date.now() - startMs;
158+
const secs = Math.round(elapsed / 1000);
159+
try { process.stderr.write(`[mmt] backend '${be}' still running (${secs}s)… [call=${callId}]\n`); } catch { /* stderr closed/EPIPE */ }
160+
writeStatus(statusFile, callId, { state: 'running', backend: be, model, tier, rule, elapsed_ms: elapsed });
161+
} catch { /* heartbeat is observability-only — never affect the dispatch */ }
162+
};
163+
const timer = setInterval(tick, HEARTBEAT_MS);
164+
if (typeof timer.unref === 'function') timer.unref();
165+
return function stop(finalObj) {
166+
clearInterval(timer);
167+
writeStatus(statusFile, callId, { backend: be, model, tier, rule, ...finalObj });
168+
};
169+
}
170+
121171
function logFailure({ backend: be, model, tier, rule, code, durMs, stderr, kind, callId }) {
122172
// 1. Loud, human-readable stderr banner (always — even if the file write fails).
123173
const why = sanitizeErr(stderr) || `exit ${code}`;
@@ -250,6 +300,8 @@ async function main() {
250300
const fullPrompt = COMPACT_PROMPT(task);
251301
const inChars = charCount(task);
252302
const callId = randomUUID().replace(/-/g, '').slice(0, 8);
303+
// Predictable status path so the orchestrator can poll it (next to the call file when given).
304+
const statusFile = statusFilePath(callId, opts.callFile);
253305

254306
// ---- 4. Walk the chain -----------------------------------------------------
255307
let lastErr = ''; // short, sanitized reason from the last backend that actually failed
@@ -293,6 +345,9 @@ async function main() {
293345

294346
state.start({ id: callId, backend: be, model, rule: D_rule, inChars });
295347
const startMs = Date.now();
348+
// Heartbeat: emit "still running" to stderr + status file while the (possibly slow) CLI works,
349+
// so the orchestrator can tell alive-but-slow from dead. Always stopped below (every branch).
350+
const stopHeartbeat = startHeartbeat({ callId, statusFile, backend: be, model, tier: D_tier, rule: D_rule, startMs });
296351
let res;
297352
try {
298353
res = await invoke(invokeCfg, fullPrompt, { model, tier: D_tier, addDir: opts.addDir });
@@ -307,6 +362,7 @@ async function main() {
307362
// fallback); the single fallback tally is counted once on the success hop via fallbackCount.
308363
if (res.quota) {
309364
lastErr = `quota/credit limit on '${be}'`;
365+
stopHeartbeat({ state: 'failed', kind: 'quota', code: res.code, elapsed_ms: durMs });
310366
logFailure({ backend: be, model, tier: D_tier, rule: D_rule, code: res.code, durMs,
311367
stderr: res.stderr || 'quota/credit limit reached', kind: 'quota', callId });
312368
state.end({ id: callId, backend: be, model, rule: D_rule, code: res.code, durMs, outChars, fallback: 0 });
@@ -317,9 +373,11 @@ async function main() {
317373
// cached health so a later call re-probes it instead of trusting a now-stale "healthy" verdict.
318374
if (res.code !== 0 || !cleanOut) {
319375
lastErr = sanitizeErr(res.stderr) || `exit ${res.code}, empty output`;
376+
const kind = res.code !== 0 ? 'nonzero-exit' : 'empty-output';
377+
stopHeartbeat({ state: 'failed', kind, code: res.code, elapsed_ms: durMs });
320378
logFailure({ backend: be, model, tier: D_tier, rule: D_rule, code: res.code, durMs,
321379
stderr: res.stderr || `no usable output (exit ${res.code})`,
322-
kind: res.code !== 0 ? 'nonzero-exit' : 'empty-output', callId });
380+
kind, callId });
323381
invalidateHealth(beCfg);
324382
state.end({ id: callId, backend: be, model, rule: D_rule, code: res.code, durMs, outChars, fallback: 0 });
325383
fallbackCount++; continue;
@@ -330,6 +388,7 @@ async function main() {
330388
// note. Missing/zero rate -> 0 cost.
331389
const rate = Number(beCfg.cost_per_1k_chars) || 0;
332390
const costMicros = Math.round(rate * (outChars / 1000) * 1e6);
391+
stopHeartbeat({ state: 'done', code: 0, elapsed_ms: durMs, out_chars: outChars });
333392
state.end({ id: callId, backend: be, model, rule: D_rule, code: 0, durMs, outChars, fallback: fallbackCount, costMicros });
334393
process.stdout.write(cleanOut + '\n');
335394
process.exit(0);

0 commit comments

Comments
 (0)