agent-dealer 1.2.6 → 1.2.8

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 (96) 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 +4 -1
  5. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  6. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  7. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  8. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  9. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  10. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  11. package/bundle/server/dist/coordinator/admission.js +14 -1
  12. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  13. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  14. package/bundle/server/dist/coordinator/auto-merge.js +39 -3
  15. package/bundle/server/dist/coordinator/commands.js +90 -5
  16. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  17. package/bundle/server/dist/coordinator/developer-effect.js +79 -1
  18. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  19. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  20. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  21. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  22. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  23. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  24. package/bundle/server/dist/coordinator/human-resolution.js +17 -1
  25. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  26. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  27. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  28. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  29. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  30. package/bundle/server/dist/coordinator/playbook-feedback.js +690 -0
  31. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  32. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  33. package/bundle/server/dist/coordinator/prompts.js +78 -2
  34. package/bundle/server/dist/coordinator/prompts.test.js +83 -0
  35. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  36. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  37. package/bundle/server/dist/coordinator/reviewer-effect.js +10 -1
  38. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  39. package/bundle/server/dist/coordinator/routing.test.js +14 -0
  40. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  41. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  42. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  43. package/bundle/server/dist/db/index.js +5 -0
  44. package/bundle/server/dist/db/schema.sql +3 -0
  45. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  46. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  47. package/bundle/server/dist/repository/human-actions.js +14 -0
  48. package/bundle/server/dist/repository/issues.js +29 -4
  49. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  50. package/bundle/server/dist/routes/human-actions.js +10 -8
  51. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  52. package/bundle/server/dist/routes/issues.js +19 -2
  53. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  54. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  55. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  56. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  57. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  58. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  59. package/bundle/server/package.json +2 -2
  60. package/bundle/server/static-ui/assets/index-B6SVCzMR.js +64 -0
  61. package/bundle/server/static-ui/assets/index-K_YcYkQU.css +1 -0
  62. package/bundle/server/static-ui/index.html +2 -2
  63. package/bundle/shared/dist/action-presentation.d.ts +51 -0
  64. package/bundle/shared/dist/action-presentation.js +280 -0
  65. package/bundle/shared/dist/action-presentation.test.d.ts +1 -0
  66. package/bundle/shared/dist/action-presentation.test.js +180 -0
  67. package/bundle/shared/dist/attempt-waste.d.ts +2 -2
  68. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  69. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  70. package/bundle/shared/dist/execution-contract.js +307 -0
  71. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  72. package/bundle/shared/dist/execution-contract.test.js +499 -0
  73. package/bundle/shared/dist/execution-report.d.ts +11 -11
  74. package/bundle/shared/dist/execution-report.js +2 -0
  75. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  76. package/bundle/shared/dist/failure-cause.js +2 -0
  77. package/bundle/shared/dist/human-actions.d.ts +14 -14
  78. package/bundle/shared/dist/human-actions.js +9 -0
  79. package/bundle/shared/dist/index.d.ts +126 -124
  80. package/bundle/shared/dist/index.js +4 -0
  81. package/bundle/shared/dist/issues.d.ts +158 -28
  82. package/bundle/shared/dist/issues.js +8 -0
  83. package/bundle/shared/dist/issues.test.js +1 -0
  84. package/bundle/shared/dist/outbound-draft.d.ts +18 -18
  85. package/bundle/shared/dist/profile-snapshot.d.ts +2 -2
  86. package/bundle/shared/dist/usage-events.d.ts +2 -2
  87. package/bundle/shared/dist/worker-sessions.d.ts +16 -6
  88. package/bundle/shared/dist/worker-sessions.js +8 -0
  89. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  90. package/bundle/shared/dist/workflow.d.ts +6 -6
  91. package/bundle/shared/dist/workflow.js +10 -0
  92. package/bundle/shared/dist/workflow.test.js +2 -0
  93. package/bundle/shared/package.json +1 -1
  94. package/package.json +1 -1
  95. package/bundle/server/static-ui/assets/index-BII-LgB8.css +0 -1
  96. package/bundle/server/static-ui/assets/index-CoDzZcsz.js +0 -60
