agent-dealer 1.2.7 → 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
  3. package/bundle/server/dist/adapters/agent-health.js +14 -3
  4. package/bundle/server/dist/adapters/github.js +84 -5
  5. package/bundle/server/dist/adapters/github.test.js +83 -1
  6. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  7. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  8. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  9. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  10. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  11. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  12. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  13. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  14. package/bundle/server/dist/coordinator/admission.js +14 -1
  15. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  16. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  17. package/bundle/server/dist/coordinator/auto-merge.js +244 -4
  18. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  19. package/bundle/server/dist/coordinator/commands.js +284 -23
  20. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  21. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  22. package/bundle/server/dist/coordinator/developer-effect.js +224 -4
  23. package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
  24. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  25. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  26. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  27. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  28. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  29. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  30. package/bundle/server/dist/coordinator/human-resolution.js +36 -1
  31. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  32. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  33. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  34. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  35. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  36. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  37. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  38. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  39. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  40. package/bundle/server/dist/coordinator/playbook-feedback.js +693 -0
  41. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  42. package/bundle/server/dist/coordinator/projection.js +10 -4
  43. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  44. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  45. package/bundle/server/dist/coordinator/prompts.js +211 -2
  46. package/bundle/server/dist/coordinator/prompts.test.js +231 -0
  47. package/bundle/server/dist/coordinator/recovery.js +57 -3
  48. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  49. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  50. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  51. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  52. package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
  53. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  54. package/bundle/server/dist/coordinator/routing.js +45 -6
  55. package/bundle/server/dist/coordinator/routing.test.js +76 -15
  56. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  57. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  58. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  59. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  60. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  61. package/bundle/server/dist/db/index.js +18 -0
  62. package/bundle/server/dist/db/schema.sql +5 -0
  63. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  64. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  65. package/bundle/server/dist/repository/human-actions.js +14 -0
  66. package/bundle/server/dist/repository/issues.js +67 -6
  67. package/bundle/server/dist/repository/work-items.js +7 -0
  68. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  69. package/bundle/server/dist/routes/human-actions.js +10 -8
  70. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  71. package/bundle/server/dist/routes/issues.js +19 -2
  72. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  73. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  74. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  75. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  76. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  77. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  78. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  79. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  80. package/bundle/server/package.json +2 -2
  81. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  82. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  83. package/bundle/server/static-ui/index.html +2 -2
  84. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  85. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  86. package/bundle/shared/dist/execution-contract.js +307 -0
  87. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  88. package/bundle/shared/dist/execution-contract.test.js +499 -0
  89. package/bundle/shared/dist/execution-report.d.ts +9 -9
  90. package/bundle/shared/dist/execution-report.js +2 -0
  91. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  92. package/bundle/shared/dist/failure-cause.js +2 -0
  93. package/bundle/shared/dist/human-actions.d.ts +4 -4
  94. package/bundle/shared/dist/human-actions.js +17 -0
  95. package/bundle/shared/dist/index.d.ts +85 -84
  96. package/bundle/shared/dist/index.js +3 -0
  97. package/bundle/shared/dist/issues.d.ts +176 -16
  98. package/bundle/shared/dist/issues.js +22 -0
  99. package/bundle/shared/dist/issues.test.js +3 -0
  100. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  101. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  102. package/bundle/shared/dist/worker-sessions.js +8 -0
  103. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  104. package/bundle/shared/dist/workflow.d.ts +4 -4
  105. package/bundle/shared/dist/workflow.js +16 -0
  106. package/bundle/shared/dist/workflow.test.js +3 -0
  107. package/bundle/shared/package.json +1 -1
  108. package/dist/action.js +4 -1
  109. package/dist/action.test.js +42 -0
  110. package/dist/index.js +1 -1
  111. package/package.json +1 -1
  112. package/bundle/server/static-ui/assets/index-yLyxRd-7.js +0 -64
@@ -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
  });
