baxian 1.0.3 → 1.2.0

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 (86) hide show
  1. package/dist/agent/diff-split.d.ts +10 -0
  2. package/dist/agent/diff-split.d.ts.map +1 -0
  3. package/dist/agent/diff-split.js +83 -0
  4. package/dist/agent/diff-split.js.map +1 -0
  5. package/dist/agent/manager.d.ts +75 -12
  6. package/dist/agent/manager.d.ts.map +1 -1
  7. package/dist/agent/manager.js +1121 -320
  8. package/dist/agent/manager.js.map +1 -1
  9. package/dist/agent/phase-signal-watcher.d.ts +7 -1
  10. package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
  11. package/dist/agent/phase-signal-watcher.js +37 -11
  12. package/dist/agent/phase-signal-watcher.js.map +1 -1
  13. package/dist/agent/phase-signal.d.ts +29 -11
  14. package/dist/agent/phase-signal.d.ts.map +1 -1
  15. package/dist/agent/phase-signal.js +38 -8
  16. package/dist/agent/phase-signal.js.map +1 -1
  17. package/dist/agent/prompt.d.ts +15 -2
  18. package/dist/agent/prompt.d.ts.map +1 -1
  19. package/dist/agent/prompt.js +250 -52
  20. package/dist/agent/prompt.js.map +1 -1
  21. package/dist/agent/repo-store.d.ts +0 -1
  22. package/dist/agent/repo-store.d.ts.map +1 -1
  23. package/dist/agent/repo-store.js +0 -25
  24. package/dist/agent/repo-store.js.map +1 -1
  25. package/dist/agent/review-transport.d.ts +36 -0
  26. package/dist/agent/review-transport.d.ts.map +1 -0
  27. package/dist/agent/review-transport.js +246 -0
  28. package/dist/agent/review-transport.js.map +1 -0
  29. package/dist/agent/worktree.d.ts +2 -0
  30. package/dist/agent/worktree.d.ts.map +1 -1
  31. package/dist/agent/worktree.js +26 -0
  32. package/dist/agent/worktree.js.map +1 -1
  33. package/dist/api/agents.d.ts.map +1 -1
  34. package/dist/api/agents.js +4 -0
  35. package/dist/api/agents.js.map +1 -1
  36. package/dist/api/tasks.d.ts.map +1 -1
  37. package/dist/api/tasks.js +8 -0
  38. package/dist/api/tasks.js.map +1 -1
  39. package/dist/cli.d.ts.map +1 -1
  40. package/dist/cli.js +8 -1
  41. package/dist/cli.js.map +1 -1
  42. package/dist/config/loader.d.ts.map +1 -1
  43. package/dist/config/loader.js +3 -0
  44. package/dist/config/loader.js.map +1 -1
  45. package/dist/config/validator.js +23 -0
  46. package/dist/config/validator.js.map +1 -1
  47. package/dist/event/handlers.d.ts.map +1 -1
  48. package/dist/event/handlers.js +33 -451
  49. package/dist/event/handlers.js.map +1 -1
  50. package/dist/event/server-handlers.d.ts +4 -0
  51. package/dist/event/server-handlers.d.ts.map +1 -0
  52. package/dist/event/server-handlers.js +835 -0
  53. package/dist/event/server-handlers.js.map +1 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +5 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/shared/constants.d.ts +7 -1
  58. package/dist/shared/constants.d.ts.map +1 -1
  59. package/dist/shared/constants.js +26 -8
  60. package/dist/shared/constants.js.map +1 -1
  61. package/dist/shared/types.d.ts +64 -2
  62. package/dist/shared/types.d.ts.map +1 -1
  63. package/dist/skills/server-feedback/SKILL.md +34 -0
  64. package/dist/skills/server-recheck/SKILL.md +30 -0
  65. package/dist/skills/server-review/SKILL.md +43 -0
  66. package/dist/skills/server-spec-review/SKILL.md +31 -0
  67. package/dist/state/index.d.ts +1 -0
  68. package/dist/state/index.d.ts.map +1 -1
  69. package/dist/state/index.js +1 -0
  70. package/dist/state/index.js.map +1 -1
  71. package/dist/state/review-store.d.ts +13 -0
  72. package/dist/state/review-store.d.ts.map +1 -0
  73. package/dist/state/review-store.js +92 -0
  74. package/dist/state/review-store.js.map +1 -0
  75. package/dist/state/snapshot.js +1 -1
  76. package/dist/state/snapshot.js.map +1 -1
  77. package/dist/state/task-store.d.ts.map +1 -1
  78. package/dist/state/task-store.js +1 -0
  79. package/dist/state/task-store.js.map +1 -1
  80. package/dist/terminal/attach.d.ts.map +1 -1
  81. package/dist/terminal/attach.js +8 -2
  82. package/dist/terminal/attach.js.map +1 -1
  83. package/dist/web/assets/index-OtgjyQI1.js +4 -0
  84. package/dist/web/index.html +1 -1
  85. package/package.json +1 -1
  86. package/dist/web/assets/index-ByNjLidI.js +0 -4