@@ -0,0 +1,121 @@
1
+ // NOT-310: conflict-failure classifier + the conflict-repair episode bound.
2
+ import { test, before, beforeEach } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not310-"));
8
+ process.env.AGENT_DEALER_SKIP_GITHUB_HEALTH = "1";
9
+ process.env.AGENT_DEALER_SKIP_AGENT_HEALTH = "1";
10
+ const { migrate, getDb } = await import("../db/index.js");
11
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
12
+ const { createIssue } = await import("../repository/issues.js");
13
+ const { startWorkflow } = await import("./commands.js");
14
+ const { appendWorkflowEvent, getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
15
+ const { conflictRepairSpent, isMergeConflictFailure } = await import("./merge-conflict-sync.js");
16
+ before(() => {
17
+ migrate();
18
+ });
19
+ beforeEach(() => {
20
+ getDb().exec(`
21
+ DELETE FROM work_items;
22
+ DELETE FROM human_actions;
23
+ DELETE FROM workflow_events;
24
+ DELETE FROM findings;
25
+ DELETE FROM review_publications;
26
+ DELETE FROM worker_sessions;
27
+ DELETE FROM artifacts;
28
+ DELETE FROM usage_events;
29
+ DELETE FROM workflow_instances;
30
+ DELETE FROM issues;
31
+ `);
32
+ });
33
+ test("isMergeConflictFailure classifies gh conflict text, not infra failures", () => {
34
+ assert.equal(isMergeConflictFailure("Pull request #42 is not mergeable: the merge commit cannot be cleanly created"), true);
35
+ assert.equal(isMergeConflictFailure("merge conflict: CONFLICTING"), true);
36
+ assert.equal(isMergeConflictFailure("CONFLICTING"), true);
37
+ // Deliberately broad: a policy "not mergeable" still enters the sync, which
38
+ // then escalates directly once the branch proves up to date (integration:
39
+ // "up-to-date branch that stays not mergeable").
40
+ assert.equal(isMergeConflictFailure("Pull request #42 is not mergeable: base branch policy prohibits the merge"), true);
41
+ assert.equal(isMergeConflictFailure("required status checks failed"), false);
42
+ assert.equal(isMergeConflictFailure("gh timed out after 20000ms"), false);
43
+ assert.equal(isMergeConflictFailure("protected branch"), false);
44
+ assert.equal(isMergeConflictFailure("Managed clone missing for github.com/a/b (/x)"), false);
45
+ assert.equal(isMergeConflictFailure("gh not on PATH — install GitHub CLI (`gh`) and ensure the daemon can see it"), false);
46
+ assert.equal(isMergeConflictFailure("invalid merge cwd (/x) — path does not exist (portable issue.repo must not be used as cwd)"), false);
47
+ });
48
+ function newIssueWithWorkflow() {
49
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not310bound-repo-"));
50
+ const issueId = createIssue({
51
+ title: "Bound me",
52
+ description: "d",
53
+ acceptanceCriteria: "It works",
54
+ repo,
55
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
56
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
57
+ baseBranch: "main",
58
+ maxReviewRounds: 3,
59
+ maxInfraAttempts: 3,
60
+ source: "manual",
61
+ autoMerge: true,
62
+ }).id;
63
+ startWorkflow(issueId);
64
+ return { issueId, instanceId: getActiveWorkflowInstance(issueId).id };
65
+ }
66
+ function emitRepairQueued(issueId, instanceId, files) {
67
+ appendWorkflowEvent({
68
+ issueId,
69
+ workflowInstanceId: instanceId,
70
+ workerSessionId: null,
71
+ type: "auto_merge.conflict_repair_queued",
72
+ actorType: "system",
73
+ stage: "repairing",
74
+ round: 2,
75
+ payload: { baseBranch: "main", branch: "issue-1", files, round: 2, workItemId: "w" },
76
+ });
77
+ }
78
+ function emitResolved(issueId, instanceId) {
79
+ appendWorkflowEvent({
80
+ issueId,
81
+ workflowInstanceId: instanceId,
82
+ workerSessionId: null,
83
+ type: "human_action.resolved",
84
+ actorType: "human",
85
+ stage: "needs_human",
86
+ round: 2,
87
+ payload: { actionType: "policy_escalation", choice: "retry_merge" },
88
+ });
89
+ }
90
+ test("conflictRepairSpent is false with no repair event", () => {
91
+ const { issueId, instanceId } = newIssueWithWorkflow();
92
+ assert.deepEqual(conflictRepairSpent(issueId, instanceId), { spent: false, files: [] });
93
+ });
94
+ test("conflictRepairSpent is true after a repair round queued, carrying its files", () => {
95
+ const { issueId, instanceId } = newIssueWithWorkflow();
96
+ emitRepairQueued(issueId, instanceId, ["shared.txt"]);
97
+ assert.deepEqual(conflictRepairSpent(issueId, instanceId), { spent: true, files: ["shared.txt"] });
98
+ });
99
+ test("conflictRepairSpent resets on a later human resolution (fresh episode)", () => {
100
+ const { issueId, instanceId } = newIssueWithWorkflow();
101
+ emitRepairQueued(issueId, instanceId, ["shared.txt"]);
102
+ emitResolved(issueId, instanceId);
103
+ assert.deepEqual(conflictRepairSpent(issueId, instanceId), { spent: false, files: [] });
104
+ });
105
+ test("conflictRepairSpent stays spent when the repair queued after the last resolution", () => {
106
+ const { issueId, instanceId } = newIssueWithWorkflow();
107
+ emitResolved(issueId, instanceId);
108
+ emitRepairQueued(issueId, instanceId, []);
109
+ assert.deepEqual(conflictRepairSpent(issueId, instanceId), { spent: true, files: [] });
110
+ });
111
+ test("conflictRepairSpent ignores other issues/instances and malformed file lists", () => {
112
+ const { issueId, instanceId } = newIssueWithWorkflow();
113
+ const other = newIssueWithWorkflow();
114
+ emitRepairQueued(other.issueId, other.instanceId, ["other.txt"]);
115
+ assert.deepEqual(conflictRepairSpent(issueId, instanceId), { spent: false, files: [] });
116
+ assert.deepEqual(conflictRepairSpent(issueId, other.instanceId), { spent: false, files: [] });
117
+ emitRepairQueued(issueId, instanceId, "not-a-list");
118
+ const spent = conflictRepairSpent(issueId, instanceId);
119
+ assert.equal(spent.spent, true);
120
+ assert.deepEqual(spent.files, []);
121
+ });
@@ -203,7 +203,7 @@ function recorded() {
203
203
  return JSON.parse(fs.readFileSync(RECORD, "utf8"));
204
204
  }
205
205
  /** The captured settings file: exactly one required agent-deck server, accepted by the guard. */
206
- function expectDeckSettings(rec, deckId = TEST_DECK_ID) {
206
+ function expectDeckSettings(rec, issueId, deckId = TEST_DECK_ID) {
207
207
  const settings = JSON.parse(rec.settings);
208
208
  assert.deepEqual(Object.keys(settings).sort(), ["mcpServers", "run", "runtime_capabilities", "schema_version"]);
209
209
  assert.deepEqual(Object.keys(settings.mcpServers), ["agent-deck"]);
@@ -211,15 +211,20 @@ function expectDeckSettings(rec, deckId = TEST_DECK_ID) {
211
211
  assert.equal(server.type, "streamable-http");
212
212
  assert.equal(server.url, expectedMcpUrl());
213
213
  assert.equal(server.mode, "required");
214
+ // NOT-305: every worker session carries an opaque Deck correlation UUID, passed here
215
+ // as the exact x-agent-deck-correlation-id observability header.
216
+ const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
217
+ assert.ok(dev.deckCorrelationId, "the developer session persists a Deck correlation ID");
214
218
  // cwdReal was resolved by the fixture at spawn time: the coordinator removes the
215
219
  // worktree after the turn, so resolving rec.cwd here would race cleanup.
216
220
  assert.deepEqual(server.headers, {
217
221
  "x-agent-deck-deck-id": deckId,
218
222
  "x-agent-deck-workspace": rec.cwdReal,
223
+ "x-agent-deck-correlation-id": dev.deckCorrelationId,
219
224
  });
220
225
  assert.equal("enabled_tools" in server, false, "developers are unfiltered: no allowlist claim");
221
226
  assert.equal("disabled_tools" in server, false, "developers are unfiltered: no denylist claim");
222
- assertMuseSettings(settings, { url: expectedMcpUrl(), deckId, workspace: rec.cwdReal }, "developer");
227
+ assertMuseSettings(settings, { url: expectedMcpUrl(), deckId, workspace: rec.cwdReal, correlationId: dev.deckCorrelationId }, "developer");
223
228
  return settings;
224
229
  }
225
230
  test("fake-Muse developer session goes admission → verified branch → draft PR, with a frozen muse_code snapshot", async () => {
@@ -272,7 +277,7 @@ test("the turn runs the isolated exec lane (never serve) with the required agent
272
277
  assert.ok(!rec.xdgConfigHome.startsWith(rec.cwd), "config is not under the worktree");
273
278
  assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config removed after success");
274
279
  assert.equal(fs.existsSync(rec.xdgDataHome), false, "per-attempt data removed after success");
275
- expectDeckSettings(rec);
280
+ expectDeckSettings(rec, issueId);
276
281
  // Exact environment: ambient secrets and sentinels never reached the child.
277
282
  assert.deepEqual(rec.envLeak, {
278
283
  META_API_KEY: false,
@@ -308,7 +313,7 @@ test("saved-login auth is symlinked, never copied, and carries no stdin key", as
308
313
  assert.equal(rec.stdinBytes, 0, "no key on stdin for saved-login auth");
309
314
  assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config (and link) removed");
310
315
  assert.equal(fs.readFileSync(operatorAuth, "utf8"), '{"token":"operator-owned"}', "operator credentials untouched");
311
- expectDeckSettings(rec);
316
+ expectDeckSettings(rec, issueId);
312
317
  });
313
318
  test("usage events record muse_code, the confirmed model, duration and tokens; cost stays null", async () => {
314
319
  const issueId = makeMuseIssue();
@@ -24,6 +24,7 @@ import { getWorkerMcpConfigDir } from "../paths.js";
24
24
  import { prepareMuseAttempt, } from "../runners/muse-config.js";
25
25
  import { parseMuseRun, } from "../runners/muse-code-jsonl.js";
26
26
  import { spawnCli } from "../runners/spawn-cli.js";
27
+ import { museIdleTimeoutMs } from "./session-timeouts.js";
27
28
  /**
28
29
  * Step cap when the profile sets no `maxTurns`. High on purpose (the PoC's value): the developer
29
30
  * wall-clock timeout is the real limit, the cap only stops a runaway loop.
@@ -32,38 +33,94 @@ export const DEFAULT_MUSE_MAX_MODEL_STEPS = 300;
32
33
  /** Muse cannot disable these; any call in a session's tool activity fails it as `muse_cron_used`. */
33
34
  const CRON_TOOL_RE = /^cron_(create|list|delete)$/;
34
35
  const STDERR_MARKER = "\n--- stderr ---\n";
36
+ export function museStallMetadata(summary) {
37
+ return {
38
+ lastActivityAt: summary.lastActivityAt,
39
+ toolCallCount: summary.toolCallCount,
40
+ lastToolName: summary.lastToolName,
41
+ firstOutputMs: summary.firstOutputMs,
42
+ idleTimedOut: summary.idleTimedOut,
43
+ };
44
+ }
35
45
  /** `sessions/YYYY/MM/DD/<session-id>/session.jsonl` under the attempt's data dir, if present. */
36
46
  function readSessionLog(dataDir, museSessionId) {
37
- const root = path.join(dataDir, "muse", "sessions");
38
- const visit = (dir, depth) => {
39
- let entries;
40
- try {
41
- entries = fs.readdirSync(dir, { withFileTypes: true });
42
- }
43
- catch {
44
- return undefined;
45
- }
46
- for (const e of entries) {
47
- if (!e.isDirectory())
48
- continue;
49
- const child = path.join(dir, e.name);
50
- if (e.name === museSessionId) {
51
- try {
52
- return fs.readFileSync(path.join(child, "session.jsonl"), "utf8");
53
- }
54
- catch {
55
- return undefined;
56
- }
47
+ const found = findSessionLogPath(path.join(dataDir, "muse", "sessions"), museSessionId, 0);
48
+ if (!found)
49
+ return undefined;
50
+ try {
51
+ return fs.readFileSync(found, "utf8");
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ /**
58
+ * NOT-307: mtime (epoch ms) of the live `session.jsonl` for one attempt's session id,
59
+ * or null when it cannot be found or statted. The idle watchdog polls this as its
60
+ * second progress source: Muse appends model-stream rows to the session log as work
61
+ * proceeds, so growth here counts as progress even across a long stdout silence
62
+ * (e.g. a big tool call whose output arrives only at the end). The found path is
63
+ * resolved on every call — the file appears only after the child starts — and any
64
+ * filesystem error reads as "unknown", never as progress and never as a throw.
65
+ */
66
+ export function sessionLogActivityMs(dataDir, museSessionId) {
67
+ try {
68
+ const found = findSessionLogPath(path.join(dataDir, "muse", "sessions"), museSessionId, 0);
69
+ if (!found)
70
+ return null;
71
+ return fs.statSync(found).mtimeMs;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function findSessionLogPath(dir, museSessionId, depth) {
78
+ let entries;
79
+ try {
80
+ entries = fs.readdirSync(dir, { withFileTypes: true });
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ for (const e of entries) {
86
+ if (!e.isDirectory())
87
+ continue;
88
+ const child = path.join(dir, e.name);
89
+ if (e.name === museSessionId) {
90
+ const candidate = path.join(child, "session.jsonl");
91
+ try {
92
+ if (fs.statSync(candidate).isFile())
93
+ return candidate;
57
94
  }
58
- if (depth < 4) {
59
- const found = visit(child, depth + 1);
60
- if (found !== undefined)
61
- return found;
95
+ catch {
96
+ return null;
62
97
  }
98
+ return null;
63
99
  }
64
- return undefined;
65
- };
66
- return visit(root, 0);
100
+ if (depth < 4) {
101
+ const found = findSessionLogPath(child, museSessionId, depth + 1);
102
+ if (found)
103
+ return found;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ /**
109
+ * NOT-307: whole minutes of silence ending now, for the idle-kill reason text.
110
+ * Prefers the measured last-activity time; falls back to the configured bound when
111
+ * no activity was ever observed (a child silent since spawn is killed after exactly
112
+ * the bound). Null when the watchdog is disabled — there is no idle kill to explain.
113
+ */
114
+ export function museIdleMinutes(opts) {
115
+ const now = opts.nowMs ?? Date.now();
116
+ if (opts.lastActivityAt) {
117
+ const at = Date.parse(opts.lastActivityAt);
118
+ if (Number.isFinite(at))
119
+ return Math.max(1, Math.round((now - at) / 60_000));
120
+ }
121
+ if (opts.idleTimeoutMs !== undefined)
122
+ return Math.max(1, Math.round(opts.idleTimeoutMs / 60_000));
123
+ return null;
67
124
  }
68
125
  /**
69
126
  * NOT-278: the attempt's credential. A saved login is linked, never read or copied; otherwise
@@ -90,6 +147,15 @@ function splitSpawnLog(raw) {
90
147
  ? { stdout: raw, stderr: "" }
91
148
  : { stdout: raw.slice(0, idx), stderr: raw.slice(idx + STDERR_MARKER.length) };
92
149
  }
150
+ /**
151
+ * NOT-307: writes the normalized session log every reader knows — one JSON event
152
+ * per line (each carrying its ISO `ts`; `tool_call` events their `durationMs`
153
+ * when a matching result was timed), plus the stderr trailer when non-blank.
154
+ * Extracted pure so tests assert the exact bytes on disk.
155
+ */
156
+ export function writeMuseNormalizedLog(logPath, events, stderr) {
157
+ fs.writeFileSync(logPath, `${events.map((e) => JSON.stringify(e)).join("\n")}\n${stderr.trim() ? `${STDERR_MARKER}${stderr}` : ""}`);
158
+ }
93
159
  /**
94
160
  * NOT-270: reuse real Dealer Muse work as the observation opportunity. After
95
161
  * a genuine Muse session ends, kick one bounded, best-effort refresh
@@ -148,7 +214,8 @@ export async function runMuseDeveloperSession(input) {
148
214
  role: "developer",
149
215
  worktreePath: input.cwd,
150
216
  baseDir: getWorkerMcpConfigDir(),
151
- agentDeck: { url: mcpUrl, deckId, workspace: input.cwd },
217
+ // NOT-305: opaque per-session correlation UUID — observability metadata, not authority.
218
+ agentDeck: { url: mcpUrl, deckId, workspace: input.cwd, correlationId: input.deckCorrelationId ?? null },
152
219
  credential: resolveMuseCredential(),
153
220
  sessionId: input.sessionId,
154
221
  prompt: input.prompt,
@@ -157,6 +224,13 @@ export async function runMuseDeveloperSession(input) {
157
224
  const { logPath } = input;
158
225
  let spawned;
159
226
  let sessionLog;
227
+ // NOT-307: Dealer-observed arrival epoch ms parallel to the transcript's lines —
228
+ // the stream's own `recorded_at` is batch-stamped, so per-event `ts` is stamped
229
+ // here instead. Only the Muse lane collects these; other runtimes pass nothing.
230
+ const lineTs = [];
231
+ // NOT-307: silence bound for this session. Undefined disables the watchdog
232
+ // (`MUSE_IDLE_TIMEOUT_MS=0`); only the Muse lane opts in.
233
+ const idleTimeoutMs = museIdleTimeoutMs();
160
234
  try {
161
235
  // The exact launch about to happen — a tampered settings file or a differing
162
236
  // cwd/argv/env/stdin fails here, before any process exists.
@@ -169,6 +243,13 @@ export async function runMuseDeveloperSession(input) {
169
243
  stdin: attempt.stdin,
170
244
  signal: input.signal,
171
245
  onSpawn: input.onSpawn,
246
+ idleTimeoutMs,
247
+ // The session log appears only after the child starts; a missing file reads as
248
+ // unknown and never postpones the kill on its own — stdout bytes still count.
249
+ progressSource: () => sessionLogActivityMs(attempt.env.XDG_DATA_HOME, input.sessionId),
250
+ onStdoutLine: (_line, atMs) => {
251
+ lineTs.push(atMs);
252
+ },
172
253
  });
173
254
  sessionLog = readSessionLog(attempt.env.XDG_DATA_HOME, input.sessionId);
174
255
  }
@@ -183,6 +264,10 @@ export async function runMuseDeveloperSession(input) {
183
264
  exitCode: spawned.timedOut ? null : spawned.exitCode,
184
265
  sessionLog,
185
266
  expectedModel: model,
267
+ lineTs,
268
+ // Log-write time: every normalized event below carries a `ts`, stream-derived
269
+ // where the arrival was observed, this fallback everywhere else.
270
+ now: new Date().toISOString(),
186
271
  });
187
272
  const cronCalls = run.tools.map((t) => t.name).filter((n) => n !== null && CRON_TOOL_RE.test(n));
188
273
  const summary = {
@@ -193,6 +278,11 @@ export async function runMuseDeveloperSession(input) {
193
278
  cronCalls,
194
279
  runCount: run.runCount,
195
280
  rawLogPath: null,
281
+ firstOutputMs: spawned.firstOutputMs,
282
+ lastActivityAt: spawned.lastActivityAt,
283
+ idleTimedOut: spawned.idleTimedOut,
284
+ toolCallCount: run.tools.length,
285
+ lastToolName: run.tools.length > 0 ? (run.tools[run.tools.length - 1].name ?? null) : null,
196
286
  };
197
287
  // The raw envelopes stay as evidence; the log path every reader knows becomes the normalized stream.
198
288
  const rawLogPath = `${logPath.replace(/\.ndjson$/, "")}.muse-raw.jsonl`;
@@ -204,7 +294,7 @@ export async function runMuseDeveloperSession(input) {
204
294
  // evidence copy is best-effort; the normalized log below still carries the outcome
205
295
  }
206
296
  const events = run.events.map((e) => e.type === "result" ? { ...e, muse: { ...summary, usage: run.usage } } : e);
207
- fs.writeFileSync(logPath, `${events.map((e) => JSON.stringify(e)).join("\n")}\n${stderr.trim() ? `${STDERR_MARKER}${stderr}` : ""}`);
297
+ writeMuseNormalizedLog(logPath, events, stderr);
208
298
  // The real session above is the observation opportunity: kick the
209
299
  // best-effort capacity refresh (no synthetic turn, never throws, never
210
300
  // blocks this result).
@@ -215,6 +305,9 @@ export async function runMuseDeveloperSession(input) {
215
305
  transcript: run.finalText ?? "",
216
306
  logPath,
217
307
  timedOut: spawned.timedOut,
308
+ // NOT-307: an idle kill reads as a timeout downstream (same salvage, same
309
+ // infra-retry budget) with the idle flag distinguishing the cause.
310
+ idleTimedOut: spawned.idleTimedOut,
218
311
  muse: summary,
219
312
  };
220
313
  }
@@ -0,0 +1,210 @@
1
+ // NOT-307: Muse idle-wiring units — env parsing, session-log progress source,
2
+ // arrival-stamped normalized events, and the five-field stall metadata object.
3
+ // The full `runMuseDeveloperSession` needs a Muse binary + credentials, so these
4
+ // test the pure pieces it wires together (plus the parser contract it relies on).
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { parseMuseRun } from "../runners/muse-code-jsonl.js";
11
+ import { museIdleTimeoutMs, DEFAULT_MUSE_IDLE_TIMEOUT_MS } from "./session-timeouts.js";
12
+ import { museIdleMinutes, museStallMetadata, sessionLogActivityMs, writeMuseNormalizedLog, } from "./muse-spawn.js";
13
+ const MODEL = "muse-spark-1.3-contributor";
14
+ // ── MUSE_IDLE_TIMEOUT_MS parsing ─────────────────────────────────────────────
15
+ function withIdleEnv(raw, fn) {
16
+ const prev = process.env.MUSE_IDLE_TIMEOUT_MS;
17
+ try {
18
+ if (raw === undefined)
19
+ delete process.env.MUSE_IDLE_TIMEOUT_MS;
20
+ else
21
+ process.env.MUSE_IDLE_TIMEOUT_MS = raw;
22
+ fn();
23
+ }
24
+ finally {
25
+ if (prev === undefined)
26
+ delete process.env.MUSE_IDLE_TIMEOUT_MS;
27
+ else
28
+ process.env.MUSE_IDLE_TIMEOUT_MS = prev;
29
+ }
30
+ }
31
+ test("museIdleTimeoutMs defaults to 30 minutes when unset or blank", () => {
32
+ withIdleEnv(undefined, () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
33
+ withIdleEnv("", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
34
+ withIdleEnv(" ", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
35
+ assert.equal(DEFAULT_MUSE_IDLE_TIMEOUT_MS, 30 * 60_000);
36
+ });
37
+ test("museIdleTimeoutMs accepts an explicit bound", () => {
38
+ withIdleEnv("600000", () => assert.equal(museIdleTimeoutMs(), 600_000));
39
+ });
40
+ test("MUSE_IDLE_TIMEOUT_MS=0 disables the watchdog", () => {
41
+ withIdleEnv("0", () => assert.equal(museIdleTimeoutMs(), undefined));
42
+ });
43
+ test("non-numeric or negative MUSE_IDLE_TIMEOUT_MS falls back to the default", () => {
44
+ withIdleEnv("soon", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
45
+ withIdleEnv("NaN", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
46
+ withIdleEnv("-1", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
47
+ withIdleEnv("Infinity", () => assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS));
48
+ });
49
+ test("invalid MUSE_IDLE_TIMEOUT_MS logs a warning naming the value", () => {
50
+ const prev = process.env.MUSE_IDLE_TIMEOUT_MS;
51
+ const warnings = [];
52
+ const orig = console.warn;
53
+ console.warn = (...args) => {
54
+ warnings.push(args);
55
+ };
56
+ try {
57
+ process.env.MUSE_IDLE_TIMEOUT_MS = "soon";
58
+ assert.equal(museIdleTimeoutMs(), DEFAULT_MUSE_IDLE_TIMEOUT_MS);
59
+ }
60
+ finally {
61
+ console.warn = orig;
62
+ if (prev === undefined)
63
+ delete process.env.MUSE_IDLE_TIMEOUT_MS;
64
+ else
65
+ process.env.MUSE_IDLE_TIMEOUT_MS = prev;
66
+ }
67
+ assert.equal(warnings.length, 1, `expected one warning, got ${warnings.length}`);
68
+ assert.match(String(warnings[0][0]), /MUSE_IDLE_TIMEOUT_MS/);
69
+ });
70
+ // ── session-log progress source ──────────────────────────────────────────────
71
+ function writeSessionLog(dataDir, sessionId) {
72
+ const dir = path.join(dataDir, "muse", "sessions", "2026", "10", "01", sessionId);
73
+ fs.mkdirSync(dir, { recursive: true });
74
+ const file = path.join(dir, "session.jsonl");
75
+ fs.writeFileSync(file, "{}\n");
76
+ return file;
77
+ }
78
+ test("sessionLogActivityMs returns the session.jsonl mtime once it exists", () => {
79
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-progress-"));
80
+ const sid = "11111111-1111-4111-8111-111111111111";
81
+ assert.equal(sessionLogActivityMs(dataDir, sid), null, "absent before the child starts");
82
+ const file = writeSessionLog(dataDir, sid);
83
+ const got = sessionLogActivityMs(dataDir, sid);
84
+ assert.ok(typeof got === "number", `expected mtime, got ${got}`);
85
+ assert.ok(Math.abs(got - fs.statSync(file).mtimeMs) < 1, "reads the live mtime");
86
+ });
87
+ test("sessionLogActivityMs is null for a missing tree and never throws", () => {
88
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-progress-"));
89
+ assert.equal(sessionLogActivityMs(dataDir, "no-such-session"), null);
90
+ assert.equal(sessionLogActivityMs(path.join(dataDir, "missing"), "x"), null);
91
+ });
92
+ // ── idle minutes ─────────────────────────────────────────────────────────────
93
+ test("museIdleMinutes measures silence from last activity, else the bound", () => {
94
+ const last = new Date(Date.now() - 20 * 60_000).toISOString();
95
+ assert.equal(museIdleMinutes({ lastActivityAt: last, idleTimeoutMs: 1_200_000 }), 20);
96
+ assert.equal(museIdleMinutes({ lastActivityAt: null, idleTimeoutMs: 1_200_000 }), 20, "silent since spawn was killed after exactly the bound");
97
+ assert.equal(museIdleMinutes({ lastActivityAt: null, idleTimeoutMs: undefined }), null, "disabled watchdog has no idle kill to explain");
98
+ });
99
+ // ── stall metadata object ────────────────────────────────────────────────────
100
+ test("museStallMetadata carries exactly the five worker_sessions fields", () => {
101
+ assert.deepEqual(museStallMetadata({
102
+ lastActivityAt: "2026-10-01T19:00:00.000Z",
103
+ toolCallCount: 24,
104
+ lastToolName: "npm_test",
105
+ firstOutputMs: 1234,
106
+ idleTimedOut: true,
107
+ }), {
108
+ lastActivityAt: "2026-10-01T19:00:00.000Z",
109
+ toolCallCount: 24,
110
+ lastToolName: "npm_test",
111
+ firstOutputMs: 1234,
112
+ idleTimedOut: true,
113
+ });
114
+ });
115
+ // ── arrival-stamped normalized events ────────────────────────────────────────
116
+ function env(payloadType, payload) {
117
+ return JSON.stringify({ payload_type: payloadType, payload, stream: { kind: "session", id: "s1" } });
118
+ }
119
+ function timedStdout() {
120
+ return [
121
+ env("run.model.configured", { run_stream: { kind: "run", id: "r1" } }),
122
+ env("task.lifecycle.side_effect_intent", {
123
+ run_stream: { kind: "run", id: "r1" },
124
+ event: { operation: "tool:read_file", idempotency_key: "tool:c1", task_id: "t1" },
125
+ }),
126
+ env("tool.result", { call_id: "c1", correlation_facts: { tool_name: "read_file", outcome: "success" } }),
127
+ env("task.lifecycle.side_effect_intent", {
128
+ run_stream: { kind: "run", id: "r1" },
129
+ event: { operation: "tool:npm_test", idempotency_key: "tool:c2", task_id: "t2" },
130
+ }),
131
+ env("run.terminal.completed", { run_stream: { kind: "run", id: "r1" }, text: "done", reason: "" }),
132
+ ].join("\n");
133
+ }
134
+ const baseInput = {
135
+ stdout: timedStdout(),
136
+ stderr: "",
137
+ exitCode: 0,
138
+ expectedModel: MODEL,
139
+ };
140
+ test("arrival times stamp every event; tool_call carries durationMs for the completed call", () => {
141
+ const t0 = Date.UTC(2026, 9, 1, 19, 0, 0);
142
+ // lineTs is parallel to stdout lines: intent c1 at +1s, result at +61s, intent c2 at +62s.
143
+ const lineTs = [t0, t0 + 1_000, t0 + 61_000, t0 + 62_000, t0 + 63_000];
144
+ const r = parseMuseRun({ ...baseInput, lineTs, now: new Date(t0 + 64_000).toISOString() });
145
+ const tools = r.events.filter((e) => e.type === "tool_call");
146
+ assert.equal(tools.length, 2);
147
+ assert.equal(tools[0].name, "read_file");
148
+ assert.equal(tools[0].ts, new Date(t0 + 1_000).toISOString());
149
+ assert.equal(tools[0].durationMs, 60_000, "result arrival minus intent arrival");
150
+ assert.equal(tools[1].name, "npm_test");
151
+ assert.equal(tools[1].ts, new Date(t0 + 62_000).toISOString());
152
+ assert.equal("durationMs" in tools[1], false, "a bare intent (no result) carries no duration");
153
+ for (const e of r.events) {
154
+ assert.ok(typeof e.ts === "string" && Number.isFinite(Date.parse(e.ts)), `every event has ts: ${JSON.stringify(e).slice(0, 120)}`);
155
+ }
156
+ });
157
+ test("without arrival times or now the parser output stays byte-stable (no ts)", () => {
158
+ const r = parseMuseRun({ ...baseInput });
159
+ const tools = r.events.filter((e) => e.type === "tool_call");
160
+ assert.deepEqual(tools.map((t) => ({ type: t.type, name: t.name })), [
161
+ { type: "tool_call", name: "read_file" },
162
+ { type: "tool_call", name: "npm_test" },
163
+ ]);
164
+ for (const e of r.events)
165
+ assert.equal("ts" in e, false);
166
+ });
167
+ test("now alone stamps every event when the stream gave no usable time", () => {
168
+ const now = "2026-10-01T19:05:00.000Z";
169
+ const r = parseMuseRun({ ...baseInput, now });
170
+ assert.ok(r.events.length > 0);
171
+ for (const e of r.events)
172
+ assert.equal(e.ts, now);
173
+ const tools = r.events.filter((e) => e.type === "tool_call");
174
+ for (const t of tools)
175
+ assert.equal("durationMs" in t, false);
176
+ });
177
+ test("the written ndjson carries ts on every event and durationMs on completed tool calls", () => {
178
+ const t0 = Date.UTC(2026, 9, 1, 19, 0, 0);
179
+ const lineTs = [t0, t0 + 1_000, t0 + 61_000, t0 + 62_000, t0 + 63_000];
180
+ const r = parseMuseRun({ ...baseInput, lineTs, now: new Date(t0 + 64_000).toISOString() });
181
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-ndjson-"));
182
+ const logPath = path.join(dir, "session.ndjson");
183
+ // What runMuseDeveloperSession writes: the result event embeds the session summary.
184
+ const summary = {
185
+ firstOutputMs: 900,
186
+ lastActivityAt: new Date(t0 + 63_000).toISOString(),
187
+ idleTimedOut: false,
188
+ toolCallCount: r.tools.length,
189
+ lastToolName: "npm_test",
190
+ };
191
+ const events = r.events.map((e) => e.type === "result" ? { ...e, muse: { ...summary } } : e);
192
+ writeMuseNormalizedLog(logPath, events, "");
193
+ const lines = fs.readFileSync(logPath, "utf8").trim().split("\n");
194
+ assert.equal(lines.length, events.length);
195
+ const read = lines.map((l) => JSON.parse(l));
196
+ for (const e of read) {
197
+ assert.ok(typeof e.ts === "string" && Number.isFinite(Date.parse(e.ts)), "ts on every written event");
198
+ }
199
+ const tools = read.filter((e) => e.type === "tool_call");
200
+ assert.equal(tools.length, 2);
201
+ assert.equal(tools[0].durationMs, 60_000);
202
+ assert.equal("durationMs" in tools[1], false);
203
+ const result = read.find((e) => e.type === "result");
204
+ const muse = result.muse;
205
+ assert.equal(muse.firstOutputMs, 900);
206
+ assert.equal(muse.toolCallCount, 2);
207
+ assert.equal(muse.lastToolName, "npm_test");
208
+ assert.equal(muse.idleTimedOut, false);
209
+ assert.equal(muse.lastActivityAt, new Date(t0 + 63_000).toISOString());
210
+ });