@@ -197,6 +197,7 @@ test("developer round 1 → reviewer changes_requested → developer round 2 on
197
197
  assert.equal(issue.status, "final_review", "the cycle must end at a human final_review, not stuck or escalated");
198
198
  assert.equal(issue.currentRound, 2, "exactly one genuine repair round — changes_requested — was spent");
199
199
  assert.equal(issue.infraAttempts, 0, "nothing in this run was an infra failure");
200
+ assert.equal(issue.ciAttempts, 0, "nothing in this run was a CI failure either");
200
201
  assert.ok(listHumanActionsForIssue(issueId).find((a) => a.actionType === "final_review" && a.status === "open"));
201
202
  // Same branch reused across both rounds — never re-created.
202
203
  const branch = issueBranchName(issueId);
@@ -26,6 +26,7 @@ import os from "node:os";
26
26
  import path from "node:path";
27
27
  import { parseProfileSnapshot, roleCeiling } from "@agent-dealer/shared";
28
28
  import { getTaskSnapshot } from "./commands.js";
29
+ import { extractOperatorCriteria } from "./operator-criteria.js";
29
30
  import { buildReviewerPrompt, formatDiffForPrompt, TOTAL_DIFF_LIMIT } from "./prompts.js";
30
31
  import { guidanceForNextSession } from "./guidance.js";
31
32
  import { realReviewerSpawn, reviewerSessionLogPath } from "./spawn.js";
@@ -34,7 +35,7 @@ import { createRoleWorktree, safeRemoveWorktree, isWorktreeClean, mergeBase, fet
34
35
  import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
35
36
  import { prepareWorkerDeckConnection, releaseWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
36
37
  import { realGithubAdapter } from "../adapters/github.js";
37
- import { getWorkerSession, patchRunningSession, recordSessionProcess } from "../repository/worker-sessions.js";
38
+ import { getOrAssignSessionCorrelationId, getWorkerSession, patchRunningSession, recordSessionProcess } from "../repository/worker-sessions.js";
38
39
  import { COORDINATOR_PROCESS_OWNER, readProcessStartTime } from "./process-liveness.js";
39
40
  import { reviewerSessionTimeoutMs } from "./session-timeouts.js";
40
41
  import { getWorkItem } from "../repository/work-items.js";
@@ -196,6 +197,11 @@ function renderReviewBody(result, marker) {
196
197
  `**Evidence assessment**`,
197
198
  result.evidenceAssessment,
198
199
  ];
200
+ // NOT-306: the exit-predicate verdict rides with the review when the task
201
+ // carried an execution contract; legacy reviews simply omit the section.
202
+ if (result.exitPredicateAssessment?.trim()) {
203
+ lines.push(``, `**Exit predicate assessment**`, result.exitPredicateAssessment);
204
+ }
199
205
  if (result.findings.length) {
200
206
  lines.push(``, `**Findings**`);
201
207
  for (const f of result.findings) {
@@ -222,6 +228,9 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
222
228
  return { kind: "session_failed" };
223
229
  const session = getWorkerSession(sessionId);
224
230
  const snapshot = parseProfileSnapshot(session?.profileSnapshotJson);
231
+ // NOT-305: opaque per-session Deck correlation UUID, persisted before spawn and
232
+ // carried as observability metadata in the materialized launch config below.
233
+ const deckCorrelationId = session?.deckCorrelationId ?? getOrAssignSessionCorrelationId(sessionId);
225
234
  const taskSnapshot = getTaskSnapshot(issue);
226
235
  const runtime = snapshot?.runtime ?? "claude_code";
227
236
  const round = workItem.round;
@@ -291,6 +300,7 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
291
300
  worktreePath,
292
301
  runtime,
293
302
  policy,
303
+ correlationId: deckCorrelationId,
294
304
  verifyCallTool: deps.deckCallTool,
295
305
  });
296
306
  if (!prepared.ok) {
@@ -327,6 +337,10 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
327
337
  const { truncated: diffTruncated, omittedPaths } = formatDiffForPrompt(diff);
328
338
  const openFindings = listFindingsForIssue(issue.id).filter((f) => f.status === "open" || f.status === "recurring");
329
339
  const guidance = guidanceForNextSession(issue.id, sessionId);
340
+ // NOT-314: the frozen snapshot's operator criteria — the reviewer must not
341
+ // treat missing operator evidence as a defect (Dealer gates the merge), but
342
+ // the probe and doc must exist. Rendered only when non-empty.
343
+ const operatorCriteria = extractOperatorCriteria(taskSnapshot.acceptanceCriteria);
330
344
  const prompt = buildReviewerPrompt({
331
345
  taskSnapshot,
332
346
  round: workItem.round,
@@ -339,6 +353,7 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
339
353
  worktreePath,
340
354
  deckId: snapshot?.deckId ?? null,
341
355
  guidance: guidance.length ? guidance : undefined,
356
+ operatorCriteria: operatorCriteria.length > 0 ? operatorCriteria : undefined,
342
357
  });
343
358
  // NOT-83 review finding — see developer-effect.ts's identical check for the full
344
359
  // rationale: re-verify this item is still leased right before the real spawn, since an
@@ -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. */
@@ -1,4 +1,5 @@
1
1
  // packages/server/src/coordinator/routing.ts
2
+ import { DEFAULT_MAX_CI_ATTEMPTS } from "@agent-dealer/shared";
2
3
  import { normalizeReviewerResult } from "./reviewer-result.js";
3
4
  /** Default N for NOT-147: escalate empty-tip timeout/crash after this many failures (one auto-retry). */
4
5
  export const DEFAULT_NO_PROGRESS_INFRA_ATTEMPTS = 2;
@@ -14,6 +15,13 @@ function roundsRemain(limits) {
14
15
  export function infraAttemptsRemain(limits) {
15
16
  return limits.infraAttempts < limits.maxInfraAttempts;
16
17
  }
18
+ /**
19
+ * NOT-313: the one definition of "is there CI-repair budget left", mirroring
20
+ * {@link infraAttemptsRemain}. Absent counters read as a fresh default budget.
21
+ */
22
+ export function ciAttemptsRemain(limits) {
23
+ return (limits.ciAttempts ?? 0) < (limits.maxCiAttempts ?? DEFAULT_MAX_CI_ATTEMPTS);
24
+ }
17
25
  export function routeDeveloperOutcome(outcome, limits) {
18
26
  switch (outcome.kind) {
19
27
  case "clean_handoff":
@@ -103,7 +111,6 @@ export function routeDeveloperOutcome(outcome, limits) {
103
111
  : { next: "human_action", actionType: "policy_escalation", reason: `${reason} (infra-attempt limit reached).` };
104
112
  }
105
113
  case "no_pr":
106
- case "checks_failed":
107
114
  case "deck_failure": {
108
115
  // Unified infra-failure policy: the session/environment failed to produce a
109
116
  // reviewable result at all — bounded auto-retry on max_infra_attempts, decoupled
@@ -113,6 +120,30 @@ export function routeDeveloperOutcome(outcome, limits) {
113
120
  ? { next: "retry_developer", reason }
114
121
  : { next: "human_action", actionType: "policy_escalation", reason: `${reason} (infra-attempt limit reached).` };
115
122
  }
123
+ case "checks_failed": {
124
+ // NOT-313: a failing CI check is the expected feedback loop, not an
125
+ // infrastructure failure — bounded on max_ci_attempts, never the infra
126
+ // budget. The retry reason names the attempt number so the next session's
127
+ // prompt reads "CI repair attempt N of M"; the escalation names the same
128
+ // count plus the failing checks (via the NOT-252 enrichment), never the
129
+ // infra-attempt text. policy_escalation (not attempts_exhausted) so a human
130
+ // "resume" resets budgets without spending a review round.
131
+ const spent = limits.ciAttempts ?? 0;
132
+ const max = limits.maxCiAttempts ?? DEFAULT_MAX_CI_ATTEMPTS;
133
+ const base = checksFailedDetails(outcome);
134
+ if (ciAttemptsRemain(limits)) {
135
+ return {
136
+ next: "retry_developer",
137
+ reason: `CI repair attempt ${spent + 1} of ${max}: ${base}`,
138
+ budget: "ci",
139
+ };
140
+ }
141
+ return {
142
+ next: "human_action",
143
+ actionType: "policy_escalation",
144
+ reason: `CI checks still failing after ${spent} repair attempts (limit ${max}). ${base}`,
145
+ };
146
+ }
116
147
  case "session_failed":
117
148
  case "timed_out": {
118
149
  // Same infra budget as above, plus NOT-147: empty tip after N timeout/crashes → human
@@ -140,6 +171,11 @@ export function routeDeveloperOutcome(outcome, limits) {
140
171
  // NOT-197: a hard-down network is not retryable on the infra-attempt timescale, and
141
172
  // nothing was spawned, so there is no attempt to charge. Wait for the network instead.
142
173
  return { next: "defer_work", reason: outcome.reason };
174
+ case "checks_pending":
175
+ // NOT-311: slow or not-yet-registered CI is not a developer failure — the
176
+ // session ended cleanly with a pushed branch and an open PR. Wait on backoff
177
+ // and re-poll; the deferral path (not this route) enforces the wait ceiling.
178
+ return { next: "defer_work", reason: outcome.reason };
143
179
  }
144
180
  }
145
181
  function infraFailureReason(outcome) {
@@ -150,17 +186,20 @@ function infraFailureReason(outcome) {
150
186
  return outcome.reason ?? "Developer session failed or crashed.";
151
187
  case "timed_out":
152
188
  return outcome.reason ?? "Developer session timed out.";
153
- case "checks_failed":
154
- // NOT-252: prefer the sanitized enrichment the effect threaded into `details`
155
- // (failing check names + bounded untrusted excerpt at the verified head SHA).
156
- // Blank/absent details keep today's exact generic reason.
157
- return outcome.details?.trim() ? outcome.details : "Developer's PR checks failed.";
158
189
  case "adapter_failure":
159
190
  return `Git/GitHub verification failed: ${outcome.reason}`;
160
191
  case "deck_failure":
161
192
  return `Agent Deck ${outcome.reason}`;
162
193
  }
163
194
  }
195
+ /**
196
+ * NOT-252: prefer the sanitized enrichment the effect threaded into `details`
197
+ * (failing check names + bounded untrusted excerpt at the verified head SHA).
198
+ * Blank/absent details keep the exact generic reason.
199
+ */
200
+ function checksFailedDetails(outcome) {
201
+ return outcome.details?.trim() ? outcome.details : "Developer's PR checks failed.";
202
+ }
164
203
  /**
165
204
  * NOT-147: when a timeout/crash left the branch with zero commits ahead of base, stop
166
205
  * burning full agent spawns after N failures (default 2 = one auto-retry). Only trips when