@@ -0,0 +1,835 @@
1
+ import { DIFF_LARGE_THRESHOLD, MAX_INLINE_CONTENT_BYTES, } from '../shared/index.js';
2
+ import { buildBatches, countLines, splitDiffByFile } from '../agent/diff-split.js';
3
+ import { ReviewExchangeError } from '../agent/review-transport.js';
4
+ // Server review mode event handlers (spec §6). Fully independent from the
5
+ // github-mode handler chain in handlers.ts — control flow here is command-driven
6
+ // (server reads agent machines via runners), not poller-driven.
7
+ // Keep injected diffs inside the 80KB prompt ceiling with headroom for skills
8
+ // and instructions; oversize content truncates with the read-file escape hatch.
9
+ const PROMPT_CONTENT_BYTE_BUDGET = 56 * 1024;
10
+ function truncateUtf8(text, maxBytes) {
11
+ const buf = Buffer.from(text, 'utf8');
12
+ if (buf.byteLength <= maxBytes)
13
+ return { text, truncated: false };
14
+ let cut = maxBytes;
15
+ while (cut > 0 && (buf[cut] & 0xc0) === 0x80)
16
+ cut--;
17
+ return { text: buf.subarray(0, cut).toString('utf8'), truncated: true };
18
+ }
19
+ function aggregateBatchFindings(batches, round) {
20
+ const findings = [];
21
+ const used = new Set();
22
+ for (const [i, batch] of batches.entries()) {
23
+ for (const f of batch.findings) {
24
+ // Recheck rounds restate unresolved findings under their already-namespaced
25
+ // id (b0-f-1); re-prefixing would drift the id every round (b0-b0-f-1).
26
+ let id = /^b\d+-/.test(f.id) ? f.id : `b${i}-${f.id}`;
27
+ // A restated b0-f-1 and a NEW batch-0 f-1 would otherwise collide — one
28
+ // response id would ambiguously cover two findings. Disambiguate with the
29
+ // round (then a counter) so coverage stays one-to-one (PR #288).
30
+ if (used.has(id)) {
31
+ let candidate = `b${i}-r${round}-${f.id}`;
32
+ for (let n = 2; used.has(candidate); n++)
33
+ candidate = `b${i}-r${round}-${f.id}-${n}`;
34
+ id = candidate;
35
+ }
36
+ used.add(id);
37
+ findings.push(id === f.id ? f : { ...f, id });
38
+ }
39
+ }
40
+ const verdict = batches.some(b => b.verdict === 'request-changes') ? 'request-changes' : 'approve';
41
+ return { round, verdict, findings };
42
+ }
43
+ function coverageGaps(findings, responseIds) {
44
+ const findingIds = new Set(findings.findings.map(f => f.id));
45
+ return {
46
+ missing: [...findingIds].filter(id => !responseIds.has(id)),
47
+ unknown: [...responseIds].filter(id => !findingIds.has(id)),
48
+ };
49
+ }
50
+ async function emitIntervention(bus, task, data) {
51
+ try {
52
+ await bus.emit({
53
+ id: '',
54
+ type: 'human.intervention',
55
+ timestamp: new Date().toISOString(),
56
+ projectId: task.projectId,
57
+ agentId: task.agentId,
58
+ taskId: task.id,
59
+ data,
60
+ });
61
+ }
62
+ catch (err) {
63
+ console.warn('[ServerEventHandler] intervention emit failed:', err);
64
+ }
65
+ }
66
+ async function gate(bus, manager, event, expect) {
67
+ if (!event.taskId)
68
+ return null;
69
+ const task = await manager.getTask(event.taskId);
70
+ if (!task)
71
+ return null;
72
+ if (expect.requireServerMode && task.reviewMode !== 'server') {
73
+ console.warn(`[ServerEventHandler] ${event.type} ignored: task ${task.id} not in server mode`);
74
+ return null;
75
+ }
76
+ const token = event.data?.token;
77
+ const phaseOk = expect.phase === 'any'
78
+ || (expect.phase === 'spec' ? task.phase === 'spec' : task.phase !== 'spec');
79
+ if (task.status !== expect.status || !phaseOk || !token || token !== task.signalToken) {
80
+ await emitIntervention(bus, task, {
81
+ phase: `${event.type}-stale`,
82
+ taskStatus: task.status,
83
+ taskPhase: task.phase ?? null,
84
+ });
85
+ return null;
86
+ }
87
+ return { task };
88
+ }
89
+ export function registerServerEventHandlers(bus, manager) {
90
+ const configured = manager.getReviewStore();
91
+ if (!configured) {
92
+ console.warn('[ServerEventHandler] no ReviewStore configured; server review mode disabled');
93
+ return;
94
+ }
95
+ const reviewStore = configured;
96
+ const transport = () => manager.getReviewTransport();
97
+ // Release an agent at a max_rounds pause and clear its TaskState reference —
98
+ // ONLY when actually unbound. A stale reference on this still-active status
99
+ // would let the released agent's later failures sweep the paused task to
100
+ // failed via failTasksForAgent; a failed release keeps the reference so the
101
+ // fault stays attributable (mirrors the GitHub cap paths) (PR #288).
102
+ async function releaseAndClearAtCap(task, agentId, field, opts = {}) {
103
+ const released = await manager.releaseAgentForTask(agentId, task.id, 'idle', opts)
104
+ .catch(() => false);
105
+ if (!released) {
106
+ const state = await manager.getAgentState(agentId);
107
+ if (state && state.taskId === task.id) {
108
+ await emitIntervention(bus, task, {
109
+ phase: 'server-max-rounds-release-failed',
110
+ agentId,
111
+ field,
112
+ });
113
+ return;
114
+ }
115
+ }
116
+ await manager.updateTask(task.id, { [field]: undefined })
117
+ .catch(err => console.error(`[ServerHandler] max_rounds clear ${field}(${task.id}) failed:`, err));
118
+ }
119
+ // Persist a verdict/response save; on failure re-arm the consumed signal so
120
+ // the agent's re-emit retries the whole read→store path (the exchange file is
121
+ // only deleted AFTER a successful store, so the retry re-reads it) (PR #288).
122
+ async function putVerdictRound(task, agentId, kind, data) {
123
+ try {
124
+ await reviewStore.putRound(task.id, data.phase, data);
125
+ return true;
126
+ }
127
+ catch (err) {
128
+ await emitIntervention(bus, task, {
129
+ phase: `server-${data.phase}-verdict-store-failed`,
130
+ round: data.round,
131
+ error: err instanceof Error ? err.message : String(err),
132
+ });
133
+ await manager.setupPhaseSignal(task.id, agentId, kind, { skipSnapshot: true });
134
+ return false;
135
+ }
136
+ }
137
+ // Missing-config guard exits: the re-arm's resolveAgent IS the getAgentConfig
138
+ // that just failed, so arming misses until the config is restored — hold the
139
+ // agent so the stall is explicit (operator restores config, then cancels to retry).
140
+ async function rearmOrHold(task, agentId, kind) {
141
+ const armed = await manager.setupPhaseSignal(task.id, agentId, kind, { skipSnapshot: true });
142
+ if (!armed)
143
+ await manager.holdAgentForUnarmedSignal(task.id, agentId, kind);
144
+ }
145
+ // Shared by code-done (first review) and code-fixed (recheck): read the dev
146
+ // diff, persist the round, size/batch, dispatch QA.
147
+ async function prepareAndDispatchCodeReview(task, opts) {
148
+ const dev = manager.getAgentConfig(task.agentId);
149
+ if (!dev) {
150
+ await emitIntervention(bus, task, { phase: 'server-code-review-no-dev-agent' });
151
+ if (task.agentId) {
152
+ const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
153
+ await rearmOrHold(task, task.agentId, kind);
154
+ }
155
+ return;
156
+ }
157
+ const cap = manager.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
158
+ const nextRound = task.reviewRound + 1;
159
+ if (nextRound > cap) {
160
+ const capResult = await manager.transitionTaskStatus(task.id, 'max_rounds', { fromStatus: ['in_progress', 'fixing'] });
161
+ if (!capResult)
162
+ return;
163
+ const paused = capResult.task;
164
+ // QA was unbound when the fix dispatched but its TaskState reference
165
+ // lingers — clear it so a released agent's later failures can't sweep
166
+ // this paused gate to failed.
167
+ if (paused.qaAgentId)
168
+ await releaseAndClearAtCap(paused, paused.qaAgentId, 'qaAgentId');
169
+ await bus.emit({
170
+ id: '',
171
+ type: 'review.max_rounds',
172
+ timestamp: new Date().toISOString(),
173
+ projectId: paused.projectId,
174
+ agentId: paused.agentId,
175
+ taskId: paused.id,
176
+ data: { round: nextRound, cap },
177
+ });
178
+ return;
179
+ }
180
+ await manager.refreshWorktreeCacheFor(task.agentId);
181
+ // Anchor BEFORE diff: a commit landing between the two reads then shows up
182
+ // in the diff but not the anchor → publish refuses (fail-closed). The
183
+ // reverse order would let an unreviewed commit publish under a matching
184
+ // anchor (PR #288).
185
+ let content;
186
+ let reviewHeadAnchorSha;
187
+ try {
188
+ reviewHeadAnchorSha = await transport().readHeadSha(dev);
189
+ content = await transport().readContent(task, dev, 'code');
190
+ }
191
+ catch (err) {
192
+ await emitIntervention(bus, task, {
193
+ phase: 'server-code-content-read-failed',
194
+ error: err instanceof Error ? err.message : String(err),
195
+ });
196
+ // First review entry consumed code-done; rechecks enter from code-fixed
197
+ // (status 'fixing'). Re-arm whichever signal was consumed so the dev can
198
+ // re-emit after the worktree/read issue is fixed.
199
+ const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
200
+ await manager.setupPhaseSignal(task.id, task.agentId, kind, { skipSnapshot: true });
201
+ return;
202
+ }
203
+ const round = {
204
+ round: nextRound,
205
+ phase: 'code',
206
+ content: content.content,
207
+ ...(content.diffstat ? { diffstat: content.diffstat } : {}),
208
+ ...(content.baseSha ? { baseSha: content.baseSha } : {}),
209
+ startedAt: new Date().toISOString(),
210
+ };
211
+ // The entry signal is already consumed — a persistence failure must re-arm
212
+ // it like content-read failures do, or the dev's re-emit has no consumer.
213
+ const putEntryRound = async (data) => {
214
+ try {
215
+ await reviewStore.putRound(task.id, 'code', data);
216
+ return true;
217
+ }
218
+ catch (err) {
219
+ await emitIntervention(bus, task, {
220
+ phase: 'server-code-round-store-failed',
221
+ error: err instanceof Error ? err.message : String(err),
222
+ });
223
+ const kind = task.status === 'fixing' ? 'code-fixed' : 'code-done';
224
+ await manager.setupPhaseSignal(task.id, task.agentId, kind, { skipSnapshot: true });
225
+ return false;
226
+ }
227
+ };
228
+ const lines = countLines(content.content);
229
+ if (lines > DIFF_LARGE_THRESHOLD) {
230
+ const batches = buildBatches(splitDiffByFile(content.content), DIFF_LARGE_THRESHOLD);
231
+ if (batches.length > 1) {
232
+ round.batchFindings = [];
233
+ if (!(await putEntryRound(round)))
234
+ return;
235
+ await dispatchBatch(task.id, batches, 0, content.diffstat, { ...opts, reviewHeadAnchorSha });
236
+ return;
237
+ }
238
+ }
239
+ if (!(await putEntryRound(round)))
240
+ return;
241
+ const sized = truncateUtf8(content.content, PROMPT_CONTENT_BYTE_BUDGET);
242
+ await manager.dispatchServerReviewToQa(task.id, {
243
+ phase: 'code',
244
+ recheck: opts.recheck,
245
+ content: sized.text,
246
+ reviewHeadAnchorSha,
247
+ ...(content.diffstat ? { diffstat: content.diffstat } : {}),
248
+ ...(sized.truncated ? { contentTruncated: true } : {}),
249
+ ...(opts.priorFindingsJson ? { priorFindingsJson: opts.priorFindingsJson } : {}),
250
+ ...(opts.priorResponseJson ? { priorResponseJson: opts.priorResponseJson } : {}),
251
+ });
252
+ }
253
+ async function dispatchBatch(taskId, batches, index, diffstat, opts) {
254
+ const text = batches[index].map(f => f.text).join('\n');
255
+ const sized = truncateUtf8(text, PROMPT_CONTENT_BYTE_BUDGET);
256
+ await manager.dispatchServerReviewToQa(taskId, {
257
+ phase: 'code',
258
+ recheck: opts.recheck,
259
+ continuation: index > 0,
260
+ content: sized.text,
261
+ ...(diffstat ? { diffstat } : {}),
262
+ ...(sized.truncated ? { contentTruncated: true } : {}),
263
+ batch: { index, total: batches.length },
264
+ // Only the round-opening slice pins the anchor; continuations are the same round.
265
+ ...(index === 0 && opts.reviewHeadAnchorSha ? { reviewHeadAnchorSha: opts.reviewHeadAnchorSha } : {}),
266
+ ...(opts.priorFindingsJson ? { priorFindingsJson: opts.priorFindingsJson } : {}),
267
+ ...(opts.priorResponseJson ? { priorResponseJson: opts.priorResponseJson } : {}),
268
+ });
269
+ }
270
+ // Re-slice the stored round content for batch continuation after restart or
271
+ // between batches — batches are deterministic for a given content + threshold.
272
+ function rebuildBatches(roundData) {
273
+ return buildBatches(splitDiffByFile(roundData.content), DIFF_LARGE_THRESHOLD);
274
+ }
275
+ bus.on('server.code.ready', async (event) => {
276
+ const gated = await gate(bus, manager, event, { status: 'in_progress', phase: 'code', requireServerMode: true });
277
+ if (!gated)
278
+ return;
279
+ await prepareAndDispatchCodeReview(gated.task, { recheck: false });
280
+ });
281
+ bus.on('server.code.review.submitted', async (event) => {
282
+ const gated = await gate(bus, manager, event, { status: 'review', phase: 'code', requireServerMode: true });
283
+ if (!gated)
284
+ return;
285
+ const { task } = gated;
286
+ const qa = manager.getAgentConfig(task.qaAgentId ?? '');
287
+ if (!qa) {
288
+ await emitIntervention(bus, task, { phase: 'server-code-review-no-qa-agent' });
289
+ if (task.qaAgentId) {
290
+ await rearmOrHold(task, task.qaAgentId, 'code-reviewed');
291
+ }
292
+ return;
293
+ }
294
+ const round = Math.max(task.reviewRound, 1);
295
+ const roundData = await reviewStore.getRound(task.id, 'code', round);
296
+ if (!roundData) {
297
+ await emitIntervention(bus, task, { phase: 'server-code-review-round-missing', round });
298
+ await manager.setupPhaseSignal(task.id, qa.id, 'code-reviewed', { skipSnapshot: true });
299
+ return;
300
+ }
301
+ await manager.refreshWorktreeCacheFor(qa.id);
302
+ let findings;
303
+ try {
304
+ findings = await transport().readFindings(task, qa);
305
+ }
306
+ catch (err) {
307
+ const reason = err instanceof ReviewExchangeError ? err.reason : 'unknown';
308
+ await emitIntervention(bus, task, {
309
+ phase: 'server-code-findings-invalid',
310
+ reason,
311
+ error: err instanceof Error ? err.message : String(err),
312
+ });
313
+ // The one-shot watcher consumed the signal; re-arm with the SAME token so
314
+ // QA can fix the file and re-emit instead of stranding the review (PR #288).
315
+ await manager.setupPhaseSignal(task.id, qa.id, 'code-reviewed', { skipSnapshot: true });
316
+ return;
317
+ }
318
+ // findings === null with data already stored = crash-replay after the file
319
+ // was deleted but before the flow advanced; fall through with the STORED
320
+ // data so the continuation resumes instead of stranding the task (PR #288).
321
+ let current = roundData;
322
+ if (findings === null) {
323
+ const alreadyStored = task.batchTotal !== undefined
324
+ ? (roundData.batchFindings?.length ?? 0) > (task.batchIndex ?? 0)
325
+ : roundData.findings !== undefined;
326
+ if (!alreadyStored) {
327
+ await emitIntervention(bus, task, { phase: 'server-code-findings-missing', round });
328
+ await manager.setupPhaseSignal(task.id, qa.id, 'code-reviewed', { skipSnapshot: true });
329
+ return;
330
+ }
331
+ }
332
+ else if (findings.round !== round) {
333
+ // Stale exchange file from an old round (delete failed / agent reuse) —
334
+ // never route an old verdict as the current round's.
335
+ await emitIntervention(bus, task, {
336
+ phase: 'server-code-findings-round-mismatch',
337
+ round,
338
+ payloadRound: findings.round,
339
+ });
340
+ await transport().deleteFindings(qa);
341
+ await manager.setupPhaseSignal(task.id, qa.id, 'code-reviewed', { skipSnapshot: true });
342
+ return;
343
+ }
344
+ else if (task.batchTotal !== undefined && task.batchIndex !== undefined) {
345
+ const batchFindings = [...(roundData.batchFindings ?? [])];
346
+ batchFindings[task.batchIndex] = findings;
347
+ current = { ...roundData, batchFindings };
348
+ if (!(await putVerdictRound(task, qa.id, 'code-reviewed', current)))
349
+ return;
350
+ await transport().deleteFindings(qa);
351
+ }
352
+ else {
353
+ current = { ...roundData, findings, completedAt: new Date().toISOString() };
354
+ if (!(await putVerdictRound(task, qa.id, 'code-reviewed', current)))
355
+ return;
356
+ await transport().deleteFindings(qa);
357
+ }
358
+ // Recheck rounds are every round after the first; survives crash-replay
359
+ // (the prior hardcoded recheck:false lost this on batch continuation).
360
+ const isRecheck = round > 1;
361
+ if (task.batchTotal !== undefined && task.batchIndex !== undefined) {
362
+ const nextIndex = task.batchIndex + 1;
363
+ if (nextIndex < task.batchTotal) {
364
+ // Recheck continuation batches still need the prior round's findings and
365
+ // response — without them QA can't verify closure for this slice's files.
366
+ const prev = isRecheck ? await reviewStore.getRound(task.id, 'code', round - 1) : null;
367
+ await dispatchBatch(task.id, rebuildBatches(current), nextIndex, current.diffstat, {
368
+ recheck: isRecheck,
369
+ ...(prev?.findings ? { priorFindingsJson: JSON.stringify(prev.findings) } : {}),
370
+ ...(prev?.response ? { priorResponseJson: JSON.stringify(prev.response) } : {}),
371
+ });
372
+ return;
373
+ }
374
+ const aggregated = current.findings
375
+ ?? aggregateBatchFindings((current.batchFindings ?? []).filter(Boolean), round);
376
+ if (!current.findings) {
377
+ const stored = await putVerdictRound(task, qa.id, 'code-reviewed', {
378
+ ...current,
379
+ findings: aggregated,
380
+ completedAt: new Date().toISOString(),
381
+ });
382
+ if (!stored)
383
+ return;
384
+ }
385
+ await manager.updateTask(task.id, { batchIndex: undefined, batchTotal: undefined });
386
+ await routeCodeVerdict({ ...task, batchIndex: undefined, batchTotal: undefined }, aggregated);
387
+ return;
388
+ }
389
+ await routeCodeVerdict(task, current.findings);
390
+ });
391
+ async function routeCodeVerdict(task, findings) {
392
+ if (findings.verdict === 'approve') {
393
+ // Snapshot afterDone the moment the verdict routes it — confirm must not
394
+ // read live config a hot-reload may have flipped mid-gate. On crash-replay
395
+ // the EXISTING snapshot wins (resolveAfterDone) so the verdict-time
396
+ // decision stays stable across restarts (PR #288).
397
+ const afterDone = manager.resolveAfterDone(task);
398
+ if (task.afterDone === undefined) {
399
+ await manager.updateTask(task.id, { afterDone });
400
+ }
401
+ if (afterDone === null) {
402
+ const ready = await manager.transitionTaskStatus(task.id, 'ready', { fromStatus: ['review'] });
403
+ if (!ready)
404
+ await emitIntervention(bus, task, { phase: 'server-code-ready-transition-failed' });
405
+ return;
406
+ }
407
+ const approved = await manager.transitionTaskStatus(task.id, 'approved', { fromStatus: ['review'] });
408
+ if (!approved) {
409
+ await emitIntervention(bus, task, { phase: 'server-code-approved-transition-failed' });
410
+ return;
411
+ }
412
+ await manager.dispatchServerAfterDone(task.id, afterDone);
413
+ return;
414
+ }
415
+ // Cap check at VERDICT time, like the GitHub handler: dispatching a fix in
416
+ // the final round would let dev change code that QA never rechecks before
417
+ // max_rounds complete can merge it (PR #288).
418
+ const cap = manager.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
419
+ if (task.reviewRound >= cap) {
420
+ const capResult = await manager.transitionTaskStatus(task.id, 'max_rounds', { fromStatus: ['review'] });
421
+ if (!capResult)
422
+ return;
423
+ const paused = capResult.task;
424
+ // QA is bound at verdict time and the verdict arriving IS its turn
425
+ // completing, so a Held QA must still be releasable; dev stays reserved
426
+ // for Continue/Complete.
427
+ if (paused.qaAgentId) {
428
+ await releaseAndClearAtCap(paused, paused.qaAgentId, 'qaAgentId', { allowAwaitingHuman: true });
429
+ }
430
+ await bus.emit({
431
+ id: '',
432
+ type: 'review.max_rounds',
433
+ timestamp: new Date().toISOString(),
434
+ projectId: paused.projectId,
435
+ agentId: paused.agentId,
436
+ taskId: paused.id,
437
+ data: { round: paused.reviewRound, cap },
438
+ });
439
+ return;
440
+ }
441
+ await manager.dispatchServerFixToDev(task.id, JSON.stringify(findings));
442
+ }
443
+ bus.on('server.code.fix.submitted', async (event) => {
444
+ const gated = await gate(bus, manager, event, { status: 'fixing', phase: 'code', requireServerMode: true });
445
+ if (!gated)
446
+ return;
447
+ const { task } = gated;
448
+ const dev = manager.getAgentConfig(task.agentId);
449
+ if (!dev) {
450
+ await emitIntervention(bus, task, { phase: 'server-code-fix-no-dev-agent' });
451
+ if (task.agentId) {
452
+ await rearmOrHold(task, task.agentId, 'code-fixed');
453
+ }
454
+ return;
455
+ }
456
+ const round = Math.max(task.reviewRound, 1);
457
+ const roundData = await reviewStore.getRound(task.id, 'code', round);
458
+ if (!roundData?.findings) {
459
+ await emitIntervention(bus, task, { phase: 'server-code-fix-findings-missing', round });
460
+ await manager.setupPhaseSignal(task.id, task.agentId, 'code-fixed', { skipSnapshot: true });
461
+ return;
462
+ }
463
+ await manager.refreshWorktreeCacheFor(task.agentId);
464
+ let response;
465
+ try {
466
+ response = await transport().readResponse(task, dev);
467
+ }
468
+ catch (err) {
469
+ await emitIntervention(bus, task, {
470
+ phase: 'server-code-response-invalid',
471
+ error: err instanceof Error ? err.message : String(err),
472
+ });
473
+ await manager.setupPhaseSignal(task.id, dev.id, 'code-fixed', { skipSnapshot: true });
474
+ return;
475
+ }
476
+ if (response === null) {
477
+ if (roundData.response === undefined) {
478
+ await emitIntervention(bus, task, { phase: 'server-code-response-missing', round });
479
+ await manager.setupPhaseSignal(task.id, dev.id, 'code-fixed', { skipSnapshot: true });
480
+ return;
481
+ }
482
+ // Crash-replay after delete: response is stored — resume the recheck dispatch.
483
+ await prepareAndDispatchCodeReview(task, {
484
+ recheck: true,
485
+ priorFindingsJson: JSON.stringify(roundData.findings),
486
+ priorResponseJson: JSON.stringify(roundData.response),
487
+ });
488
+ return;
489
+ }
490
+ if (response.round !== round) {
491
+ await emitIntervention(bus, task, {
492
+ phase: 'server-code-response-round-mismatch',
493
+ round,
494
+ payloadRound: response.round,
495
+ });
496
+ await transport().deleteResponse(dev);
497
+ await manager.setupPhaseSignal(task.id, dev.id, 'code-fixed', { skipSnapshot: true });
498
+ return;
499
+ }
500
+ // Fail-closed coverage: every finding id must have exactly one response item,
501
+ // and no response may reference an id QA never raised (hallucinated f99).
502
+ const gaps = coverageGaps(roundData.findings, new Set(response.responses.map(r => r.findingId)));
503
+ if (gaps.missing.length > 0 || gaps.unknown.length > 0) {
504
+ await emitIntervention(bus, task, {
505
+ phase: 'server-code-response-coverage-gap',
506
+ round,
507
+ missingFindingIds: gaps.missing,
508
+ unknownFindingIds: gaps.unknown,
509
+ });
510
+ await manager.setupPhaseSignal(task.id, dev.id, 'code-fixed', { skipSnapshot: true });
511
+ return;
512
+ }
513
+ if (!(await putVerdictRound(task, dev.id, 'code-fixed', { ...roundData, response })))
514
+ return;
515
+ await transport().deleteResponse(dev);
516
+ await prepareAndDispatchCodeReview(task, {
517
+ recheck: true,
518
+ priorFindingsJson: JSON.stringify(roundData.findings),
519
+ priorResponseJson: JSON.stringify(response),
520
+ });
521
+ });
522
+ bus.on('server.code.published', async (event) => {
523
+ const gated = await gate(bus, manager, event, { status: 'approved', phase: 'code', requireServerMode: true });
524
+ if (!gated)
525
+ return;
526
+ const { task } = gated;
527
+ const prNumber = typeof event.data?.prNumber === 'number' ? event.data.prNumber : undefined;
528
+ if (prNumber === undefined && manager.resolveAfterDone(task) === 'pr' && task.prNumber === undefined) {
529
+ // afterDone:'pr' without a PR number would sail through confirm as plain
530
+ // 'done', leaving the created PR unmerged. Make dev re-emit with the number.
531
+ await emitIntervention(bus, task, { phase: 'server-code-published-missing-pr-number' });
532
+ await manager.setupPhaseSignal(task.id, task.agentId, 'code-ready', { skipSnapshot: true });
533
+ return;
534
+ }
535
+ if (prNumber !== undefined) {
536
+ // Same trust model as pane pr-created: never act on an agent-reported PR
537
+ // number without confirming its head branch is OURS (typo/hallucination
538
+ // would point Confirm/Cancel at someone else's PR) (PR #288).
539
+ const verified = await manager.verifyPaneSignalPrNumber(task.id, prNumber);
540
+ if (!verified) {
541
+ await emitIntervention(bus, task, {
542
+ phase: 'server-code-published-pr-number-unverified',
543
+ prNumber,
544
+ });
545
+ await manager.setupPhaseSignal(task.id, task.agentId, 'code-ready', { skipSnapshot: true });
546
+ return;
547
+ }
548
+ await manager.updateTask(task.id, { prNumber });
549
+ }
550
+ // Reviewed-head capture for BOTH pr and branch publishes — the confirm-time
551
+ // merge guard depends on it, so a capture failure must NOT reach ready
552
+ // (fail-open would re-enable blind merges of post-gate pushes).
553
+ const dev = manager.getAgentConfig(task.agentId);
554
+ if (!dev) {
555
+ await emitIntervention(bus, task, { phase: 'server-code-published-no-dev-agent' });
556
+ return;
557
+ }
558
+ await manager.refreshWorktreeCacheFor(task.agentId);
559
+ let publishedHead;
560
+ try {
561
+ publishedHead = await transport().readHeadSha(dev);
562
+ }
563
+ catch (err) {
564
+ await emitIntervention(bus, task, {
565
+ phase: 'server-code-published-head-capture-failed',
566
+ error: err instanceof Error ? err.message : String(err),
567
+ });
568
+ await manager.setupPhaseSignal(task.id, task.agentId, 'code-ready', { skipSnapshot: true });
569
+ return;
570
+ }
571
+ // QA reviewed the diff pinned at dispatch (reviewHeadAnchorSha); a publish
572
+ // from any other head carries commits no server review ever saw. No re-arm:
573
+ // the publish already happened — the exits are Cancel (retract the
574
+ // PR/branch) then Retry for a fresh review.
575
+ if (publishedHead !== task.reviewHeadAnchorSha) {
576
+ await emitIntervention(bus, task, {
577
+ phase: 'server-code-published-head-mismatch',
578
+ publishedHead,
579
+ reviewedHead: task.reviewHeadAnchorSha ?? null,
580
+ });
581
+ return;
582
+ }
583
+ try {
584
+ await manager.updateTask(task.id, { latestHeadSha: publishedHead });
585
+ }
586
+ catch (err) {
587
+ await emitIntervention(bus, task, {
588
+ phase: 'server-code-published-head-capture-failed',
589
+ error: err instanceof Error ? err.message : String(err),
590
+ });
591
+ await manager.setupPhaseSignal(task.id, task.agentId, 'code-ready', { skipSnapshot: true });
592
+ return;
593
+ }
594
+ const ready = await manager.transitionTaskStatus(task.id, 'ready', { fromStatus: ['approved'] });
595
+ if (!ready)
596
+ await emitIntervention(bus, task, { phase: 'server-code-published-transition-failed' });
597
+ });
598
+ bus.on('server.spec.ready', async (event) => {
599
+ const gated = await gate(bus, manager, event, { status: 'in_progress', phase: 'any', requireServerMode: false });
600
+ if (!gated)
601
+ return;
602
+ await dispatchSpecReview(gated.task);
603
+ });
604
+ async function dispatchSpecReview(task, prior) {
605
+ const dev = manager.getAgentConfig(task.agentId);
606
+ if (!dev) {
607
+ await emitIntervention(bus, task, { phase: 'server-spec-review-no-dev-agent' });
608
+ if (task.agentId) {
609
+ const kind = task.status === 'fixing' ? 'spec-fixed' : 'spec-done';
610
+ await rearmOrHold(task, task.agentId, kind);
611
+ }
612
+ return;
613
+ }
614
+ const cap = manager.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
615
+ const nextRound = (task.specReviewRound ?? 0) + 1;
616
+ if (nextRound > cap) {
617
+ const capResult = await manager.transitionTaskStatus(task.id, 'max_rounds', { fromStatus: ['in_progress', 'fixing'] });
618
+ if (!capResult)
619
+ return;
620
+ const paused = capResult.task;
621
+ // Spec-phase max_rounds has no Continue/Complete — Retry/Cancel only.
622
+ // Release BOTH agents and clear their references (GitHub spec-cap parity).
623
+ if (paused.qaAgentId)
624
+ await releaseAndClearAtCap(paused, paused.qaAgentId, 'qaAgentId');
625
+ if (paused.agentId)
626
+ await releaseAndClearAtCap(paused, paused.agentId, 'agentId');
627
+ return;
628
+ }
629
+ await manager.refreshWorktreeCacheFor(task.agentId);
630
+ let content;
631
+ try {
632
+ content = await transport().readContent(task, dev, 'spec');
633
+ }
634
+ catch (err) {
635
+ await emitIntervention(bus, task, {
636
+ phase: 'server-spec-content-read-failed',
637
+ error: err instanceof Error ? err.message : String(err),
638
+ });
639
+ const kind = task.status === 'fixing' ? 'spec-fixed' : 'spec-done';
640
+ await manager.setupPhaseSignal(task.id, task.agentId, kind, { skipSnapshot: true });
641
+ return;
642
+ }
643
+ try {
644
+ await reviewStore.putRound(task.id, 'spec', {
645
+ round: nextRound,
646
+ phase: 'spec',
647
+ content: content.content,
648
+ startedAt: new Date().toISOString(),
649
+ });
650
+ }
651
+ catch (err) {
652
+ await emitIntervention(bus, task, {
653
+ phase: 'server-spec-round-store-failed',
654
+ error: err instanceof Error ? err.message : String(err),
655
+ });
656
+ const kind = task.status === 'fixing' ? 'spec-fixed' : 'spec-done';
657
+ await manager.setupPhaseSignal(task.id, task.agentId, kind, { skipSnapshot: true });
658
+ return;
659
+ }
660
+ const sized = truncateUtf8(content.content, MAX_INLINE_CONTENT_BYTES);
661
+ await manager.dispatchServerReviewToQa(task.id, {
662
+ phase: 'spec',
663
+ content: sized.text,
664
+ ...(sized.truncated ? { contentTruncated: true } : {}),
665
+ ...(prior ? { priorFindingsJson: prior.findingsJson, priorResponseJson: prior.responseJson } : {}),
666
+ });
667
+ }
668
+ bus.on('server.spec.review.submitted', async (event) => {
669
+ const gated = await gate(bus, manager, event, { status: 'review', phase: 'spec', requireServerMode: false });
670
+ if (!gated)
671
+ return;
672
+ const { task } = gated;
673
+ const qa = manager.getAgentConfig(task.qaAgentId ?? '');
674
+ if (!qa) {
675
+ await emitIntervention(bus, task, { phase: 'server-spec-review-no-qa-agent' });
676
+ if (task.qaAgentId) {
677
+ await rearmOrHold(task, task.qaAgentId, 'spec-reviewed');
678
+ }
679
+ return;
680
+ }
681
+ const round = task.specReviewRound ?? 1;
682
+ const roundData = await reviewStore.getRound(task.id, 'spec', round);
683
+ if (!roundData) {
684
+ await emitIntervention(bus, task, { phase: 'server-spec-review-round-missing', round });
685
+ await manager.setupPhaseSignal(task.id, qa.id, 'spec-reviewed', { skipSnapshot: true });
686
+ return;
687
+ }
688
+ await manager.refreshWorktreeCacheFor(qa.id);
689
+ let findings;
690
+ try {
691
+ findings = await transport().readFindings(task, qa);
692
+ }
693
+ catch (err) {
694
+ await emitIntervention(bus, task, {
695
+ phase: 'server-spec-findings-invalid',
696
+ error: err instanceof Error ? err.message : String(err),
697
+ });
698
+ await manager.setupPhaseSignal(task.id, qa.id, 'spec-reviewed', { skipSnapshot: true });
699
+ return;
700
+ }
701
+ // Crash-replay after delete: route on the STORED findings (PR #288).
702
+ let effective = findings;
703
+ if (effective === null) {
704
+ if (roundData.findings === undefined) {
705
+ await emitIntervention(bus, task, { phase: 'server-spec-findings-missing', round });
706
+ await manager.setupPhaseSignal(task.id, qa.id, 'spec-reviewed', { skipSnapshot: true });
707
+ return;
708
+ }
709
+ effective = roundData.findings;
710
+ }
711
+ else if (effective.round !== round) {
712
+ await emitIntervention(bus, task, {
713
+ phase: 'server-spec-findings-round-mismatch',
714
+ round,
715
+ payloadRound: effective.round,
716
+ });
717
+ await transport().deleteFindings(qa);
718
+ await manager.setupPhaseSignal(task.id, qa.id, 'spec-reviewed', { skipSnapshot: true });
719
+ return;
720
+ }
721
+ else {
722
+ const stored = await putVerdictRound(task, qa.id, 'spec-reviewed', {
723
+ ...roundData,
724
+ findings: effective,
725
+ completedAt: new Date().toISOString(),
726
+ });
727
+ if (!stored)
728
+ return;
729
+ await transport().deleteFindings(qa);
730
+ }
731
+ if (effective.verdict === 'approve') {
732
+ try {
733
+ await manager.transitionToCodePhase(task.id);
734
+ }
735
+ catch (err) {
736
+ await emitIntervention(bus, task, {
737
+ phase: 'server-spec-approve-transition-failed',
738
+ error: err instanceof Error ? err.message : String(err),
739
+ });
740
+ }
741
+ return;
742
+ }
743
+ // Cap at verdict time (mirrors the code path): a final-round fix would
744
+ // never be rechecked before the spec cap pauses the task.
745
+ const cap = manager.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
746
+ if ((task.specReviewRound ?? 0) >= cap) {
747
+ const capResult = await manager.transitionTaskStatus(task.id, 'max_rounds', { fromStatus: ['review'] });
748
+ if (!capResult)
749
+ return;
750
+ const paused = capResult.task;
751
+ if (paused.qaAgentId)
752
+ await releaseAndClearAtCap(paused, paused.qaAgentId, 'qaAgentId');
753
+ if (paused.agentId)
754
+ await releaseAndClearAtCap(paused, paused.agentId, 'agentId');
755
+ return;
756
+ }
757
+ await manager.dispatchServerFixToDev(task.id, JSON.stringify(effective));
758
+ });
759
+ bus.on('server.spec.fix.submitted', async (event) => {
760
+ const gated = await gate(bus, manager, event, { status: 'fixing', phase: 'spec', requireServerMode: false });
761
+ if (!gated)
762
+ return;
763
+ const { task } = gated;
764
+ const dev = manager.getAgentConfig(task.agentId);
765
+ if (!dev) {
766
+ await emitIntervention(bus, task, { phase: 'server-spec-fix-no-dev-agent' });
767
+ if (task.agentId) {
768
+ await rearmOrHold(task, task.agentId, 'spec-fixed');
769
+ }
770
+ return;
771
+ }
772
+ const round = task.specReviewRound ?? 1;
773
+ const roundData = await reviewStore.getRound(task.id, 'spec', round);
774
+ if (!roundData?.findings) {
775
+ await emitIntervention(bus, task, { phase: 'server-spec-fix-findings-missing', round });
776
+ await manager.setupPhaseSignal(task.id, task.agentId, 'spec-fixed', { skipSnapshot: true });
777
+ return;
778
+ }
779
+ await manager.refreshWorktreeCacheFor(task.agentId);
780
+ let response;
781
+ try {
782
+ response = await transport().readResponse(task, dev);
783
+ }
784
+ catch (err) {
785
+ await emitIntervention(bus, task, {
786
+ phase: 'server-spec-response-invalid',
787
+ error: err instanceof Error ? err.message : String(err),
788
+ });
789
+ await manager.setupPhaseSignal(task.id, dev.id, 'spec-fixed', { skipSnapshot: true });
790
+ return;
791
+ }
792
+ if (response === null) {
793
+ if (roundData.response === undefined) {
794
+ await emitIntervention(bus, task, { phase: 'server-spec-response-missing', round });
795
+ await manager.setupPhaseSignal(task.id, dev.id, 'spec-fixed', { skipSnapshot: true });
796
+ return;
797
+ }
798
+ // Crash-replay after delete: response is stored — resume the recheck dispatch.
799
+ await dispatchSpecReview(task, {
800
+ findingsJson: JSON.stringify(roundData.findings),
801
+ responseJson: JSON.stringify(roundData.response),
802
+ });
803
+ return;
804
+ }
805
+ if (response.round !== round) {
806
+ await emitIntervention(bus, task, {
807
+ phase: 'server-spec-response-round-mismatch',
808
+ round,
809
+ payloadRound: response.round,
810
+ });
811
+ await transport().deleteResponse(dev);
812
+ await manager.setupPhaseSignal(task.id, dev.id, 'spec-fixed', { skipSnapshot: true });
813
+ return;
814
+ }
815
+ const gaps = coverageGaps(roundData.findings, new Set(response.responses.map(r => r.findingId)));
816
+ if (gaps.missing.length > 0 || gaps.unknown.length > 0) {
817
+ await emitIntervention(bus, task, {
818
+ phase: 'server-spec-response-coverage-gap',
819
+ round,
820
+ missingFindingIds: gaps.missing,
821
+ unknownFindingIds: gaps.unknown,
822
+ });
823
+ await manager.setupPhaseSignal(task.id, dev.id, 'spec-fixed', { skipSnapshot: true });
824
+ return;
825
+ }
826
+ if (!(await putVerdictRound(task, dev.id, 'spec-fixed', { ...roundData, response })))
827
+ return;
828
+ await transport().deleteResponse(dev);
829
+ await dispatchSpecReview(task, {
830
+ findingsJson: JSON.stringify(roundData.findings),
831
+ responseJson: JSON.stringify(response),
832
+ });
833
+ });
834
+ }
835
+ //# sourceMappingURL=server-handlers.js.map