agent-dealer 1.2.7 → 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
  3. package/bundle/server/dist/adapters/agent-health.js +14 -3
  4. package/bundle/server/dist/adapters/github.js +84 -5
  5. package/bundle/server/dist/adapters/github.test.js +83 -1
  6. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  7. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  8. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  9. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  10. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  11. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  12. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  13. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  14. package/bundle/server/dist/coordinator/admission.js +14 -1
  15. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  16. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  17. package/bundle/server/dist/coordinator/auto-merge.js +244 -4
  18. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  19. package/bundle/server/dist/coordinator/commands.js +284 -23
  20. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  21. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  22. package/bundle/server/dist/coordinator/developer-effect.js +224 -4
  23. package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
  24. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  25. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  26. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  27. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  28. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  29. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  30. package/bundle/server/dist/coordinator/human-resolution.js +36 -1
  31. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  32. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  33. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  34. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  35. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  36. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  37. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  38. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  39. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  40. package/bundle/server/dist/coordinator/playbook-feedback.js +693 -0
  41. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  42. package/bundle/server/dist/coordinator/projection.js +10 -4
  43. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  44. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  45. package/bundle/server/dist/coordinator/prompts.js +211 -2
  46. package/bundle/server/dist/coordinator/prompts.test.js +231 -0
  47. package/bundle/server/dist/coordinator/recovery.js +57 -3
  48. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  49. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  50. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  51. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  52. package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
  53. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  54. package/bundle/server/dist/coordinator/routing.js +45 -6
  55. package/bundle/server/dist/coordinator/routing.test.js +76 -15
  56. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  57. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  58. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  59. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  60. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  61. package/bundle/server/dist/db/index.js +18 -0
  62. package/bundle/server/dist/db/schema.sql +5 -0
  63. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  64. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  65. package/bundle/server/dist/repository/human-actions.js +14 -0
  66. package/bundle/server/dist/repository/issues.js +67 -6
  67. package/bundle/server/dist/repository/work-items.js +7 -0
  68. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  69. package/bundle/server/dist/routes/human-actions.js +10 -8
  70. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  71. package/bundle/server/dist/routes/issues.js +19 -2
  72. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  73. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  74. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  75. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  76. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  77. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  78. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  79. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  80. package/bundle/server/package.json +2 -2
  81. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  82. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  83. package/bundle/server/static-ui/index.html +2 -2
  84. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  85. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  86. package/bundle/shared/dist/execution-contract.js +307 -0
  87. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  88. package/bundle/shared/dist/execution-contract.test.js +499 -0
  89. package/bundle/shared/dist/execution-report.d.ts +9 -9
  90. package/bundle/shared/dist/execution-report.js +2 -0
  91. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  92. package/bundle/shared/dist/failure-cause.js +2 -0
  93. package/bundle/shared/dist/human-actions.d.ts +4 -4
  94. package/bundle/shared/dist/human-actions.js +17 -0
  95. package/bundle/shared/dist/index.d.ts +85 -84
  96. package/bundle/shared/dist/index.js +3 -0
  97. package/bundle/shared/dist/issues.d.ts +176 -16
  98. package/bundle/shared/dist/issues.js +22 -0
  99. package/bundle/shared/dist/issues.test.js +3 -0
  100. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  101. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  102. package/bundle/shared/dist/worker-sessions.js +8 -0
  103. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  104. package/bundle/shared/dist/workflow.d.ts +4 -4
  105. package/bundle/shared/dist/workflow.js +16 -0
  106. package/bundle/shared/dist/workflow.test.js +3 -0
  107. package/bundle/shared/package.json +1 -1
  108. package/dist/action.js +4 -1
  109. package/dist/action.test.js +42 -0
  110. package/dist/index.js +1 -1
  111. package/package.json +1 -1
  112. package/bundle/server/static-ui/assets/index-yLyxRd-7.js +0 -64
@@ -32,14 +32,20 @@ function toEnvelope(o) {
32
32
  if (!rec || payloadType === undefined || !payload)
33
33
  return undefined;
34
34
  const stream = asRecord(rec.stream);
35
- return { streamKind: str(stream?.kind), streamId: str(stream?.id), payloadType, payload };
35
+ return {
36
+ streamKind: str(stream?.kind),
37
+ streamId: str(stream?.id),
38
+ payloadType,
39
+ payload,
40
+ };
36
41
  }
