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.
- package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
- package/bundle/server/dist/adapters/agent-health.js +14 -3
- package/bundle/server/dist/adapters/github.js +84 -5
- package/bundle/server/dist/adapters/github.test.js +83 -1
- package/bundle/server/dist/adapters/muse-capability.js +443 -24
- package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
- package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
- package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
- package/bundle/server/dist/adapters/worktree-deps.js +96 -0
- package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
- package/bundle/server/dist/capacity/muse-probe.js +56 -9
- package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
- package/bundle/server/dist/coordinator/admission.js +14 -1
- package/bundle/server/dist/coordinator/admission.test.js +197 -4
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
- package/bundle/server/dist/coordinator/auto-merge.js +244 -4
- package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
- package/bundle/server/dist/coordinator/commands.js +284 -23
- package/bundle/server/dist/coordinator/commands.test.js +47 -0
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
- package/bundle/server/dist/coordinator/developer-effect.js +224 -4
- package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
- package/bundle/server/dist/coordinator/execution-report.js +6 -0
- package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
- package/bundle/server/dist/coordinator/failure-cause.js +64 -1
- package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
- package/bundle/server/dist/coordinator/failure-reason.js +7 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
- package/bundle/server/dist/coordinator/human-resolution.js +36 -1
- package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
- package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
- package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
- package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
- package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +693 -0
- package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
- package/bundle/server/dist/coordinator/projection.js +10 -4
- package/bundle/server/dist/coordinator/projection.test.js +13 -0
- package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
- package/bundle/server/dist/coordinator/prompts.js +211 -2
- package/bundle/server/dist/coordinator/prompts.test.js +231 -0
- package/bundle/server/dist/coordinator/recovery.js +57 -3
- package/bundle/server/dist/coordinator/recovery.test.js +67 -8
- package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
- package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
- package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
- package/bundle/server/dist/coordinator/routing.js +45 -6
- package/bundle/server/dist/coordinator/routing.test.js +76 -15
- package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
- package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
- package/bundle/server/dist/coordinator/worker-loop.js +18 -4
- package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
- package/bundle/server/dist/db/index.js +18 -0
- package/bundle/server/dist/db/schema.sql +5 -0
- package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
- package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
- package/bundle/server/dist/repository/human-actions.js +14 -0
- package/bundle/server/dist/repository/issues.js +67 -6
- package/bundle/server/dist/repository/work-items.js +7 -0
- package/bundle/server/dist/repository/worker-sessions.js +51 -2
- package/bundle/server/dist/routes/human-actions.js +10 -8
- package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
- package/bundle/server/dist/routes/issues.js +19 -2
- package/bundle/server/dist/runners/muse-code-args.js +25 -0
- package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
- package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
- package/bundle/server/dist/runners/muse-config-core.js +18 -1
- package/bundle/server/dist/runners/muse-config.test.js +37 -0
- package/bundle/server/dist/runners/muse-serve-session.js +5 -0
- package/bundle/server/dist/runners/spawn-cli.js +116 -0
- package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
- package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/execution-analysis.d.ts +22 -22
- package/bundle/shared/dist/execution-contract.d.ts +117 -0
- package/bundle/shared/dist/execution-contract.js +307 -0
- package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
- package/bundle/shared/dist/execution-contract.test.js +499 -0
- package/bundle/shared/dist/execution-report.d.ts +9 -9
- package/bundle/shared/dist/execution-report.js +2 -0
- package/bundle/shared/dist/failure-cause.d.ts +4 -4
- package/bundle/shared/dist/failure-cause.js +2 -0
- package/bundle/shared/dist/human-actions.d.ts +4 -4
- package/bundle/shared/dist/human-actions.js +17 -0
- package/bundle/shared/dist/index.d.ts +85 -84
- package/bundle/shared/dist/index.js +3 -0
- package/bundle/shared/dist/issues.d.ts +176 -16
- package/bundle/shared/dist/issues.js +22 -0
- package/bundle/shared/dist/issues.test.js +3 -0
- package/bundle/shared/dist/outbound-draft.d.ts +12 -12
- package/bundle/shared/dist/worker-sessions.d.ts +10 -0
- package/bundle/shared/dist/worker-sessions.js +8 -0
- package/bundle/shared/dist/worker-sessions.test.js +1 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +16 -0
- package/bundle/shared/dist/workflow.test.js +3 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.js +4 -1
- package/dist/action.test.js +42 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- 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 {
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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 ?? {
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
|
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.
|
|
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.
|
|
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",
|