agent-dealer 1.2.7 → 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 (87) 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-yLyxRd-7.js → index-B6SVCzMR.js} +14 -14
  61. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  62. package/bundle/server/static-ui/index.html +2 -2
  63. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  64. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  65. package/bundle/shared/dist/execution-contract.js +307 -0
  66. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  67. package/bundle/shared/dist/execution-contract.test.js +499 -0
  68. package/bundle/shared/dist/execution-report.d.ts +9 -9
  69. package/bundle/shared/dist/execution-report.js +2 -0
  70. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  71. package/bundle/shared/dist/failure-cause.js +2 -0
  72. package/bundle/shared/dist/human-actions.d.ts +4 -4
  73. package/bundle/shared/dist/human-actions.js +9 -0
  74. package/bundle/shared/dist/index.d.ts +85 -84
  75. package/bundle/shared/dist/index.js +3 -0
  76. package/bundle/shared/dist/issues.d.ts +146 -16
  77. package/bundle/shared/dist/issues.js +8 -0
  78. package/bundle/shared/dist/issues.test.js +1 -0
  79. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  80. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  81. package/bundle/shared/dist/worker-sessions.js +8 -0
  82. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  83. package/bundle/shared/dist/workflow.d.ts +4 -4
  84. package/bundle/shared/dist/workflow.js +10 -0
  85. package/bundle/shared/dist/workflow.test.js +2 -0
  86. package/bundle/shared/package.json +1 -1
  87. package/package.json +1 -1
@@ -1,31 +1,30 @@
1
1
  // packages/server/src/coordinator/reflect-trigger.test.ts
2
2
  //
3
- // NOT-64: triggerIssueReflect is the lightweight (no agent-spawn) reflect fired on
4
- // final_review:complete. Network calls to Agent Deck are faked via the injectable
5
- // `deps` seam.
6
- //
7
- // NOT-106: Deck calls go through launch-fixed deck headers via `deps.callTool`
8
- // (adapters/reflect-authority.ts) — no mint.
3
+ // NOT-305: triggerIssueReflect records completion evidence — actual-use receipts for
4
+ // every terminal worker session plus one idempotent `signal_only` Deck report per
5
+ // failure/correction trigger. It never proposes a playbook patch (`kind: update`) and
6
+ // never appends generic `Notes` items.
9
7
  import { test, before } from "node:test";
10
8
  import assert from "node:assert/strict";
11
9
  import fs from "node:fs";
12
10
  import os from "node:os";
13
11
  import path from "node:path";
12
+ import { DECK_CORRELATION_TOOL, validateSignalOnlyArgs } from "./playbook-feedback.js";
14
13
  process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-reflect-"));
15
- const { migrate, getDb } = await import("../db/index.js");
14
+ const { migrate } = await import("../db/index.js");
16
15
  const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
17
16
  const { createIssue } = await import("../repository/issues.js");
18
17
  const { createAgent } = await import("../repository/agents.js");
19
- const { createWorkerSession } = await import("../repository/worker-sessions.js");
18
+ const { createWorkerSession, completeSession } = await import("../repository/worker-sessions.js");
20
19
  const { buildProfileSnapshot, serializeProfileSnapshot } = await import("./profile-snapshot.js");
21
- const { appendWorkflowEvent } = await import("../repository/workflow-events.js");
22
- const { createIssueArtifact } = await import("../repository/artifacts.js");
20
+ const { createHumanAction, resolveHumanAction } = await import("../repository/human-actions.js");
23
21
  const { listArtifactsForIssue, listArtifactsForIssueByKind } = await import("../repository/artifacts-for-issue.js");
24
- const { listHumanActionsForIssue, createHumanAction } = await import("../repository/human-actions.js");
22
+ const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
25
23
  const { triggerIssueReflect, resolveReflectionInteractionAction } = await import("./reflect-trigger.js");
26
24
  before(() => {
27
25
  migrate();
28
26
  });