37
42
  /** Envelopes plus the count of non-empty lines that were not a JSON envelope. */
38
- function readEnvelopes(raw) {
43
+ function readEnvelopes(raw, lineTs) {
39
44
  const envelopes = [];
40
45
  let malformed = 0;
41
- for (const line of raw.split("\n")) {
42
- const t = line.trim();
46
+ const lines = raw.split("\n");
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const t = lines[i].trim();
43
49
  if (!t)
44
50
  continue;
45
51
  let parsed;
@@ -51,13 +57,49 @@ function readEnvelopes(raw) {
51
57
  continue;
52
58
  }
53
59
  const env = toEnvelope(parsed);
54
- if (env)
55
- envelopes.push(env);
60
+ if (env) {
61
+ const at = lineTs && i < lineTs.length ? lineTs[i] : null;
62
+ envelopes.push({
63
+ ...env,
64
+ recordedAt: parsed.recorded_at ?? null,
65
+ lineTs: typeof at === "number" && Number.isFinite(at) ? at : null,
66
+ });
67
+ }
56
68
  else
57
69
  malformed++;
58
70
  }
59
71
  return { envelopes, malformed };
60
72
  }
73
+ /**
74
+ * NOT-307: usable event time for one envelope, epoch milliseconds. Dealer-observed
75
+ * arrival first; the stream's own `recorded_at` only as a fallback, and only when
76
+ * it parses — real sessions batch-stamp it (µs number, whole session in ~40ms),
77
+ * fixtures redact it to `<ts>`. Numbers above 1e14 are microseconds, below are
78
+ * milliseconds; strings go through Date.parse.
79
+ */
80
+ function envelopeTimeMs(env) {
81
+ if (env.lineTs !== null)
82
+ return env.lineTs;
83
+ const r = env.recordedAt;
84
+ if (typeof r === "number" && Number.isFinite(r)) {
85
+ return r > 1e14 ? Math.round(r / 1000) : Math.round(r);
86
+ }
87
+ if (typeof r === "string") {
88
+ const ms = Date.parse(r);
89
+ return Number.isFinite(ms) ? ms : null;
90
+ }
91
+ return null;
92
+ }
93
+ function isoOf(ms) {
94
+ if (ms === null || !Number.isFinite(ms))
95
+ return null;
96
+ try {
97
+ return new Date(ms).toISOString();
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
61
103
  /** Session-log lines are auxiliary: unparseable ones are skipped rather than failing the run. */
62
104
  function readModelCompleted(sessionLog) {
63
105
  if (!sessionLog)
@@ -122,8 +164,12 @@ function classifyReason(reason) {
122
164
  return { kind: "other", message };
123
165
  }
124
166
  export function parseMuseRun(input) {
125
- const { envelopes, malformed } = readEnvelopes(input.stdout);
167
+ const { envelopes, malformed } = readEnvelopes(input.stdout, input.lineTs);
126
168
  const stderr = input.stderr ?? "";
169
+ // A garbage `now` must never stamp garbage: only a parseable ISO string qualifies.
170
+ const fallbackTs = typeof input.now === "string" && input.now && Number.isFinite(Date.parse(input.now))
171
+ ? input.now
172
+ : null;
127
173
  let sessionId = null;
128
174
  const tools = new Map();
129
175
  const toolByTask = new Map();
@@ -132,6 +178,16 @@ export function parseMuseRun(input) {
132
178
  // The first run seen is the primary one; a cron job may start further runs in the same process.
133
179
  let primaryRunId = null;
134
180
  const runIds = new Set();
181
+ // NOT-307: stream-derived per-event times. firstTs anchors the `system` event;
182
+ // terminalTs anchors `assistant`/`result`; per-tool intent/result endpoints make
183
+ // durationMs. All stay null when the stream gave nothing usable (fixtures redact
184
+ // recorded_at, no lineTs) so unstamped output stays byte-stable.
185
+ let firstTs = null;
186
+ let terminalTs = null;
187
+ const noteEnvelopeTime = (at) => {
188
+ if (at !== null && firstTs === null)
189
+ firstTs = at;
190
+ };
135
191
  for (const env of envelopes) {
136
192
  const p = env.payload;
137
193
  if (sessionId === null && env.streamKind === "session" && env.streamId)
@@ -141,7 +197,11 @@ export function parseMuseRun(input) {
141
197
  runIds.add(runId);
142
198
  if (primaryRunId === null)
143
199
  primaryRunId = runId ?? null;
200
+ noteEnvelopeTime(isoOf(envelopeTimeMs(env)));
144
201
  if (env.payloadType === "run.terminal.completed" || env.payloadType === "run.terminal.failed") {
202
+ const at = isoOf(envelopeTimeMs(env));
203
+ if (at !== null && terminalTs === null)
204
+ terminalTs = at;
145
205
  terminals.push({
146
206
  runId: runIdOf(p),
147
207
  terminal: env.payloadType === "run.terminal.completed" ? "completed" : "failed",
@@ -156,7 +216,14 @@ export function parseMuseRun(input) {
156
216
  if (operation?.startsWith("tool:") && key?.startsWith("tool:")) {
157
217
  const callId = key.slice("tool:".length);
158
218
  const existing = tools.get(callId);
159
- tools.set(callId, existing ?? { callId, name: operation.slice("tool:".length), outcome: null, error: null });
219
+ tools.set(callId, existing ?? {
220
+ callId,
221
+ name: operation.slice("tool:".length),
222
+ outcome: null,
223
+ error: null,
224
+ startedAt: isoOf(envelopeTimeMs(env)),
225
+ durationMs: null,
226
+ });
160
227
  const taskId = str(event?.task_id);
161
228
  if (taskId)
162
229
  toolByTask.set(taskId, callId);
@@ -169,11 +236,17 @@ export function parseMuseRun(input) {
169
236
  const facts = asRecord(p.correlation_facts);
170
237
  const outcome = facts?.outcome === "success" || facts?.outcome === "failure" ? facts.outcome : null;
171
238
  const prev = tools.get(callId);
239
+ const endMs = envelopeTimeMs(env);
240
+ const startMs = prev?.startedAt ? Date.parse(prev.startedAt) : NaN;
172
241
  tools.set(callId, {
173
242
  callId,
174
243
  name: str(facts?.tool_name) ?? prev?.name ?? null,
175
244
  outcome,
176
245
  error: prev?.error ?? null,
246
+ startedAt: prev?.startedAt ?? null,
247
+ // Present only when the matching result arrived with both endpoints timed —
248
+ // a bare intent (tool still running at kill time) carries no duration.
249
+ durationMs: endMs !== null && Number.isFinite(startMs) && endMs >= startMs ? endMs - startMs : null,
177
250
  });
178
251
  }
179
252
  else if (env.payloadType === "task.lifecycle.failed") {
@@ -249,7 +322,17 @@ export function parseMuseRun(input) {
249
322
  rateLimited,
250
323
  failure,
251
324
  exitCode: input.exitCode,
252
- events: normalizeMuseRun({ sessionId, confirmedModel, tools: toolList, finalText, usage, failure }),
325
+ events: normalizeMuseRun({
326
+ sessionId,
327
+ confirmedModel,
328
+ tools: toolList,
329
+ finalText,
330
+ usage,
331
+ failure,
332
+ now: fallbackTs,
333
+ firstTs,
334
+ terminalTs,
335
+ }),
253
336
  };
254
337
  }
255
338
  /**
@@ -260,18 +343,29 @@ export function parseMuseRun(input) {
260
343
  */
261
344
  export function normalizeMuseRun(r) {
262
345
  const out = [];
346
+ const at = (ts) => ts ? { ts } : r.now ? { ts: r.now } : {};
263
347
  if (r.sessionId) {
264
348
  out.push({
265
349
  type: "system",
266
350
  subtype: "init",
267
351
  session_id: r.sessionId,
268
352
  ...(r.confirmedModel ? { model: r.confirmedModel } : {}),
353
+ ...at(r.firstTs ?? null),
269
354
  });
270
355
  }
271
356
  for (const t of r.tools)
272
- out.push({ type: "tool_call", name: t.name ?? "unknown" });
357
+ out.push({
358
+ type: "tool_call",
359
+ name: t.name ?? "unknown",
360
+ ...at(t.startedAt),
361
+ ...(t.durationMs !== null ? { durationMs: t.durationMs } : {}),
362
+ });
273
363
  if (r.finalText) {
274
- out.push({ type: "assistant", message: { content: [{ type: "text", text: r.finalText }] } });
364
+ out.push({
365
+ type: "assistant",
366
+ message: { content: [{ type: "text", text: r.finalText }] },
367
+ ...at(r.terminalTs ?? null),
368
+ });
275
369
  }
276
370
  const usage = {};
277
371
  if (r.usage.inputTokens !== null)
@@ -286,6 +380,7 @@ export function normalizeMuseRun(r) {
286
380
  type: "result",
287
381
  ...(r.failure ? { is_error: true, result: r.failure.message } : { result: r.finalText ?? "" }),
288
382
  ...(Object.keys(usage).length > 0 ? { usage } : {}),
383
+ ...at(r.terminalTs ?? null),
289
384
  });
290
385
  return out;
291
386
  }
@@ -123,6 +123,8 @@ export function buildMuseSettings(agentDeck, role = "developer", evidence = NOT_
123
123
  headers: {
124
124
  "x-agent-deck-deck-id": agentDeck.deckId,
125
125
  "x-agent-deck-workspace": agentDeck.workspace,
126
+ // NOT-305: opaque per-session correlation UUID — observability metadata, not authority.
127
+ ...(agentDeck.correlationId ? { "x-agent-deck-correlation-id": agentDeck.correlationId } : {}),
126
128
  },
127
129
  // `mode`, not `required`: writing both drops the whole MCP block. Never "optional".
128
130
  mode: "required",
@@ -308,6 +310,14 @@ function validateInput(input) {
308
310
  if (!deckId?.trim() || !workspace?.trim()) {
309
311
  throw new MuseIsolationError("invalid_input", "agentDeck.deckId and agentDeck.workspace are required");
310
312
  }
313
+ // NOT-305: when a correlation UUID rides the launch config it must be opaque-UUID
314
+ // shaped — anything else is a caller bug, refused before anything is written.
315
+ const correlationId = input.agentDeck?.correlationId;
316
+ if (correlationId !== undefined && correlationId !== null) {
317
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(correlationId)) {
318
+ throw new MuseIsolationError("invalid_input", "agentDeck.correlationId must be a UUID");
319
+ }
320
+ }
311
321
  let workspaceReal;
312
322
  try {
313
323
  workspaceReal = fs.realpathSync(workspace);
@@ -371,7 +381,14 @@ function snapshotInput(raw) {
371
381
  const cred = raw.credential && typeof raw.credential === "object" ? raw.credential : undefined;
372
382
  return deepFreeze({
373
383
  ...raw,
374
- agentDeck: deck ? { url: deck.url, deckId: deck.deckId, workspace: deck.workspace } : raw.agentDeck,
384
+ agentDeck: deck
385
+ ? {
386
+ url: deck.url,
387
+ deckId: deck.deckId,
388
+ workspace: deck.workspace,
389
+ ...(deck.correlationId !== undefined ? { correlationId: deck.correlationId } : {}),
390
+ }
391
+ : raw.agentDeck,
375
392
  credential: cred ? { ...cred } : raw.credential,
376
393
  env: raw.env ? { ...raw.env } : undefined,
377
394
  });
@@ -625,3 +625,40 @@ test("only muse-config.ts and tests import muse-config-core", () => {
625
625
  });
626
626
  assert.deepEqual(offenders, []);
627
627
  });
628
+ // NOT-305: the worker session's opaque Deck correlation UUID rides the Muse settings as
629
+ // the exact `x-agent-deck-correlation-id` observability header; without one the header
630
+ // is absent, and a malformed one is refused before anything is written.
631
+ test("Muse settings carry the exact correlation header only when the session has one", () => {
632
+ const correlationId = "bbbbbbbb-2222-4222-b222-bbbbbbbbbbbb";
633
+ const fx = fixture();
634
+ const attempt = prepareMuseAttemptPinned(input(fx, "developer", {
635
+ agentDeck: { url: DECK_URL, deckId: "deck-123", workspace: fx.worktree, correlationId },
636
+ }));
637
+ try {
638
+ const onDisk = JSON.parse(fs.readFileSync(attempt.settingsPath, "utf8"));
639
+ assert.deepEqual(onDisk.mcpServers["agent-deck"].headers, {
640
+ "x-agent-deck-deck-id": "deck-123",
641
+ "x-agent-deck-workspace": fs.realpathSync(fx.worktree),
642
+ "x-agent-deck-correlation-id": correlationId,
643
+ });
644
+ assert.doesNotThrow(() => attempt.verify());
645
+ }
646
+ finally {
647
+ attempt.cleanup();
648
+ }
649
+ const fxBare = fixture();
650
+ const bare = prepareMuseAttemptPinned(input(fxBare, "developer"));
651
+ try {
652
+ const onDisk = JSON.parse(fs.readFileSync(bare.settingsPath, "utf8"));
653
+ assert.equal("x-agent-deck-correlation-id" in onDisk.mcpServers["agent-deck"].headers, false);
654
+ }
655
+ finally {
656
+ bare.cleanup();
657
+ }
658
+ const fxBad = fixture();
659
+ const bad = () => prepareMuseAttemptPinned(input(fxBad, "developer", {
660
+ agentDeck: { url: DECK_URL, deckId: "deck-123", workspace: fxBad.worktree, correlationId: "not-a-uuid" },
661
+ }));
662
+ assert.equal(code(bad), "invalid_input");
663
+ assert.deepEqual(fs.readdirSync(fxBad.baseDir), []);
664
+ });
@@ -151,6 +151,11 @@ export function foldServeViewItems(items, turnId) {
151
151
  name: name ?? null,
152
152
  outcome: it.status === "inProgress" ? null : failed ? "failure" : "success",
153
153
  error: asString(it.failureReason) ?? null,
154
+ // NOT-307: the serve turn items carry no per-tool times; exec-lane arrival
155
+ // stamping does not apply here, so these stay unstamped (no `ts` fallback
156
+ // is passed for this lane either).
157
+ startedAt: null,
158
+ durationMs: null,
154
159
  });
155
160
  }
156
161
  }
@@ -55,6 +55,57 @@ export async function spawnCli(runId, cmd, args, cwd, opts) {
55
55
  let timedOut = false;
56
56
  let settled = false;
57
57
  let killEscalation;
58
+ // NOT-307: idle watchdog state. lastActivityMs starts at spawn so a child that
59
+ // never emits anything is killed after exactly idleTimeoutMs. stdout bytes always
60
+ // count; progressSource (when supplied) can only move the mark forward, never back.
61
+ const spawnStartMs = Date.now();
62
+ let firstOutputMs = null;
63
+ let lastActivityMs = spawnStartMs;
64
+ let idleTimedOut = false;
65
+ let aborted = false;
66
+ const idleMs = opts.idleTimeoutMs;
67
+ const idleEnabled = typeof idleMs === "number" && Number.isFinite(idleMs) && idleMs > 0;
68
+ let idleTimer;
69
+ let progressTimer;
70
+ const clearIdle = () => {
71
+ if (idleTimer)
72
+ clearTimeout(idleTimer);
73
+ if (progressTimer)
74
+ clearInterval(progressTimer);
75
+ idleTimer = undefined;
76
+ progressTimer = undefined;
77
+ };
78
+ const rescheduleIdle = () => {
79
+ if (!idleEnabled || settled || aborted)
80
+ return;
81
+ if (idleTimer)
82
+ clearTimeout(idleTimer);
83
+ idleTimer = setTimeout(fireIdle, idleMs);
84
+ idleTimer.unref?.();
85
+ };
86
+ const noteActivity = (atMs) => {
87
+ const now = typeof atMs === "number" && Number.isFinite(atMs) ? atMs : Date.now();
88
+ // A stale report (an old mtime re-read, a same-millisecond second chunk) leaves
89
+ // the deadline standing: only genuinely newer activity postpones the kill, so a
90
+ // source stuck on an old value can never hold the watchdog off indefinitely.
91
+ if (firstOutputMs === null)
92
+ firstOutputMs = Math.max(0, now - spawnStartMs);
93
+ if (now <= lastActivityMs)
94
+ return;
95
+ lastActivityMs = now;
96
+ rescheduleIdle();
97
+ };
98
+ // Mirror the wall-clock timeout path exactly (same SIGTERM, same finish(124) 500ms
99
+ // later) so salvage and infra-retry accounting treat an idle kill as a timeout.
100
+ // Never fires after an abort: a lost lease is not idleness.
101
+ function fireIdle() {
102
+ if (settled || aborted)
103
+ return;
104
+ idleTimedOut = true;
105
+ timedOut = true;
106
+ killRunProcess(runId);
107
+ setTimeout(() => finish(124), 500);
108
+ }
58
109
  const logStream = fs.createWriteStream(opts.logPath, { flags: "w" });
59
110
  // A WriteStream's 'error' event has no default handler — left unguarded, any
60
111
  // stream error (a write after end, ENOSPC, a permissions problem) is an uncaught
@@ -102,6 +153,8 @@ export async function spawnCli(runId, cmd, args, cwd, opts) {
102
153
  const onAbort = () => {
103
154
  if (settled)
104
155
  return;
156
+ aborted = true;
157
+ clearIdle();
105
158
  try {
106
159
  child.kill("SIGTERM");
107
160
  }
@@ -132,11 +185,32 @@ export async function spawnCli(runId, cmd, args, cwd, opts) {
132
185
  else
133
186
  opts.signal.addEventListener("abort", onAbort, { once: true });
134
187
  }
188
+ // NOT-307: line splitter for onStdoutLine only — untouched when no hook is set.
189
+ // Declared before finish() so no path can observe the bindings uninitialized.
190
+ let lineTail = "";
191
+ const emitLine = (line) => {
192
+ const hook = opts.onStdoutLine;
193
+ if (!hook)
194
+ return;
195
+ try {
196
+ hook(line, Date.now());
197
+ }
198
+ catch (err) {
199
+ console.error(`[spawn-cli] onStdoutLine for ${runId}`, err);
200
+ }
201
+ };
135
202
  const finish = (exitCode) => {
136
203
  if (settled)
137
204
  return;
138
205
  settled = true;
206
+ // A final line without a trailing newline still arrived — report it at close time.
207
+ if (opts.onStdoutLine && lineTail !== "") {
208
+ const tail = lineTail;
209
+ lineTail = "";
210
+ emitLine(tail);
211
+ }
139
212
  clearTimeout(timer);
213
+ clearIdle();
140
214
  cleanupAbort();
141
215
  unregisterChild(runId);
142
216
  // Write any stderr BEFORE ending the stream — writing after end() throws
@@ -163,6 +237,10 @@ export async function spawnCli(runId, cmd, args, cwd, opts) {
163
237
  exitCode,
164
238
  transcript: stdoutChunks.join(""),
165
239
  timedOut,
240
+ idleTimedOut,
241
+ firstOutputMs,
242
+ lastActivityAt: new Date(lastActivityMs).toISOString(),
243
+ idleForMs: idleTimedOut ? Math.max(0, Date.now() - lastActivityMs) : null,
166
244
  });
167
245
  };
168
246
  if (logStream.destroyed || logStream.errored) {
@@ -195,10 +273,47 @@ export async function spawnCli(runId, cmd, args, cwd, opts) {
195
273
  killRunProcess(runId);
196
274
  setTimeout(() => finish(124), 500);
197
275
  }, opts.timeoutMs);
276
+ // NOT-307: arm the idle watchdog once the child exists. The first deadline is one
277
+ // full idle window after spawn (lastActivityMs starts at spawn), so a child that
278
+ // never emits anything is killed after exactly idleTimeoutMs.
279
+ if (idleEnabled) {
280
+ rescheduleIdle();
281
+ if (opts.progressSource) {
282
+ // Sample at most every 30s and at least every handful of ms: frequent enough
283
+ // that the kill lands near the bound, sparse enough to never matter for I/O.
284
+ const pollMs = Math.min(Math.max(Math.floor(idleMs / 10), 50), 30_000);
285
+ progressTimer = setInterval(() => {
286
+ if (settled || aborted)
287
+ return;
288
+ let at = null;
289
+ try {
290
+ at = opts.progressSource?.() ?? null;
291
+ }
292
+ catch {
293
+ at = null;
294
+ }
295
+ if (typeof at === "number" && Number.isFinite(at) && at >= spawnStartMs) {
296
+ noteActivity(at);
297
+ }
298
+ }, pollMs);
299
+ progressTimer.unref?.();
300
+ }
301
+ }
198
302
  child.stdout?.on("data", (buf) => {
199
303
  const chunk = buf.toString();
200
304
  stdoutChunks.push(chunk);
201
305
  logStream.write(buf);
306
+ // NOT-307: any stdout bytes are progress. (stderr deliberately does not count:
307
+ // the contract defines progress as stdout bytes or the external source.)
308
+ noteActivity();
309
+ if (opts.onStdoutLine) {
310
+ lineTail += chunk;
311
+ let idx;
312
+ while ((idx = lineTail.indexOf("\n")) >= 0) {
313
+ emitLine(lineTail.slice(0, idx));
314
+ lineTail = lineTail.slice(idx + 1);
315
+ }
316
+ }
202
317
  });
203
318
  child.stderr?.on("data", (buf) => {
204
319
  stderrChunks.push(buf.toString());
@@ -208,6 +323,7 @@ export async function spawnCli(runId, cmd, args, cwd, opts) {
208
323
  return;
209
324
  settled = true;
210
325
  clearTimeout(timer);
326
+ clearIdle();
211
327
  cleanupAbort();
212
328
  unregisterChild(runId);
213
329
  logStream.end();
@@ -216,3 +216,127 @@ test("spawnCli stdin close on an instantly-exiting child still settles", async (
216
216
  assert.equal(result.exitCode, 3);
217
217
  assert.equal(result.timedOut, false);
218
218
  });
219
+ // NOT-307: a child that prints nothing for longer than idleTimeoutMs is killed like a
220
+ // wall-clock timeout (timedOut true) with the idle flag set — well before timeoutMs.
221
+ // Fake timers: the child is real (only the parent's clock is mocked), so the SIGTERM
222
+ // kill and the close event need real event-loop turns between mock advances.
223
+ test("spawnCli kills a silent child after idleTimeoutMs, well before timeoutMs", async (t) => {
224
+ t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
225
+ try {
226
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
227
+ const { cmd, args } = nodeChild("setInterval(() => {}, 1000);");
228
+ const promise = spawnCli("test-run-idle-kill", cmd, args, process.cwd(), {
229
+ logPath,
230
+ timeoutMs: 60_000,
231
+ idleTimeoutMs: 250,
232
+ });
233
+ const flushIo = async (rounds) => {
234
+ for (let i = 0; i < rounds; i++)
235
+ await new Promise((r) => setImmediate(r));
236
+ };
237
+ t.mock.timers.tick(1000); // the 250ms idle bound fires; the 60s wall clock does not
238
+ await flushIo(50); // let the SIGTERM kill land and the close event arrive
239
+ t.mock.timers.tick(1000); // the finish(124) 500ms delay after the kill
240
+ const result = await promise;
241
+ assert.equal(result.timedOut, true, "an idle kill reads as a timeout downstream");
242
+ assert.equal(result.idleTimedOut, true);
243
+ assert.equal(result.transcript, "");
244
+ assert.equal(result.firstOutputMs, null, "a silent child never produced output");
245
+ assert.ok(typeof result.idleForMs === "number" && result.idleForMs >= 250, `idleForMs reports the observed silence, got ${result.idleForMs}`);
246
+ }
247
+ finally {
248
+ t.mock.timers.reset();
249
+ }
250
+ });
251
+ // NOT-307: a progressSource that keeps reporting fresh activity holds the watchdog off.
252
+ // The child prints once then idles forever; after forty idle windows of mock time it must
253
+ // still be alive (the abort below ends it — an idle kill would have beaten the abort).
254
+ test("spawnCli does not idle-kill while the progress source reports activity", async (t) => {
255
+ t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
256
+ try {
257
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
258
+ const { cmd, args } = nodeChild("console.log('started'); setInterval(() => {}, 1000);");
259
+ const controller = new AbortController();
260
+ const promise = spawnCli("test-run-idle-source", cmd, args, process.cwd(), {
261
+ logPath,
262
+ timeoutMs: 3_600_000,
263
+ idleTimeoutMs: 250,
264
+ progressSource: () => Date.now(),
265
+ signal: controller.signal,
266
+ });
267
+ // Real-time wait for the child to actually print (mock-safe: no timer calls) —
268
+ // otherwise the abort below can race child startup and prove nothing.
269
+ for (let i = 0; i < 20000; i++) {
270
+ try {
271
+ if (fs.readFileSync(logPath, "utf8").includes("started"))
272
+ break;
273
+ }
274
+ catch {
275
+ // The log file is created asynchronously by the write stream — keep polling.
276
+ }
277
+ if (i === 19999)
278
+ throw new Error("child never reported that it had started");
279
+ await new Promise((r) => setImmediate(r));
280
+ }
281
+ for (let i = 0; i < 40; i++) {
282
+ t.mock.timers.tick(250);
283
+ await new Promise((r) => setImmediate(r));
284
+ }
285
+ controller.abort();
286
+ const result = await promise;
287
+ assert.equal(result.timedOut, false, "the abort ends it, not any timeout");
288
+ assert.equal(result.idleTimedOut, false, "forty idle windows passed with no idle kill");
289
+ assert.match(result.transcript, /started/);
290
+ }
291
+ finally {
292
+ t.mock.timers.reset();
293
+ }
294
+ });
295
+ // NOT-307: frequent stdout (faster than idleTimeoutMs, real timers) is progress — the
296
+ // child is never killed and the timing fields are populated.
297
+ test("spawnCli does not idle-kill a child that emits output more often than idleTimeoutMs", async () => {
298
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
299
+ const { cmd, args } = nodeChild("let i = 0; const t = setInterval(() => { console.log('tick-' + i++); if (i >= 8) clearInterval(t); }, 50);");
300
+ const result = await spawnCli("test-run-idle-chatty", cmd, args, process.cwd(), {
301
+ logPath,
302
+ timeoutMs: 30_000,
303
+ idleTimeoutMs: 500,
304
+ });
305
+ assert.equal(result.exitCode, 0);
306
+ assert.equal(result.timedOut, false);
307
+ assert.equal(result.idleTimedOut, false);
308
+ assert.equal(result.idleForMs, null);
309
+ assert.match(result.transcript, /tick-7/);
310
+ assert.ok(typeof result.firstOutputMs === "number" && result.firstOutputMs >= 0, `firstOutputMs is measured, got ${result.firstOutputMs}`);
311
+ assert.ok(result.lastActivityAt !== null, "lastActivityAt is recorded");
312
+ });
313
+ // NOT-307: the line hook reports each stdout line with a non-decreasing arrival time —
314
+ // the arrival stamps muse-spawn joins back to stream envelopes for per-event `ts`.
315
+ test("spawnCli onStdoutLine reports every line with arrival times", async () => {
316
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
317
+ const { cmd, args } = nodeChild("console.log('line-one'); setTimeout(() => console.log('line-two'), 120);");
318
+ const seen = [];
319
+ const startedAt = Date.now();
320
+ const result = await spawnCli("test-run-lines", cmd, args, process.cwd(), {
321
+ logPath,
322
+ timeoutMs: 10_000,
323
+ onStdoutLine: (line, atMs) => seen.push({ line, atMs }),
324
+ });
325
+ assert.equal(result.exitCode, 0);
326
+ assert.deepEqual(seen.map((s) => s.line), ["line-one", "line-two"]);
327
+ assert.ok(seen[0].atMs >= startedAt && seen[0].atMs <= Date.now());
328
+ assert.ok(seen[1].atMs >= seen[0].atMs, "arrival times never run backwards");
329
+ assert.ok(seen[1].atMs - seen[0].atMs >= 50, `the 120ms gap is visible, got ${seen[1].atMs - seen[0].atMs}ms`);
330
+ });
331
+ // NOT-307: with idleTimeoutMs unset the watchdog is disabled — a silent child runs to
332
+ // the wall clock exactly as before, and idleTimedOut reads false.
333
+ test("spawnCli without idleTimeoutMs times out on the wall clock with idleTimedOut false", async () => {
334
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
335
+ const { cmd, args } = nodeChild("setInterval(() => {}, 1000);");
336
+ const result = await spawnCli("test-run-no-idle", cmd, args, process.cwd(), {
337
+ logPath,
338
+ timeoutMs: 400,
339
+ });
340
+ assert.equal(result.timedOut, true);
341
+ assert.equal(result.idleTimedOut, false);
342
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-dealer/shared": "1.2.7",
25
+ "@agent-dealer/shared": "1.2.9",
26
26
  "@fastify/cors": "^11.0.1",
27
27
  "@fastify/static": "^8.2.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",