27
+ const DECK = "11111111-1111-4111-a111-111111111111";
29
28
  function seedIssue(developerAgentId) {
30
29
  return createIssue({
31
30
  title: "Reflect issue",
@@ -38,14 +37,11 @@ function seedIssue(developerAgentId) {
38
37
  source: "manual",
39
38
  });
40
39
  }
41
- function seedFinalDeveloperSession(issueId, agent, overrides) {
42
- if (overrides.playbookIds !== undefined) {
43
- getDb()
44
- .prepare("UPDATE agents SET playbook_ids_json = ? WHERE id = ?")
45
- .run(JSON.stringify(overrides.playbookIds), agent.id);
46
- }
47
- const snapshot = { ...buildProfileSnapshot(agent, "developer"), deckId: overrides.deckId };
48
- return createWorkerSession({
40
+ /** Terminal developer session on a NOT-149 profile: snapshot names the deck, no playbook IDs anywhere. */
41
+ function seedTerminalDeveloperSession(issueId, agent) {
42
+ assert.equal(agent.playbookIdsJson, null);
43
+ const snapshot = { ...buildProfileSnapshot(agent, "developer"), deckId: DECK };
44
+ const session = createWorkerSession({
49
45
  issueId,
50
46
  role: "developer",
51
47
  round: 1,
@@ -53,185 +49,125 @@ function seedFinalDeveloperSession(issueId, agent, overrides) {
53
49
  runtime: agent.runtime,
54
50
  profileSnapshotJson: serializeProfileSnapshot(snapshot),
55
51
  });
52
+ completeSession(session.id, { status: "done" });
53
+ return session;
56
54
  }
57
- async function waitFor(predicate, timeoutMs = 2000, intervalMs = 5) {
58
- const start = Date.now();
59
- while (!predicate()) {
60
- if (Date.now() - start > timeoutMs)
61
- throw new Error("waitFor: timed out waiting for predicate");
62
- await new Promise((r) => setTimeout(r, intervalMs));
63
- }
64
- }
65
- /** Default deps: healthy deck, get_playbook/propose_playbook_patch both succeed. */
66
- function makeDeps(overrides) {
67
- return {
55
+ /** Default deps: healthy deck, correlation succeeds, signal_only succeeds. */
56
+ function makeDeps(overrides = {}) {
57
+ const calls = [];
58
+ const deps = {
68
59
  checkHealth: overrides.checkHealth ?? (async () => true),
69
60
  callTool: (async (opts) => {
70
- if (opts.toolName === "get_playbook") {
71
- const playbookId = opts.arguments.playbook_id;
72
- return overrides.getPlaybook
73
- ? overrides.getPlaybook(playbookId)
74
- : { ok: true, data: { id: playbookId, title: `Playbook ${playbookId}`, body: "" } };
61
+ calls.push({ toolName: opts.toolName, args: opts.arguments });
62
+ if (opts.toolName === DECK_CORRELATION_TOOL) {
63
+ return { ok: true, data: overrides.correlationData ?? { fetches: overrides.fetches ?? [] } };
75
64
  }
76
65
  if (opts.toolName === "propose_playbook_patch") {
77
- const playbookId = opts.arguments.playbook_id;
78
- const rationale = opts.arguments.rationale;
79
- return overrides.proposePatch
80
- ? overrides.proposePatch(playbookId, rationale)
81
- : { ok: true, data: { id: `patch-${playbookId}`, playbookId } };
66
+ if (opts.arguments.kind !== "signal_only") {
67
+ throw new Error(`issue-centric reflection must only send signal_only, got ${opts.arguments.kind}`);
68
+ }
69
+ const schemaError = validateSignalOnlyArgs(opts.arguments);
70
+ if (schemaError) {
71
+ throw new Error(`signal payload violates Deck schema: ${schemaError}`);
72
+ }
73
+ return overrides.propose
74
+ ? overrides.propose(opts.arguments)
75
+ : { ok: true, data: { id: `sig-${calls.length}` } };
82
76
  }
83
77
  throw new Error(`unexpected tool call: ${opts.toolName}`);
84
78
  }),
85
79
  };
80
+ return { deps, calls };
81
+ }
82
+ function sentSignals(issueId) {
83
+ return listArtifactsForIssueByKind(issueId, "deck_feedback_signal")
84
+ .map((a) => JSON.parse(a.contentJson))
85
+ .filter((s) => s.status === "sent");
86
+ }
87
+ async function waitFor(predicate, timeoutMs = 2000, intervalMs = 5) {
88
+ const start = Date.now();
89
+ while (!predicate()) {
90
+ if (Date.now() - start > timeoutMs)
91
+ throw new Error("waitFor: timed out waiting for predicate");
92
+ await new Promise((r) => setTimeout(r, intervalMs));
93
+ }
86
94
  }
87
- test("skips when the developer profile has no deck configured and no session snapshot exists", async () => {
95
+ test("skips when the issue has no terminal sessions and no failure triggers", async () => {
88
96
  const issue = seedIssue(BUILTIN_AGENT_CLAUDE_ID);
89
- const result = await triggerIssueReflect(issue.id);
97
+ const { deps, calls } = makeDeps();
98
+ const result = await triggerIssueReflect(issue.id, deps);
90
99
  assert.equal(result, "skipped");
100
+ assert.deepStrictEqual(calls, []);
91
101
  });
92
- test("uses the frozen session snapshot's deck/playbooks, not the live (possibly edited) agent profile", async () => {
93
- const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "22222222-2222-4222-a222-222222222222" });
102
+ test("a clean completed run records the actual-use receipt and sends no signal", async () => {
103
+ const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
94
104
  const issue = seedIssue(dev.id);
95
- seedFinalDeveloperSession(issue.id, dev, {
96
- deckId: "11111111-1111-4111-a111-111111111111",
97
- playbookIds: ["pb-1", "pb-2"],
98
- });
99
- const calledDeckIds = [];
100
- const proposed = [];
101
- const deps = makeDeps({
102
- proposePatch: (playbookId) => {
103
- proposed.push(playbookId);
104
- return { ok: true, data: { id: `patch-${playbookId}`, playbookId } };
105
- },
106
- });
107
- const wrapped = {
108
- ...deps,
109
- callTool: (async (opts) => {
110
- calledDeckIds.push(opts.deckId);
111
- return deps.callTool(opts);
112
- }),
113
- };
114
- const result = await triggerIssueReflect(issue.id, wrapped);
115
- assert.equal(result, "triggered");
116
- assert.ok(calledDeckIds.every((id) => id === "11111111-1111-4111-a111-111111111111"));
117
- assert.deepStrictEqual(proposed.sort(), ["pb-1", "pb-2"]);
118
- });
119
- test("falls back to the live agent profile when the developer session has no frozen snapshot (legacy row)", async () => {
120
- const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "33333333-3333-4333-a333-333333333333" });
121
- getDb()
122
- .prepare("UPDATE agents SET playbook_ids_json = ? WHERE id = ?")
123
- .run(JSON.stringify(["pb-live"]), dev.id);
124
- const issue = seedIssue(dev.id);
125
- createWorkerSession({ issueId: issue.id, role: "developer", round: 1, agentId: dev.id, runtime: "claude_code" });
126
- const proposed = [];
127
- const deps = makeDeps({
128
- proposePatch: (playbookId) => {
129
- proposed.push(playbookId);
130
- return { ok: true, data: { id: "patch", playbookId } };
131
- },
132
- });
105
+ const session = seedTerminalDeveloperSession(issue.id, dev);
106
+ const { deps, calls } = makeDeps({ fetches: [{ playbook_id: "pb-1" }, { playbook_id: "pb-2" }] });
133
107
  const result = await triggerIssueReflect(issue.id, deps);
134
108
  assert.equal(result, "triggered");
135
- assert.deepStrictEqual(proposed, ["pb-live"]);
109
+ const receipts = listArtifactsForIssueByKind(issue.id, "playbook_use_receipt").map((a) => JSON.parse(a.contentJson));
110
+ assert.equal(receipts.length, 1);
111
+ assert.deepStrictEqual(receipts[0].playbookIds, ["pb-1", "pb-2"]);
112
+ assert.equal(receipts[0].workerSessionId, session.id);
113
+ assert.deepStrictEqual(calls.filter((c) => c.toolName === "propose_playbook_patch"), []);
114
+ assert.deepStrictEqual(listArtifactsForIssueByKind(issue.id, "deck_feedback_signal"), []);
115
+ assert.deepStrictEqual(sentSignals(issue.id), []);
136
116
  });
137
- test("the legacy-row fallback also honors a profile with only the singular legacy playbookId", async () => {
138
- const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "44444444-4444-4444-a444-444444444444" });
139
- getDb().prepare("UPDATE agents SET playbook_id = ? WHERE id = ?").run("pb-legacy", dev.id);
117
+ test("a human correction produces one signal_only report; a restart sends nothing more", async () => {
118
+ const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
140
119
  const issue = seedIssue(dev.id);
141
- createWorkerSession({ issueId: issue.id, role: "developer", round: 1, agentId: dev.id, runtime: "claude_code" });
142
- const proposed = [];
143
- const deps = makeDeps({
144
- proposePatch: (playbookId) => {
145
- proposed.push(playbookId);
146
- return { ok: true, data: { id: "patch", playbookId } };
147
- },
120
+ seedTerminalDeveloperSession(issue.id, dev);
121
+ const action = createHumanAction({
122
+ issueId: issue.id,
123
+ actionType: "final_review",
124
+ reason: "Needs a repair round.",
125
+ question: "Repair?",
148
126
  });
149
- const result = await triggerIssueReflect(issue.id, deps);
150
- assert.equal(result, "triggered");
151
- assert.deepStrictEqual(proposed, ["pb-legacy"]);
127
+ resolveHumanAction(action.id, "operator", { choice: "repair" });
128
+ const { deps, calls } = makeDeps({ fetches: [{ playbook_id: "pb-1" }] });
129
+ assert.equal(await triggerIssueReflect(issue.id, deps), "triggered");
130
+ const proposes = () => calls.filter((c) => c.toolName === "propose_playbook_patch");
131
+ assert.equal(proposes().length, 1);
132
+ assert.equal(proposes()[0].args.kind, "signal_only");
133
+ // Simulated restart: idempotent, no duplicate Deck report.
134
+ assert.equal(await triggerIssueReflect(issue.id, deps), "skipped");
135
+ assert.equal(proposes().length, 1);
136
+ assert.equal(sentSignals(issue.id).length, 1);
152
137
  });
153
- test("skips when the deck is offline, and records why — never calls Deck tools", async () => {
154
- const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "11111111-1111-4111-a111-111111111111" });
138
+ test("records a visible failure and preserves state when the deck is offline", async () => {
139
+ const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
155
140
  const issue = seedIssue(dev.id);
156
- seedFinalDeveloperSession(issue.id, dev, {
157
- deckId: "11111111-1111-4111-a111-111111111111",
158
- playbookIds: ["pb-1"],
159
- });
160
- const deps = {
161
- checkHealth: async () => false,
162
- callTool: (async () => {
163
- throw new Error("should not be called");
164
- }),
165
- };
141
+ seedTerminalDeveloperSession(issue.id, dev);
142
+ const { deps, calls } = makeDeps({ checkHealth: async () => false });
166
143
  const result = await triggerIssueReflect(issue.id, deps);
167
- assert.equal(result, "skipped");
144
+ assert.equal(result, "failed");
145
+ assert.deepStrictEqual(calls, []);
168
146
  const artifacts = listArtifactsForIssue(issue.id);
169
- assert.ok(artifacts.some((a) => a.kind === "reflect_status" && JSON.parse(a.contentJson).reason === "Agent Deck offline"));
147
+ assert.ok(artifacts.some((a) => a.kind === "reflect_status" || (a.kind === "playbook_use_receipt" && JSON.parse(a.contentJson).status === "error")));
170
148
  });
171
- test("posts one patch per playbook, using the implementation conclusion and review history as rationale", async () => {
172
- const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
149
+ test("a retry after a failed signal send re-sends exactly once, never duplicating", async () => {
150
+ const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
173
151
  const issue = seedIssue(dev.id);
174
- seedFinalDeveloperSession(issue.id, dev, {
175
- deckId: "11111111-1111-4111-a111-111111111111",
176
- playbookIds: ["pb-1", "pb-2"],
177
- });
178
- createIssueArtifact({
179
- issueId: issue.id,
180
- kind: "implementation_conclusion",
181
- author: "agent",
182
- content: { text: "Implemented the widget using the shared component." },
183
- });
184
- appendWorkflowEvent({
152
+ seedTerminalDeveloperSession(issue.id, dev);
153
+ const action = createHumanAction({
185
154
  issueId: issue.id,
186
- type: "review.submitted",
187
- actorType: "reviewer",
188
- stage: "reviewing",
189
- payload: { verdict: "changes_requested" },
190
- });
191
- appendWorkflowEvent({
192
- issueId: issue.id,
193
- type: "review.submitted",
194
- actorType: "reviewer",
195
- stage: "reviewing",
196
- payload: { verdict: "approved" },
197
- });
198
- const proposed = [];
199
- const deps = makeDeps({
200
- proposePatch: (playbookId, rationale) => {
201
- proposed.push(playbookId);
202
- assert.match(rationale, /2 review round\(s\)/);
203
- assert.match(rationale, /changes_requested → approved/);
204
- assert.match(rationale, /Implemented the widget using the shared component\./);
205
- return { ok: true, data: { id: `patch-${playbookId}`, playbookId } };
206
- },
207
- });
208
- const result = await triggerIssueReflect(issue.id, deps);
209
- assert.equal(result, "triggered");
210
- assert.deepStrictEqual(proposed.sort(), ["pb-1", "pb-2"]);
211
- const artifacts = listArtifactsForIssue(issue.id);
212
- assert.equal(artifacts.filter((a) => a.kind === "playbook_patch").length, 2);
213
- });
214
- test("a retry after a mid-loop failure does not re-propose a playbook that already succeeded", async () => {
215
- const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
216
- const issue = seedIssue(dev.id);
217
- seedFinalDeveloperSession(issue.id, dev, {
218
- deckId: "11111111-1111-4111-a111-111111111111",
219
- playbookIds: ["pb-ok", "pb-fail"],
155
+ actionType: "final_review",
156
+ reason: "Needs a repair round.",
157
+ question: "Repair?",
220
158
  });
221
- let failPbFail = true;
222
- const proposedCalls = [];
223
- const deps = makeDeps({
224
- proposePatch: (playbookId) => {
225
- proposedCalls.push(playbookId);
226
- if (playbookId === "pb-fail" && failPbFail) {
227
- return { ok: false, kind: "infra_failure", reason: "temporary deck error" };
228
- }
229
- return { ok: true, data: { id: `patch-${playbookId}`, playbookId } };
230
- },
159
+ resolveHumanAction(action.id, "operator", { choice: "repair" });
160
+ let failPropose = true;
161
+ const { deps, calls } = makeDeps({
162
+ propose: () => failPropose
163
+ ? { ok: false, kind: "infra_failure", reason: "temporary deck error" }
164
+ : { ok: true, data: { id: "sig-1" } },
231
165
  });
166
+ const proposes = () => calls.filter((c) => c.toolName === "propose_playbook_patch");
232
167
  const first = await triggerIssueReflect(issue.id, deps);
233
- assert.equal(first, "triggered");
234
- assert.deepStrictEqual(proposedCalls, ["pb-ok", "pb-fail"]);
168
+ assert.equal(first, "triggered"); // the receipt still recorded
169
+ assert.equal(proposes().length, 1);
170
+ assert.equal(sentSignals(issue.id).length, 0);
235
171
  createHumanAction({
236
172
  issueId: issue.id,
237
173
  actionType: "reflection_interaction_required",
@@ -243,11 +179,12 @@ test("a retry after a mid-loop failure does not re-propose a playbook that alrea
243
179
  ],
244
180
  });
245
181
  const parkedAction = listHumanActionsForIssue(issue.id).find((a) => a.actionType === "reflection_interaction_required" && a.status === "open");
246
- failPbFail = false;
182
+ failPropose = false;
247
183
  const resolved = resolveReflectionInteractionAction(parkedAction.id, "operator", "retry", deps);
248
184
  assert.equal(resolved.ok, true);
249
- await waitFor(() => proposedCalls.length >= 3);
250
- assert.deepStrictEqual(proposedCalls, ["pb-ok", "pb-fail", "pb-fail"]);
185
+ await waitFor(() => sentSignals(issue.id).length >= 1);
186
+ assert.equal(proposes().length, 2);
187
+ assert.equal(sentSignals(issue.id).length, 1);
251
188
  });
252
189
  test("resolveReflectionInteractionAction:dismiss closes the action without further Deck calls", async () => {
253
190
  const issue = seedIssue(BUILTIN_AGENT_CLAUDE_ID);
@@ -289,36 +226,48 @@ test("resolveReflectionInteractionAction rejects an invalid choice and already-r
289
226
  const alreadyResolved = resolveReflectionInteractionAction(parkedAction.id, "operator", "retry");
290
227
  assert.equal(alreadyResolved.ok, false);
291
228
  });
292
- test("already-proposed playbook ids are skipped even behind a flood of other artifacts", async () => {
293
- const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
229
+ test("reflect_status names a malformed correlation response instead of blaming an offline Deck", async () => {
230
+ const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
294
231
  const issue = seedIssue(dev.id);
295
- seedFinalDeveloperSession(issue.id, dev, {
296
- deckId: "11111111-1111-4111-a111-111111111111",
297
- playbookIds: ["pb-ok", "pb-next"],
298
- });
299
- createIssueArtifact({
232
+ seedTerminalDeveloperSession(issue.id, dev);
233
+ const { deps } = makeDeps({ correlationData: { unexpected: "shape" } });
234
+ const result = await triggerIssueReflect(issue.id, deps);
235
+ assert.equal(result, "failed");
236
+ const statuses = listArtifactsForIssueByKind(issue.id, "reflect_status").map((a) => JSON.parse(a.contentJson));
237
+ assert.ok(statuses.length > 0);
238
+ const text = statuses.map((s) => `${s.reason ?? ""} ${s.error ?? ""}`).join("\n");
239
+ assert.match(text, /malformed/i);
240
+ assert.doesNotMatch(text, /offline/i);
241
+ });
242
+ test("reflect_status still says offline when the receipt error really is a Deck outage", async () => {
243
+ const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
244
+ const issue = seedIssue(dev.id);
245
+ seedTerminalDeveloperSession(issue.id, dev);
246
+ const { deps } = makeDeps({ checkHealth: async () => false });
247
+ assert.equal(await triggerIssueReflect(issue.id, deps), "failed");
248
+ const statuses = listArtifactsForIssueByKind(issue.id, "reflect_status").map((a) => JSON.parse(a.contentJson));
249
+ assert.ok(statuses.some((s) => /offline/i.test(`${s.reason ?? ""} ${s.error ?? ""}`)));
250
+ });
251
+ test("never calls propose_playbook_patch with kind:update and never writes Notes items", async () => {
252
+ const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: DECK });
253
+ const issue = seedIssue(dev.id);
254
+ seedTerminalDeveloperSession(issue.id, dev);
255
+ const action = createHumanAction({
300
256
  issueId: issue.id,
301
- kind: "playbook_patch",
302
- author: "system",
303
- content: { patchId: "existing", playbookId: "pb-ok", status: "proposed" },
257
+ actionType: "attempts_exhausted",
258
+ reason: "Rounds spent.",
259
+ question: "Close?",
304
260
  });
305
- for (let i = 0; i < 30; i++) {
306
- createIssueArtifact({
307
- issueId: issue.id,
308
- kind: "developer_transcript",
309
- author: "system",
310
- content: { n: i },
311
- });
261
+ resolveHumanAction(action.id, "operator", { choice: "close" });
262
+ const { deps, calls } = makeDeps({ fetches: [{ playbook_id: "pb-9" }] });
263
+ await triggerIssueReflect(issue.id, deps);
264
+ for (const call of calls) {
265
+ if (call.toolName !== "propose_playbook_patch")
266
+ continue;
267
+ assert.notEqual(call.args.kind, "update");
268
+ assert.equal(call.args.kind, "signal_only");
269
+ const ops = call.args.ops;
270
+ assert.ok(!ops || !ops.some((op) => op.section === "Notes"));
312
271
  }
313
- const proposed = [];
314
- const deps = makeDeps({
315
- proposePatch: (playbookId) => {
316
- proposed.push(playbookId);
317
- return { ok: true, data: { id: `patch-${playbookId}`, playbookId } };
318
- },
319
- });
320
- const result = await triggerIssueReflect(issue.id, deps);
321
- assert.equal(result, "triggered");
322
- assert.deepStrictEqual(proposed, ["pb-next"]);
323
- assert.equal(listArtifactsForIssueByKind(issue.id, "playbook_patch").length, 2);
272
+ assert.equal(listArtifactsForIssueByKind(issue.id, "playbook_patch").length, 0);
324
273
  });
@@ -34,7 +34,7 @@ import { createRoleWorktree, safeRemoveWorktree, isWorktreeClean, mergeBase, fet
34
34
  import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
35
35
  import { prepareWorkerDeckConnection, releaseWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
36
36
  import { realGithubAdapter } from "../adapters/github.js";
37
- import { getWorkerSession, patchRunningSession, recordSessionProcess } from "../repository/worker-sessions.js";
37
+ import { getOrAssignSessionCorrelationId, getWorkerSession, patchRunningSession, recordSessionProcess } from "../repository/worker-sessions.js";
38
38
  import { COORDINATOR_PROCESS_OWNER, readProcessStartTime } from "./process-liveness.js";
39
39
  import { reviewerSessionTimeoutMs } from "./session-timeouts.js";
40
40
  import { getWorkItem } from "../repository/work-items.js";
@@ -196,6 +196,11 @@ function renderReviewBody(result, marker) {
196
196
  `**Evidence assessment**`,
197
197
  result.evidenceAssessment,
198
198
  ];
199
+ // NOT-306: the exit-predicate verdict rides with the review when the task
200
+ // carried an execution contract; legacy reviews simply omit the section.
201
+ if (result.exitPredicateAssessment?.trim()) {
202
+ lines.push(``, `**Exit predicate assessment**`, result.exitPredicateAssessment);
203
+ }
199
204
  if (result.findings.length) {
200
205
  lines.push(``, `**Findings**`);
201
206
  for (const f of result.findings) {
@@ -222,6 +227,9 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
222
227
  return { kind: "session_failed" };
223
228
  const session = getWorkerSession(sessionId);
224
229
  const snapshot = parseProfileSnapshot(session?.profileSnapshotJson);
230
+ // NOT-305: opaque per-session Deck correlation UUID, persisted before spawn and
231
+ // carried as observability metadata in the materialized launch config below.
232
+ const deckCorrelationId = session?.deckCorrelationId ?? getOrAssignSessionCorrelationId(sessionId);
225
233
  const taskSnapshot = getTaskSnapshot(issue);
226
234
  const runtime = snapshot?.runtime ?? "claude_code";
227
235
  const round = workItem.round;
@@ -291,6 +299,7 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
291
299
  worktreePath,
292
300
  runtime,
293
301
  policy,
302
+ correlationId: deckCorrelationId,
294
303
  verifyCallTool: deps.deckCallTool,
295
304
  });
296
305
  if (!prepared.ok) {
@@ -14,6 +14,12 @@ export const ReviewerResult = z.object({
14
14
  headSha: z.string(),
15
15
  acceptanceCriteriaAssessment: z.string(),
16
16
  evidenceAssessment: z.string(),
17
+ /**
18
+ * NOT-306: whether the frozen exit predicate held at the reviewed tip. The
19
+ * reviewer sets this when the task carries an execution contract; legacy
20
+ * contract-free tasks omit it (optional so older outputs still parse).
21
+ */
22
+ exitPredicateAssessment: z.string().optional(),
17
23
  findings: z.array(ReviewerFinding),
18
24
  risks: z.array(z.string()),
19
25
  /** Present only when verdict is "escalated" and the reviewer identifies a missing product call. */
@@ -216,6 +216,20 @@ test("timed_out with infra attempts remaining retries (same bucket as session_fa
216
216
  reason: "Developer session timed out.",
217
217
  });
218
218
  });
219
+ test("NOT-307: an idle-watchdog timed_out routes exactly like a wall-clock timeout", () => {
220
+ // The idle kill reads as timed_out downstream: same bounded infra retry, same
221
+ // budget accounting — only the reason (and the failure cause) names the stall.
222
+ const outcome = {
223
+ kind: "timed_out",
224
+ reason: "Developer session made no progress for 20 minutes. (last tool: npm_test) Killed on the idle timeout.",
225
+ };
226
+ assert.deepStrictEqual(routeDeveloperOutcome(outcome, INFRA_ATTEMPTS_LEFT), {
227
+ next: "retry_developer",
228
+ reason: "Developer session made no progress for 20 minutes. (last tool: npm_test) Killed on the idle timeout.",
229
+ });
230
+ assert.deepStrictEqual(routeDeveloperOutcome({ kind: "timed_out" }, INFRA_AT_LIMIT).next, "human_action");
231
+ assert.deepStrictEqual(routeDeveloperOutcome(outcome, INFRA_AT_LIMIT).next, "human_action");
232
+ });
219
233
  test("checks_failed with infra attempts remaining retries (same bucket as session_failed/no_pr)", () => {
220
234
  const outcome = { kind: "checks_failed" };
221
235
  assert.deepStrictEqual(routeDeveloperOutcome(outcome, INFRA_ATTEMPTS_LEFT), {
@@ -5,6 +5,36 @@ export function developerSessionTimeoutMs() {
5
5
  export function reviewerSessionTimeoutMs() {
6
6
  return num("REVIEWER_TIMEOUT_MS", 30 * 60_000);
7
7
  }
8
+ /**
9
+ * NOT-307: silent-child bound for the Muse developer lane only (other runtimes do
10
+ * not read this). Undefined means the watchdog is disabled. The watchdog ships
11
+ * ENABLED with a 30-minute default: 20 min risks false kills against the
12
+ * reported ~39.8 min silent gap in completed-session history; 45 min saves only
13
+ * 15 of the 60-min wall-clock cap; disabled does not address the observed
14
+ * 1-hour stalls. The ~39.8 min figure could not be re-verified — historical raw
15
+ * logs carry replayed `recorded_at`, not arrival times, so it may overstate
16
+ * true stdout/session.jsonl silence. The value is env-tunable, and the new
17
+ * per-event `ts`/`durationMs` plus `metadata_json` stall fields will provide
18
+ * the real gap distribution to recalibrate; a false kill is bounded by timeout
19
+ * salvage + retry from the branch. `0` disables explicitly. A non-numeric or
20
+ * negative value falls back to the default with a logged warning rather than
21
+ * silently disabling the guard (or killing healthy sessions on a typo like
22
+ * `-1`).
23
+ */
24
+ export const DEFAULT_MUSE_IDLE_TIMEOUT_MS = 30 * 60_000;
25
+ export function museIdleTimeoutMs() {
26
+ const raw = process.env.MUSE_IDLE_TIMEOUT_MS;
27
+ if (raw === undefined || raw.trim() === "")
28
+ return DEFAULT_MUSE_IDLE_TIMEOUT_MS;
29
+ const parsed = Number(raw);
30
+ if (Number.isFinite(parsed) && parsed === 0)
31
+ return undefined;
32
+ if (!Number.isFinite(parsed) || parsed < 0) {
33
+ console.warn(`[dealer] ignoring invalid MUSE_IDLE_TIMEOUT_MS=${JSON.stringify(raw)} — using default ${DEFAULT_MUSE_IDLE_TIMEOUT_MS}`);
34
+ return DEFAULT_MUSE_IDLE_TIMEOUT_MS;
35
+ }
36
+ return parsed;
37
+ }
8
38
  export function sessionTimeoutMsFor(role) {
9
39
  return role === "reviewer" ? reviewerSessionTimeoutMs() : developerSessionTimeoutMs();
10
40
  }
@@ -32,8 +32,8 @@ beforeEach(() => {
32
32
  DELETE FROM human_actions;
33
33
  DELETE FROM workflow_events;
34
34
  DELETE FROM findings;
35
- DELETE FROM worker_sessions;
36
35
  DELETE FROM artifacts;
36
+ DELETE FROM worker_sessions;
37
37
  DELETE FROM usage_events;
38
38
  DELETE FROM workflow_instances;
39
39
  DELETE FROM issues;
@@ -83,6 +83,15 @@ function safeCompleteSession(sessionId, status, error) {
83
83
  catch (err) {
84
84
  console.error("[coordinator] completeSession", sessionId, err);
85
85
  }
86
+ // NOT-305: best-effort actual-use receipt the moment the session goes terminal — a
87
+ // later issue-completion trigger re-attempts anything still missing, so this never
88
+ // throws and never changes the session outcome or the issue state.
89
+ try {
90
+ void import("./playbook-feedback.js").then(({ collectPlaybookUseReceipt }) => collectPlaybookUseReceipt(sessionId).catch(() => { }));
91
+ }
92
+ catch (err) {
93
+ console.error("[coordinator] playbook receipt", sessionId, err);
94
+ }
86
95
  }
87
96
  /**
88
97
  * A failed effect (handler threw, or lost its lease mid-run). One transaction, fenced on
@@ -269,10 +278,6 @@ async function processWorkItem(claimed) {
269
278
  safeCompleteSession(session.id, "cancelled", { reason: result.reason });
270
279
  return;
271
280
  }
272
- if (result.triggerReflect) {
273
- const { triggerIssueReflect } = await import("./reflect-trigger.js");
274
- void triggerIssueReflect(claimed.issueId).catch(() => { });
275
- }
276
281
  // NOT-136: a deck-unavailable session never spawned anything — `cancelled`, not `failed`
277
282
  // (which would read as a worker crash) and not `done` (which would claim it ran).
278
283
  // NOT-197: a base-fetch failure likewise never spawned (and created no branch).
@@ -298,7 +303,16 @@ async function processWorkItem(claimed) {
298
303
  }),
299
304
  }
300
305
  : undefined;
306
+ // NOT-305: the session row goes terminal BEFORE the issue-level reflect trigger
307
+ // below. triggerIssueReflect collects receipts for every terminal session, and
308
+ // completeSession writes the terminal status synchronously — firing reflect first
309
+ // would exclude this just-finished session from the signal's worker sessions and
310
+ // playbook evidence.
301
311
  safeCompleteSession(session.id, sessionStatus, errorPayload);
312
+ if (result.triggerReflect) {
313
+ const { triggerIssueReflect } = await import("./reflect-trigger.js");
314
+ void triggerIssueReflect(claimed.issueId).catch(() => { });
315
+ }
302
316
  }
303
317
  /**
304
318
  * Claims and starts as many work items as free concurrency slots allow, then returns the
@@ -131,6 +131,11 @@ export function migrate() {
131
131
  if (!workerSessionCols.some((c) => c.name === "process_started_at")) {
132
132
  db.exec("ALTER TABLE worker_sessions ADD COLUMN process_started_at TEXT");
133
133
  }
134
+ // NOT-305: opaque Deck correlation UUID per session. Pre-feature rows stay NULL,
135
+ // which readers treat as "no correlation" (receipt records a skip, never a guess).
136
+ if (!workerSessionCols.some((c) => c.name === "deck_correlation_id")) {
137
+ db.exec("ALTER TABLE worker_sessions ADD COLUMN deck_correlation_id TEXT");
138
+ }
134
139
  // NOT-181: the model a session actually ran (Muse's server-confirmed one). Existing rows stay
135
140
  // NULL, which every reader treats as "not recorded".
136
141
  const usageEventCols = db.prepare("PRAGMA table_info(usage_events)").all();
@@ -155,6 +155,9 @@ CREATE TABLE IF NOT EXISTS worker_sessions (
155
155
  process_pid INTEGER,
156
156
  process_owner TEXT,
157
157
  process_started_at TEXT,
158
+ -- NOT-305: opaque Agent Deck correlation UUID, one per session, generated before
159
+ -- spawn and passed as observability metadata in every runtime's Deck launch config.
160
+ deck_correlation_id TEXT,
158
161
  created_at TEXT NOT NULL,
159
162
  started_at TEXT,
160
163
  heartbeat_at TEXT,
@@ -67,6 +67,7 @@ test("the contract defines every phase, taxonomy code, and quality label", () =>
67
67
  "provider_capacity_rate_limit",
68
68
  "agent_cli_crash",
69
69
  "tool_test_timeout",
70
+ "muse_no_progress",
70
71
  "coordinator_crash",
71
72
  "validation_failure",
72
73
  "publish_git_failure",