baxian 0.0.1 → 0.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.
- package/LICENSE +201 -0
- package/README.md +19 -1
- package/dist/agent/bootstrap-poller.d.ts +34 -0
- package/dist/agent/bootstrap-poller.d.ts.map +1 -0
- package/dist/agent/bootstrap-poller.js +93 -0
- package/dist/agent/bootstrap-poller.js.map +1 -0
- package/dist/agent/bootstrap.d.ts +39 -0
- package/dist/agent/bootstrap.d.ts.map +1 -0
- package/dist/agent/bootstrap.js +214 -0
- package/dist/agent/bootstrap.js.map +1 -0
- package/dist/agent/index.d.ts +8 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +8 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/manager.d.ts +280 -0
- package/dist/agent/manager.d.ts.map +1 -0
- package/dist/agent/manager.js +3666 -0
- package/dist/agent/manager.js.map +1 -0
- package/dist/agent/marker-protocol.d.ts +12 -0
- package/dist/agent/marker-protocol.d.ts.map +1 -0
- package/dist/agent/marker-protocol.js +52 -0
- package/dist/agent/marker-protocol.js.map +1 -0
- package/dist/agent/pane-streamer-manager.d.ts +24 -0
- package/dist/agent/pane-streamer-manager.d.ts.map +1 -0
- package/dist/agent/pane-streamer-manager.js +107 -0
- package/dist/agent/pane-streamer-manager.js.map +1 -0
- package/dist/agent/pane-streamer.d.ts +97 -0
- package/dist/agent/pane-streamer.d.ts.map +1 -0
- package/dist/agent/pane-streamer.js +382 -0
- package/dist/agent/pane-streamer.js.map +1 -0
- package/dist/agent/post-approve-marker-watcher.d.ts +29 -0
- package/dist/agent/post-approve-marker-watcher.d.ts.map +1 -0
- package/dist/agent/post-approve-marker-watcher.js +160 -0
- package/dist/agent/post-approve-marker-watcher.js.map +1 -0
- package/dist/agent/preflight.d.ts +9 -0
- package/dist/agent/preflight.d.ts.map +1 -0
- package/dist/agent/preflight.js +164 -0
- package/dist/agent/preflight.js.map +1 -0
- package/dist/agent/prompt.d.ts +44 -0
- package/dist/agent/prompt.d.ts.map +1 -0
- package/dist/agent/prompt.js +252 -0
- package/dist/agent/prompt.js.map +1 -0
- package/dist/agent/repo-store.d.ts +27 -0
- package/dist/agent/repo-store.d.ts.map +1 -0
- package/dist/agent/repo-store.js +152 -0
- package/dist/agent/repo-store.js.map +1 -0
- package/dist/agent/runner.d.ts +46 -0
- package/dist/agent/runner.d.ts.map +1 -0
- package/dist/agent/runner.js +241 -0
- package/dist/agent/runner.js.map +1 -0
- package/dist/agent/spec-review-marker-watcher.d.ts +33 -0
- package/dist/agent/spec-review-marker-watcher.d.ts.map +1 -0
- package/dist/agent/spec-review-marker-watcher.js +180 -0
- package/dist/agent/spec-review-marker-watcher.js.map +1 -0
- package/dist/agent/tmux-probe-poller.d.ts +78 -0
- package/dist/agent/tmux-probe-poller.d.ts.map +1 -0
- package/dist/agent/tmux-probe-poller.js +418 -0
- package/dist/agent/tmux-probe-poller.js.map +1 -0
- package/dist/agent/tmux.d.ts +78 -0
- package/dist/agent/tmux.d.ts.map +1 -0
- package/dist/agent/tmux.js +395 -0
- package/dist/agent/tmux.js.map +1 -0
- package/dist/agent/worktree.d.ts +10 -0
- package/dist/agent/worktree.d.ts.map +1 -0
- package/dist/agent/worktree.js +41 -0
- package/dist/agent/worktree.js.map +1 -0
- package/dist/api/agents.d.ts +3 -0
- package/dist/api/agents.d.ts.map +1 -0
- package/dist/api/agents.js +29 -0
- package/dist/api/agents.js.map +1 -0
- package/dist/api/config.d.ts +5 -0
- package/dist/api/config.d.ts.map +1 -0
- package/dist/api/config.js +114 -0
- package/dist/api/config.js.map +1 -0
- package/dist/api/events.d.ts +3 -0
- package/dist/api/events.d.ts.map +1 -0
- package/dist/api/events.js +11 -0
- package/dist/api/events.js.map +1 -0
- package/dist/api/pollers.d.ts +3 -0
- package/dist/api/pollers.d.ts.map +1 -0
- package/dist/api/pollers.js +4 -0
- package/dist/api/pollers.js.map +1 -0
- package/dist/api/probe.d.ts +9 -0
- package/dist/api/probe.d.ts.map +1 -0
- package/dist/api/probe.js +87 -0
- package/dist/api/probe.js.map +1 -0
- package/dist/api/projects.d.ts +3 -0
- package/dist/api/projects.d.ts.map +1 -0
- package/dist/api/projects.js +602 -0
- package/dist/api/projects.js.map +1 -0
- package/dist/api/restart.d.ts +3 -0
- package/dist/api/restart.d.ts.map +1 -0
- package/dist/api/restart.js +20 -0
- package/dist/api/restart.js.map +1 -0
- package/dist/api/tasks.d.ts +3 -0
- package/dist/api/tasks.d.ts.map +1 -0
- package/dist/api/tasks.js +136 -0
- package/dist/api/tasks.js.map +1 -0
- package/dist/app.d.ts +51 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +169 -0
- package/dist/app.js.map +1 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +319 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/backup.d.ts +3 -0
- package/dist/config/backup.d.ts.map +1 -0
- package/dist/config/backup.js +44 -0
- package/dist/config/backup.js.map +1 -0
- package/dist/config/index.d.ts +5 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +5 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/loader.d.ts +42 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +197 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/mutex.d.ts +2 -0
- package/dist/config/mutex.d.ts.map +1 -0
- package/dist/config/mutex.js +7 -0
- package/dist/config/mutex.js.map +1 -0
- package/dist/config/normalizer.d.ts +2 -0
- package/dist/config/normalizer.d.ts.map +1 -0
- package/dist/config/normalizer.js +42 -0
- package/dist/config/normalizer.js.map +1 -0
- package/dist/config/validator.d.ts +7 -0
- package/dist/config/validator.d.ts.map +1 -0
- package/dist/config/validator.js +278 -0
- package/dist/config/validator.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -0
- package/dist/event/broker.d.ts +11 -0
- package/dist/event/broker.d.ts.map +1 -0
- package/dist/event/broker.js +47 -0
- package/dist/event/broker.js.map +1 -0
- package/dist/event/bus.d.ts +12 -0
- package/dist/event/bus.d.ts.map +1 -0
- package/dist/event/bus.js +35 -0
- package/dist/event/bus.js.map +1 -0
- package/dist/event/handlers.d.ts +6 -0
- package/dist/event/handlers.d.ts.map +1 -0
- package/dist/event/handlers.js +1121 -0
- package/dist/event/handlers.js.map +1 -0
- package/dist/event/index.d.ts +4 -0
- package/dist/event/index.d.ts.map +1 -0
- package/dist/event/index.js +4 -0
- package/dist/event/index.js.map +1 -0
- package/dist/event/log.d.ts +9 -0
- package/dist/event/log.d.ts.map +1 -0
- package/dist/event/log.js +43 -0
- package/dist/event/log.js.map +1 -0
- package/dist/event/publish.d.ts +37 -0
- package/dist/event/publish.d.ts.map +1 -0
- package/dist/event/publish.js +197 -0
- package/dist/event/publish.js.map +1 -0
- package/dist/event/ws.d.ts +3 -0
- package/dist/event/ws.d.ts.map +1 -0
- package/dist/event/ws.js +169 -0
- package/dist/event/ws.js.map +1 -0
- package/dist/github/index.d.ts +4 -0
- package/dist/github/index.d.ts.map +1 -0
- package/dist/github/index.js +3 -0
- package/dist/github/index.js.map +1 -0
- package/dist/github/mapper.d.ts +51 -0
- package/dist/github/mapper.d.ts.map +1 -0
- package/dist/github/mapper.js +191 -0
- package/dist/github/mapper.js.map +1 -0
- package/dist/github/poller.d.ts +64 -0
- package/dist/github/poller.d.ts.map +1 -0
- package/dist/github/poller.js +513 -0
- package/dist/github/poller.js.map +1 -0
- package/dist/github/resolver.d.ts +8 -0
- package/dist/github/resolver.d.ts.map +1 -0
- package/dist/github/resolver.js +24 -0
- package/dist/github/resolver.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +310 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle/restart-sentinel.d.ts +18 -0
- package/dist/lifecycle/restart-sentinel.d.ts.map +1 -0
- package/dist/lifecycle/restart-sentinel.js +61 -0
- package/dist/lifecycle/restart-sentinel.js.map +1 -0
- package/dist/lifecycle/restart.d.ts +21 -0
- package/dist/lifecycle/restart.d.ts.map +1 -0
- package/dist/lifecycle/restart.js +57 -0
- package/dist/lifecycle/restart.js.map +1 -0
- package/dist/shared/constants.d.ts +18 -0
- package/dist/shared/constants.d.ts.map +1 -0
- package/dist/shared/constants.js +60 -0
- package/dist/shared/constants.js.map +1 -0
- package/dist/shared/index.d.ts +3 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/index.js +3 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/shared/types.d.ts +263 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +2 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/skill/index.d.ts +2 -0
- package/dist/skill/index.d.ts.map +1 -0
- package/dist/skill/index.js +2 -0
- package/dist/skill/index.js.map +1 -0
- package/dist/skill/registry.d.ts +30 -0
- package/dist/skill/registry.d.ts.map +1 -0
- package/dist/skill/registry.js +174 -0
- package/dist/skill/registry.js.map +1 -0
- package/dist/skills/UPSTREAM.md +38 -0
- package/dist/skills/baxian-rules/SKILL.md +68 -0
- package/dist/skills/merge-sync/SKILL.md +42 -0
- package/dist/skills/pr-feedback/SKILL.md +117 -0
- package/dist/skills/pr-recheck/SKILL.md +52 -0
- package/dist/skills/pr-review/SKILL.md +60 -0
- package/dist/skills/spells/SKILL.md +41 -0
- package/dist/skills/task-check/SKILL.md +26 -0
- package/dist/state/agent-store.d.ts +21 -0
- package/dist/state/agent-store.d.ts.map +1 -0
- package/dist/state/agent-store.js +145 -0
- package/dist/state/agent-store.js.map +1 -0
- package/dist/state/error-record-store.d.ts +40 -0
- package/dist/state/error-record-store.d.ts.map +1 -0
- package/dist/state/error-record-store.js +203 -0
- package/dist/state/error-record-store.js.map +1 -0
- package/dist/state/index.d.ts +5 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +5 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/init.d.ts +2 -0
- package/dist/state/init.d.ts.map +1 -0
- package/dist/state/init.js +13 -0
- package/dist/state/init.js.map +1 -0
- package/dist/state/lock.d.ts +9 -0
- package/dist/state/lock.d.ts.map +1 -0
- package/dist/state/lock.js +36 -0
- package/dist/state/lock.js.map +1 -0
- package/dist/state/post-approve-store.d.ts +23 -0
- package/dist/state/post-approve-store.d.ts.map +1 -0
- package/dist/state/post-approve-store.js +79 -0
- package/dist/state/post-approve-store.js.map +1 -0
- package/dist/state/process-lock.d.ts +24 -0
- package/dist/state/process-lock.d.ts.map +1 -0
- package/dist/state/process-lock.js +175 -0
- package/dist/state/process-lock.js.map +1 -0
- package/dist/state/snapshot.d.ts +38 -0
- package/dist/state/snapshot.d.ts.map +1 -0
- package/dist/state/snapshot.js +134 -0
- package/dist/state/snapshot.js.map +1 -0
- package/dist/state/task-store.d.ts +25 -0
- package/dist/state/task-store.d.ts.map +1 -0
- package/dist/state/task-store.js +167 -0
- package/dist/state/task-store.js.map +1 -0
- package/dist/terminal/attach.d.ts +7 -0
- package/dist/terminal/attach.d.ts.map +1 -0
- package/dist/terminal/attach.js +26 -0
- package/dist/terminal/attach.js.map +1 -0
- package/dist/terminal/index.d.ts +3 -0
- package/dist/terminal/index.d.ts.map +1 -0
- package/dist/terminal/index.js +3 -0
- package/dist/terminal/index.js.map +1 -0
- package/dist/terminal/key-sanitizer.d.ts +2 -0
- package/dist/terminal/key-sanitizer.d.ts.map +1 -0
- package/dist/terminal/key-sanitizer.js +9 -0
- package/dist/terminal/key-sanitizer.js.map +1 -0
- package/dist/terminal/stream-ws.d.ts +3 -0
- package/dist/terminal/stream-ws.d.ts.map +1 -0
- package/dist/terminal/stream-ws.js +426 -0
- package/dist/terminal/stream-ws.js.map +1 -0
- package/dist/terminal/ws-auth.d.ts +5 -0
- package/dist/terminal/ws-auth.d.ts.map +1 -0
- package/dist/terminal/ws-auth.js +45 -0
- package/dist/terminal/ws-auth.js.map +1 -0
- package/dist/timing/debounced-task.d.ts +9 -0
- package/dist/timing/debounced-task.d.ts.map +1 -0
- package/dist/timing/debounced-task.js +23 -0
- package/dist/timing/debounced-task.js.map +1 -0
- package/dist/timing/periodic-task-runner.d.ts +21 -0
- package/dist/timing/periodic-task-runner.d.ts.map +1 -0
- package/dist/timing/periodic-task-runner.js +61 -0
- package/dist/timing/periodic-task-runner.js.map +1 -0
- package/dist/web/assets/index-53CBbz4w.js +4 -0
- package/dist/web/assets/index-B9D6BV08.css +1 -0
- package/dist/web/assets/react-BG4Iuztk.js +40 -0
- package/dist/web/assets/router-B_Nv0oRz.js +12 -0
- package/dist/web/assets/xterm-CFbL2ovg.css +32 -0
- package/dist/web/assets/xterm-D5X2JljJ.js +9 -0
- package/dist/web/index.html +17 -0
- package/package.json +44 -5
- package/index.js +0 -1
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
import { createMarkerToken } from '../agent/marker-protocol.js';
|
|
2
|
+
import { TASK_TERMINAL_STATUS_SET } from '../shared/index.js';
|
|
3
|
+
import { DispatchTerminalError, EnsureSessionError } from '../agent/manager.js';
|
|
4
|
+
const HEAD_SHA_RE = /^[0-9a-f]{40}$/i;
|
|
5
|
+
function validHeadSha(value) {
|
|
6
|
+
return typeof value === 'string' && HEAD_SHA_RE.test(value) ? value : undefined;
|
|
7
|
+
}
|
|
8
|
+
async function emitIntervention(bus, projectId, agentId, taskId, data) {
|
|
9
|
+
try {
|
|
10
|
+
const evt = {
|
|
11
|
+
id: '',
|
|
12
|
+
type: 'human.intervention',
|
|
13
|
+
timestamp: new Date().toISOString(),
|
|
14
|
+
projectId,
|
|
15
|
+
agentId,
|
|
16
|
+
taskId,
|
|
17
|
+
data,
|
|
18
|
+
};
|
|
19
|
+
await bus.emit(evt);
|
|
20
|
+
}
|
|
21
|
+
catch (emitErr) {
|
|
22
|
+
console.warn(`[EventHandler] human.intervention emit failed (phase=${data.phase}):`, emitErr);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function resolveAuthoritativeHead(manager, task, opts = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const headSha = await manager.fetchPrHeadSha(task.id);
|
|
28
|
+
return { headSha, source: 'fetch' };
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const fetchError = err instanceof Error ? err.message : String(err);
|
|
32
|
+
console.warn(`[handlers] fetchPrHeadSha(task=${task.id}) failed; falling back to stored / payload:`, err);
|
|
33
|
+
const stored = validHeadSha(task.latestHeadSha);
|
|
34
|
+
if (stored)
|
|
35
|
+
return { headSha: stored, source: 'task-store', fetchError };
|
|
36
|
+
if (opts.payloadCurrentHeadSha) {
|
|
37
|
+
return { headSha: opts.payloadCurrentHeadSha, source: 'payload-self', fetchError };
|
|
38
|
+
}
|
|
39
|
+
if (opts.legacyFallback) {
|
|
40
|
+
return { headSha: opts.legacyFallback, source: 'completion-approved', fetchError };
|
|
41
|
+
}
|
|
42
|
+
return { headSha: undefined, source: 'unknown', fetchError };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Budget = initial APPROVE dispatch + N redispatches; sized to converge a ~10-finding PR.
|
|
46
|
+
export const POST_APPROVE_REDISPATCH_CAP = 10;
|
|
47
|
+
async function dispatchDevPostApproveCheck(bus, manager, task, approvedHeadSha, opts = {}) {
|
|
48
|
+
if (!approvedHeadSha) {
|
|
49
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
50
|
+
phase: 'post-approve-approved-head-unavailable',
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const acquired = await manager.acquireAgentForTask(task.agentId, task.id, 'post_approve');
|
|
55
|
+
if (!acquired) {
|
|
56
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
57
|
+
phase: 'post-approve-dev-acquire-failed',
|
|
58
|
+
devAgentId: task.agentId,
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const postApproveToken = createMarkerToken();
|
|
63
|
+
await manager.setPostApproveCompletion(task.id, {
|
|
64
|
+
token: postApproveToken,
|
|
65
|
+
approvedHeadSha,
|
|
66
|
+
...(typeof opts.redispatchCount === 'number' ? { redispatchCount: opts.redispatchCount } : {}),
|
|
67
|
+
});
|
|
68
|
+
let resumed = false;
|
|
69
|
+
let dispatchErr = null;
|
|
70
|
+
try {
|
|
71
|
+
resumed = await manager.continueSession(task.id, task.agentId, 'post_approve', {
|
|
72
|
+
postApproveToken,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
dispatchErr = err;
|
|
77
|
+
console.error(`[EventHandler] APPROVE continueSession(dev=${task.agentId}, post_approve) failed:`, err);
|
|
78
|
+
}
|
|
79
|
+
if (resumed)
|
|
80
|
+
return;
|
|
81
|
+
await manager.clearPostApproveCompletionIfMatches(task.id, postApproveToken);
|
|
82
|
+
if (dispatchErr instanceof DispatchTerminalError) {
|
|
83
|
+
await manager.failTaskForDispatchError(task.id, 'post_approve', task.agentId, dispatchErr);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await manager.markAgentWaiting(task.agentId, task.id)
|
|
87
|
+
.catch(err => {
|
|
88
|
+
console.error(`[EventHandler] APPROVE markAgentWaiting(dev=${task.agentId}) rollback failed:`, err);
|
|
89
|
+
return false;
|
|
90
|
+
});
|
|
91
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
92
|
+
phase: 'post-approve-dispatch-failed',
|
|
93
|
+
reviewRound: task.reviewRound,
|
|
94
|
+
...(dispatchErr instanceof DispatchTerminalError ? { terminalReason: dispatchErr.reason } : {}),
|
|
95
|
+
...(dispatchErr ? { error: dispatchErr instanceof Error ? dispatchErr.message : String(dispatchErr) } : {}),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
async function gateDevForPostApproveRedispatch(bus, manager, task) {
|
|
99
|
+
const ready = await manager
|
|
100
|
+
.releaseAgentForTask(task.agentId, task.id, 'waiting')
|
|
101
|
+
.catch(err => {
|
|
102
|
+
console.error(`[EventHandler] post-approve redispatch releaseAgentForTask(dev=${task.agentId}) failed:`, err);
|
|
103
|
+
return false;
|
|
104
|
+
});
|
|
105
|
+
if (ready)
|
|
106
|
+
return true;
|
|
107
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
108
|
+
phase: 'post-approve-dev-wait-gate-failed-before-redispatch',
|
|
109
|
+
devAgentId: task.agentId,
|
|
110
|
+
});
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
export function registerEventHandlers(bus, manager, config) {
|
|
114
|
+
bus.on('pr.created', async (event) => {
|
|
115
|
+
if (!event.taskId || !event.agentId)
|
|
116
|
+
return;
|
|
117
|
+
const createdHeadSha = validHeadSha(event.data.headSha);
|
|
118
|
+
const result = await manager.transitionTaskStatus(event.taskId, 'review', { fromStatus: ['in_progress', 'fixing'] }, {
|
|
119
|
+
...(event.data.prNumber !== undefined ? { prNumber: event.data.prNumber } : {}),
|
|
120
|
+
...(event.data.prUrl !== undefined ? { prUrl: event.data.prUrl } : {}),
|
|
121
|
+
...(createdHeadSha ? { latestHeadSha: createdHeadSha } : {}),
|
|
122
|
+
});
|
|
123
|
+
if (!result) {
|
|
124
|
+
console.warn(`[EventHandler] pr.created: cannot transition task ${event.taskId} (terminal or invalid from-state)`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const { task: transitioned } = result;
|
|
128
|
+
const qa = manager.findQaPartner(event.agentId);
|
|
129
|
+
if (!qa) {
|
|
130
|
+
const ok = await manager.markAgentWaiting(event.agentId, transitioned.id);
|
|
131
|
+
if (!ok) {
|
|
132
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
133
|
+
phase: 'dev-wait-gate-failed-no-qa',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Acquire before startSession so the lock window covers the task binding write.
|
|
139
|
+
const acquired = await manager.acquireAgentForTask(qa.id, transitioned.id, 'review');
|
|
140
|
+
if (!acquired) {
|
|
141
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
142
|
+
phase: 'qa-acquire-failed',
|
|
143
|
+
qaAgentId: qa.id,
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
let started = false;
|
|
148
|
+
let dispatchErr = null;
|
|
149
|
+
try {
|
|
150
|
+
started = await manager.startSession(transitioned.id, qa.id, 'review');
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
dispatchErr = err;
|
|
154
|
+
console.error(`[EventHandler] pr.created startSession(QA=${qa.id}) hard error:`, err);
|
|
155
|
+
}
|
|
156
|
+
if (!started) {
|
|
157
|
+
console.warn(`[EventHandler] pr.created QA session not started for task=${transitioned.id}; ` +
|
|
158
|
+
`task stays in 'review' but no active QA — emitting human.intervention`);
|
|
159
|
+
if (dispatchErr instanceof DispatchTerminalError) {
|
|
160
|
+
await manager.failTaskForDispatchError(transitioned.id, 'review', qa.id, dispatchErr);
|
|
161
|
+
}
|
|
162
|
+
else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
|
|
163
|
+
// handleDialogPendingFromRuntime 已标 QA Held + fail task + release partners;这里
|
|
164
|
+
// 再调 release 会因 boundTask terminal 让 shouldReleaseHeldBinding 放行 → 解锁仍卡 dialog 的 pane。
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle')
|
|
168
|
+
.catch(err => {
|
|
169
|
+
console.error(`[EventHandler] pr.created releaseAgentForTask(QA=${qa.id}) after start-not-true failed:`, err);
|
|
170
|
+
return false;
|
|
171
|
+
});
|
|
172
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
173
|
+
phase: 'qa-review-start-failed',
|
|
174
|
+
qaAgentId: qa.id,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Persist qaAgentId BEFORE markAgentWaiting so failure can still mark the QA.
|
|
180
|
+
await manager.updateTask(transitioned.id, { qaAgentId: qa.id });
|
|
181
|
+
const ok = await manager.markAgentWaiting(event.agentId, transitioned.id);
|
|
182
|
+
if (!ok) {
|
|
183
|
+
// QA review prompt 已粘进 pane 在跑;裸 release 让下一 review 派来同一 pane 会污染 outcome。
|
|
184
|
+
// 标 awaiting_human 让 operator 决定如何收尾(取消该 task 走 cancelTask,或等 QA 跑完再 Resume)。
|
|
185
|
+
// expectedTaskId: 防止迟到的 mark 撞 outcome 已被接受 + QA release+reassign 后的新 binding。
|
|
186
|
+
await manager.markAwaitingHuman(qa.id, 'dev-wait-gate-failed-after-qa-started', `QA review for task ${transitioned.id} started but dev wait-gate failed; QA prompt may still be running, needs operator decision.`, { expectedTaskId: transitioned.id }).catch(err => {
|
|
187
|
+
console.error(`[EventHandler] pr.created markAwaitingHuman(QA=${qa.id}) after dev-wait-gate-fail:`, err);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
bus.on('pr.updated', async (event) => {
|
|
192
|
+
if (!event.taskId || !event.agentId)
|
|
193
|
+
return;
|
|
194
|
+
const eventPrNumber = event.data.prNumber;
|
|
195
|
+
const eventPrUrl = event.data.prUrl;
|
|
196
|
+
const eventKind = event.data.kind;
|
|
197
|
+
// Only `push` freshens `latestHeadSha` — other event payloads can regress it under reorder.
|
|
198
|
+
const eventHeadSha = validHeadSha(event.data.headSha);
|
|
199
|
+
const prPatch = {
|
|
200
|
+
...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
|
|
201
|
+
...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
|
|
202
|
+
...(eventKind === 'push' && eventHeadSha ? { latestHeadSha: eventHeadSha } : {}),
|
|
203
|
+
};
|
|
204
|
+
if (eventKind === 'post-approve-complete') {
|
|
205
|
+
const taskNow = await manager.getTask(event.taskId);
|
|
206
|
+
if (!taskNow)
|
|
207
|
+
return;
|
|
208
|
+
const needsPatch = (eventPrNumber !== undefined && eventPrNumber !== taskNow.prNumber)
|
|
209
|
+
|| (eventPrUrl !== undefined && eventPrUrl !== taskNow.prUrl);
|
|
210
|
+
if (needsPatch) {
|
|
211
|
+
await manager.updateTask(event.taskId, prPatch);
|
|
212
|
+
}
|
|
213
|
+
const verdictAgentId = event.data.verdictAgentId;
|
|
214
|
+
const postApproveToken = event.data.postApproveToken;
|
|
215
|
+
const completion = await manager.getPostApproveCompletion(taskNow.id);
|
|
216
|
+
if (taskNow.status !== 'approved'
|
|
217
|
+
|| verdictAgentId !== taskNow.agentId
|
|
218
|
+
|| !postApproveToken
|
|
219
|
+
|| completion?.token !== postApproveToken)
|
|
220
|
+
return;
|
|
221
|
+
const ok = await manager.markAgentWaiting(taskNow.agentId, taskNow.id);
|
|
222
|
+
if (!ok) {
|
|
223
|
+
await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
|
|
224
|
+
phase: 'post-approve-dev-wait-gate-failed',
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const freshTask = await manager.getTask(taskNow.id);
|
|
229
|
+
const freshCompletion = await manager.getPostApproveCompletion(taskNow.id);
|
|
230
|
+
if (!freshTask
|
|
231
|
+
|| freshTask.status !== 'approved'
|
|
232
|
+
|| freshTask.agentId !== taskNow.agentId
|
|
233
|
+
|| freshCompletion?.token !== postApproveToken) {
|
|
234
|
+
await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
|
|
235
|
+
phase: 'post-approve-merge-skipped-stale-task',
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// pendingRedispatch means new feedback arrived mid-pass — redispatch instead of merging.
|
|
240
|
+
if (freshCompletion.pendingRedispatch) {
|
|
241
|
+
const nextCount = (freshCompletion.redispatchCount ?? 0) + 1;
|
|
242
|
+
if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
|
|
243
|
+
await manager.clearPostApproveCompletion(freshTask.id);
|
|
244
|
+
await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
|
|
245
|
+
phase: 'post-approve-redispatch-cap-exceeded',
|
|
246
|
+
redispatchCount: freshCompletion.redispatchCount ?? 0,
|
|
247
|
+
cap: POST_APPROVE_REDISPATCH_CAP,
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
await dispatchDevPostApproveCheck(bus, manager, freshTask, freshCompletion.approvedHeadSha, { redispatchCount: nextCount });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const project = manager.getProjectConfig(freshTask.projectId);
|
|
255
|
+
if (project?.merge === 'auto') {
|
|
256
|
+
try {
|
|
257
|
+
await manager.mergePr(freshTask.id, { matchHeadSha: freshCompletion.approvedHeadSha });
|
|
258
|
+
await manager.clearPostApproveCompletionIfMatches(freshTask.id, postApproveToken);
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
262
|
+
console.error(`[EventHandler] post-approve auto-merge failed for task=${freshTask.id}:`, err);
|
|
263
|
+
await emitIntervention(bus, freshTask.projectId, freshTask.agentId, freshTask.id, {
|
|
264
|
+
phase: 'merge-failed',
|
|
265
|
+
error: message,
|
|
266
|
+
});
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const isCodeUpdate = eventKind === 'push' || eventKind === undefined;
|
|
273
|
+
if (!isCodeUpdate) {
|
|
274
|
+
const taskNow = await manager.getTask(event.taskId);
|
|
275
|
+
if (!taskNow)
|
|
276
|
+
return;
|
|
277
|
+
const needsPatch = (eventPrNumber !== undefined && eventPrNumber !== taskNow.prNumber)
|
|
278
|
+
|| (eventPrUrl !== undefined && eventPrUrl !== taskNow.prUrl);
|
|
279
|
+
if (needsPatch) {
|
|
280
|
+
await manager.updateTask(event.taskId, prPatch);
|
|
281
|
+
}
|
|
282
|
+
const completion = taskNow.status === 'approved'
|
|
283
|
+
? await manager.getPostApproveCompletion(taskNow.id)
|
|
284
|
+
: null;
|
|
285
|
+
const isNewFeedback = eventKind === 'comment' || eventKind === 'review-comment';
|
|
286
|
+
if (taskNow.status === 'approved' && isNewFeedback) {
|
|
287
|
+
if (!completion) {
|
|
288
|
+
await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
|
|
289
|
+
phase: 'post-approve-approved-head-unavailable',
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// Don't Ctrl-C Dev mid-pass on its own webhook echo — coalesce via pendingRedispatch instead.
|
|
294
|
+
const devState = await manager.getAgentState(taskNow.agentId);
|
|
295
|
+
if (devState?.taskId === taskNow.id) {
|
|
296
|
+
if (!completion.pendingRedispatch) {
|
|
297
|
+
await manager.setPostApproveCompletion(taskNow.id, {
|
|
298
|
+
token: completion.token,
|
|
299
|
+
approvedHeadSha: completion.approvedHeadSha,
|
|
300
|
+
...(typeof completion.redispatchCount === 'number'
|
|
301
|
+
? { redispatchCount: completion.redispatchCount } : {}),
|
|
302
|
+
pendingRedispatch: true,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const nextCount = (completion.redispatchCount ?? 0) + 1;
|
|
308
|
+
if (nextCount > POST_APPROVE_REDISPATCH_CAP) {
|
|
309
|
+
// Clear completion so an in-flight marker can't auto-merge past the cap.
|
|
310
|
+
await manager.clearPostApproveCompletion(taskNow.id);
|
|
311
|
+
await emitIntervention(bus, taskNow.projectId, taskNow.agentId, taskNow.id, {
|
|
312
|
+
phase: 'post-approve-redispatch-cap-exceeded',
|
|
313
|
+
redispatchCount: completion.redispatchCount ?? 0,
|
|
314
|
+
cap: POST_APPROVE_REDISPATCH_CAP,
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const ready = await gateDevForPostApproveRedispatch(bus, manager, taskNow);
|
|
319
|
+
if (!ready)
|
|
320
|
+
return;
|
|
321
|
+
await dispatchDevPostApproveCheck(bus, manager, { ...taskNow, ...prPatch }, completion.approvedHeadSha, { redispatchCount: nextCount });
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const taskBeforeTransition = await manager.getTask(event.taskId);
|
|
326
|
+
if (!taskBeforeTransition)
|
|
327
|
+
return;
|
|
328
|
+
const willHavePrNumber = taskBeforeTransition.prNumber !== undefined || eventPrNumber !== undefined;
|
|
329
|
+
if (taskBeforeTransition.status === 'in_progress' && !willHavePrNumber) {
|
|
330
|
+
console.warn(`[EventHandler] pr.updated: task ${event.taskId} in_progress but neither task nor event has prNumber; ` +
|
|
331
|
+
`deferring catch-up`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const result = await manager.transitionTaskStatus(event.taskId, 'review', { fromStatus: ['in_progress', 'fixing', 'review', 'approved'] }, prPatch);
|
|
335
|
+
if (!result)
|
|
336
|
+
return;
|
|
337
|
+
const { task: transitioned, previousStatus } = result;
|
|
338
|
+
await manager.clearPostApproveCompletion(transitioned.id);
|
|
339
|
+
let devAlreadyWaiting = false;
|
|
340
|
+
if (previousStatus === 'approved') {
|
|
341
|
+
devAlreadyWaiting = await manager
|
|
342
|
+
.releaseAgentForTask(transitioned.agentId, transitioned.id, 'waiting')
|
|
343
|
+
.catch(err => {
|
|
344
|
+
console.error(`[EventHandler] pr.updated releaseAgentForTask(dev=${transitioned.agentId}) before approved→recheck failed:`, err);
|
|
345
|
+
return false;
|
|
346
|
+
});
|
|
347
|
+
if (!devAlreadyWaiting) {
|
|
348
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
349
|
+
phase: 'post-approve-dev-wait-gate-failed-before-recheck',
|
|
350
|
+
devAgentId: transitioned.agentId,
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// Release stale QA before recheck — a half-released REPL must not receive new prompt.
|
|
356
|
+
if (previousStatus === 'review' && transitioned.qaAgentId) {
|
|
357
|
+
const released = await manager
|
|
358
|
+
.releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle')
|
|
359
|
+
.catch(err => {
|
|
360
|
+
console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${transitioned.qaAgentId}) for review→review push failed:`, err);
|
|
361
|
+
return false;
|
|
362
|
+
});
|
|
363
|
+
if (!released) {
|
|
364
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
365
|
+
phase: 'qa-release-failed-cannot-recheck',
|
|
366
|
+
qaAgentId: transitioned.qaAgentId,
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const qaPhase = previousStatus === 'fixing' || previousStatus === 'review' || previousStatus === 'approved'
|
|
372
|
+
? 'recheck'
|
|
373
|
+
: 'review';
|
|
374
|
+
const qa = manager.findQaPartner(event.agentId);
|
|
375
|
+
if (!qa) {
|
|
376
|
+
if (!devAlreadyWaiting && !(await manager.markAgentWaiting(event.agentId, transitioned.id))) {
|
|
377
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
378
|
+
phase: 'dev-wait-gate-failed-no-qa',
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const acquired = await manager.acquireAgentForTask(qa.id, transitioned.id, qaPhase);
|
|
384
|
+
if (!acquired) {
|
|
385
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
386
|
+
phase: 'qa-acquire-failed',
|
|
387
|
+
qaAgentId: qa.id,
|
|
388
|
+
qaPhase,
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
let started = false;
|
|
393
|
+
let dispatchErr = null;
|
|
394
|
+
try {
|
|
395
|
+
started = await manager.startSession(transitioned.id, qa.id, qaPhase);
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
dispatchErr = err;
|
|
399
|
+
console.error(`[EventHandler] pr.updated startSession(QA=${qa.id}, ${qaPhase}) hard error:`, err);
|
|
400
|
+
}
|
|
401
|
+
if (!started) {
|
|
402
|
+
console.warn(`[EventHandler] pr.updated QA ${qaPhase} not started; previousStatus=${previousStatus}`);
|
|
403
|
+
if (dispatchErr instanceof DispatchTerminalError) {
|
|
404
|
+
await manager.failTaskForDispatchError(transitioned.id, qaPhase, qa.id, dispatchErr);
|
|
405
|
+
}
|
|
406
|
+
else if (dispatchErr instanceof EnsureSessionError && dispatchErr.partial.handled) {
|
|
407
|
+
// handleDialogPendingFromRuntime 已标 QA Held + fail task + release partners;不能再 release
|
|
408
|
+
// 否则 boundTask terminal 会让 shouldReleaseHeldBinding 放行 → 解锁仍卡 dialog 的 pane。
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
await manager.releaseAgentForTask(qa.id, transitioned.id, 'idle')
|
|
412
|
+
.catch(err => {
|
|
413
|
+
console.error(`[EventHandler] pr.updated releaseAgentForTask(QA=${qa.id}) after start-not-true failed:`, err);
|
|
414
|
+
return false;
|
|
415
|
+
});
|
|
416
|
+
if (previousStatus === 'approved') {
|
|
417
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
418
|
+
phase: 'qa-recheck-failed-after-approved-push',
|
|
419
|
+
qaAgentId: qa.id,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
else if (previousStatus !== 'review') {
|
|
423
|
+
await manager.transitionTaskStatus(transitioned.id, previousStatus, { fromStatus: ['review'] });
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
427
|
+
phase: 'qa-recheck-failed-after-stop',
|
|
428
|
+
qaAgentId: qa.id,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
await manager.updateTask(transitioned.id, { qaAgentId: qa.id });
|
|
435
|
+
const ok = devAlreadyWaiting || (await manager.markAgentWaiting(event.agentId, transitioned.id));
|
|
436
|
+
if (!ok) {
|
|
437
|
+
// 同 pr.created 路径:QA review prompt 已粘进 pane 在跑,裸 release 让下一 review
|
|
438
|
+
// 派同一 QA 时新 prompt 灌进仍在跑旧 review 的 pane。标 awaiting_human 让 operator 处理。
|
|
439
|
+
await manager.markAwaitingHuman(qa.id, 'dev-wait-gate-failed-after-qa-started', `QA review for task ${transitioned.id} started but dev wait-gate failed; QA prompt may still be running, needs operator decision.`, { expectedTaskId: transitioned.id }).catch(err => {
|
|
440
|
+
console.error(`[EventHandler] pr.updated markAwaitingHuman(QA=${qa.id}) after dev-wait-gate-fail:`, err);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
bus.on('pr.merged', async (event) => {
|
|
445
|
+
if (!event.taskId)
|
|
446
|
+
return;
|
|
447
|
+
const eventPrNumber = event.data.prNumber;
|
|
448
|
+
const eventPrUrl = event.data.prUrl;
|
|
449
|
+
const prPatch = {
|
|
450
|
+
...(eventPrNumber !== undefined ? { prNumber: eventPrNumber } : {}),
|
|
451
|
+
...(eventPrUrl !== undefined ? { prUrl: eventPrUrl } : {}),
|
|
452
|
+
};
|
|
453
|
+
const result = await manager.transitionTaskStatus(event.taskId, 'merged', { fromStatus: ['in_progress', 'fixing', 'review', 'approved'] }, prPatch);
|
|
454
|
+
if (!result)
|
|
455
|
+
return;
|
|
456
|
+
const { task: transitioned } = result;
|
|
457
|
+
if (transitioned.qaAgentId) {
|
|
458
|
+
// Release first: 'idle' release removes QA's worktree, and the cleanup must run
|
|
459
|
+
// in the stable post-release state (server-side git ops use the main repo clone).
|
|
460
|
+
try {
|
|
461
|
+
await manager.releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle');
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
console.error(`[EventHandler] pr.merged releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err);
|
|
465
|
+
}
|
|
466
|
+
if (transitioned.prNumber && transitioned.branch) {
|
|
467
|
+
await manager.dispatchPostMergeCleanup(transitioned.qaAgentId, {
|
|
468
|
+
prNumber: transitioned.prNumber,
|
|
469
|
+
taskId: transitioned.id,
|
|
470
|
+
branch: transitioned.branch,
|
|
471
|
+
}).catch(err => console.warn(`[EventHandler] pr.merged dispatchPostMergeCleanup(QA=${transitioned.qaAgentId}) failed:`, err));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
await manager.cleanupAfterMerge(transitioned.id);
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
console.error(`[EventHandler] cleanupAfterMerge(${transitioned.id}) failed:`, err);
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
await manager.drainQueue(transitioned.projectId);
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
console.error(`[EventHandler] drainQueue(${transitioned.projectId}) failed:`, err);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
bus.on('review.submitted', async (event) => {
|
|
488
|
+
if (!event.taskId)
|
|
489
|
+
return;
|
|
490
|
+
const action = event.data.action;
|
|
491
|
+
let task = await manager.getTask(event.taskId);
|
|
492
|
+
if (!task)
|
|
493
|
+
return;
|
|
494
|
+
// Terminal-task escape: release QA on manual reviews of merged/cancelled/failed/max_rounds
|
|
495
|
+
// tasks; transitions below return null on terminal so QA would otherwise stay glued.
|
|
496
|
+
{
|
|
497
|
+
const terminalQaId = task.qaAgentId;
|
|
498
|
+
if (terminalQaId && TASK_TERMINAL_STATUS_SET.has(task.status)) {
|
|
499
|
+
const qaState = await manager.getAgentState(terminalQaId);
|
|
500
|
+
if (qaState?.taskId === task.id) {
|
|
501
|
+
const terminalTask = task;
|
|
502
|
+
await manager
|
|
503
|
+
.releaseAgentForTask(terminalQaId, terminalTask.id, 'idle')
|
|
504
|
+
.catch(err => console.error(`[EventHandler] terminal-task manual review releaseAgentForTask(QA=${terminalQaId}) failed:`, err));
|
|
505
|
+
await emitIntervention(bus, terminalTask.projectId, terminalTask.agentId, terminalTask.id, {
|
|
506
|
+
phase: 'manual-review-on-terminal-task-completed',
|
|
507
|
+
qaAgentId: terminalQaId,
|
|
508
|
+
taskStatus: terminalTask.status,
|
|
509
|
+
reviewAction: action,
|
|
510
|
+
note: 'Manual QA review on a terminal task finished. Task state untouched; QA released. Operator owns any follow-up.',
|
|
511
|
+
});
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const eventPrNumber = event.data.prNumber;
|
|
517
|
+
const eventPrUrl = event.data.prUrl;
|
|
518
|
+
const prPatch = {
|
|
519
|
+
...(eventPrNumber !== undefined && eventPrNumber !== task.prNumber ? { prNumber: eventPrNumber } : {}),
|
|
520
|
+
...(eventPrUrl !== undefined && eventPrUrl !== task.prUrl ? { prUrl: eventPrUrl } : {}),
|
|
521
|
+
};
|
|
522
|
+
const reviewedHeadSha = validHeadSha(event.data.headSha);
|
|
523
|
+
const currentHeadSha = validHeadSha(event.data.currentHeadSha);
|
|
524
|
+
if ((task.status === 'in_progress' || task.status === 'fixing') && eventPrNumber !== undefined) {
|
|
525
|
+
const catchup = await manager.transitionTaskStatus(event.taskId, 'review', { fromStatus: ['in_progress', 'fixing'] }, prPatch);
|
|
526
|
+
if (catchup) {
|
|
527
|
+
task = catchup.task;
|
|
528
|
+
if (task.agentId) {
|
|
529
|
+
const ok = await manager.markAgentWaiting(task.agentId, task.id);
|
|
530
|
+
if (!ok) {
|
|
531
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
532
|
+
phase: 'dev-wait-gate-failed-late-catchup',
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (action === 'APPROVE') {
|
|
542
|
+
if (!reviewedHeadSha) {
|
|
543
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
544
|
+
phase: 'approval-reviewed-head-unavailable',
|
|
545
|
+
});
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const anchor = await resolveAuthoritativeHead(manager, task, {
|
|
549
|
+
payloadCurrentHeadSha: currentHeadSha,
|
|
550
|
+
});
|
|
551
|
+
// Refresh cache BEFORE the reject decision so a fetch outage can't fall back to a stale store.
|
|
552
|
+
if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
|
|
553
|
+
await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
|
|
554
|
+
}
|
|
555
|
+
// No anchor (fetch + fallbacks all missing) ⇒ proceed; can't prove staleness either way.
|
|
556
|
+
if (anchor.headSha && reviewedHeadSha !== anchor.headSha) {
|
|
557
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
558
|
+
phase: 'stale-approval-head-mismatch',
|
|
559
|
+
reviewedHeadSha,
|
|
560
|
+
currentHeadSha: anchor.headSha,
|
|
561
|
+
source: anchor.source,
|
|
562
|
+
...(anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
|
|
563
|
+
});
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const result = await manager.transitionTaskStatus(event.taskId, 'approved', { fromStatus: ['review'] }, prPatch);
|
|
567
|
+
if (!result)
|
|
568
|
+
return;
|
|
569
|
+
const { task: transitioned } = result;
|
|
570
|
+
if (transitioned.qaAgentId) {
|
|
571
|
+
// outcome 到达 = QA turn 完成,即使 QA 之前 Held(dev_wait_gate_failed / ack_unknown)也可放行。
|
|
572
|
+
await manager.releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
|
|
573
|
+
.catch(err => console.error(`[EventHandler] APPROVE releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err));
|
|
574
|
+
}
|
|
575
|
+
await dispatchDevPostApproveCheck(bus, manager, transitioned, reviewedHeadSha);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (action === 'REQUEST_CHANGES') {
|
|
579
|
+
const approvedCompletion = task.status === 'approved'
|
|
580
|
+
? await manager.getPostApproveCompletion(task.id)
|
|
581
|
+
: null;
|
|
582
|
+
const anchor = await resolveAuthoritativeHead(manager, task, {
|
|
583
|
+
payloadCurrentHeadSha: currentHeadSha,
|
|
584
|
+
legacyFallback: approvedCompletion?.approvedHeadSha,
|
|
585
|
+
});
|
|
586
|
+
if (anchor.source === 'fetch' && anchor.headSha && task.latestHeadSha !== anchor.headSha) {
|
|
587
|
+
await manager.updateTask(task.id, { latestHeadSha: anchor.headSha });
|
|
588
|
+
}
|
|
589
|
+
if (reviewedHeadSha && anchor.headSha && reviewedHeadSha !== anchor.headSha) {
|
|
590
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
591
|
+
phase: 'stale-request-changes-head-mismatch',
|
|
592
|
+
reviewedHeadSha,
|
|
593
|
+
currentHeadSha: anchor.headSha,
|
|
594
|
+
source: anchor.source,
|
|
595
|
+
...(anchor.fetchError ? { fetchError: anchor.fetchError } : {}),
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const nextRound = task.reviewRound + 1;
|
|
600
|
+
if (task.status === 'approved' && nextRound <= config.review.rounds) {
|
|
601
|
+
// post-approve check 可能仍在 dev pane 中跑——release(waiting) 只 bump updatedAt 不 wait ready,
|
|
602
|
+
// 后续 continueSession(fix) 会把 fix prompt 灌进 busy pane (pr.updated approved+new feedback 已通过
|
|
603
|
+
// pendingRedispatch 走 coalesce;review.submitted 这里需要同等 gate)。检测 dev 仍绑 task + post-approve
|
|
604
|
+
// completion 仍存在 → emit intervention + skip 派发;marker 完成后 (post-approve-complete handler)
|
|
605
|
+
// task 会回到 approved,operator 可以重新触发 REQUEST_CHANGES manual review 或 cancel task。
|
|
606
|
+
const devState = await manager.getAgentState(task.agentId);
|
|
607
|
+
const postApproveActive = await manager.getPostApproveCompletion(task.id);
|
|
608
|
+
if (devState?.taskId === task.id && postApproveActive) {
|
|
609
|
+
// 必须先清 PostApproveCompletion 阻止 marker 完成后 auto-merge:
|
|
610
|
+
// post-approve-complete handler 看 completion.token 仍匹配 + freshTask.status='approved'
|
|
611
|
+
// + pendingRedispatch=false 会调 mergePr——但我们刚收到 REQUEST_CHANGES,绝不能 merge。
|
|
612
|
+
await manager.clearPostApproveCompletion(task.id);
|
|
613
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
614
|
+
phase: 'request-changes-during-post-approve',
|
|
615
|
+
devAgentId: task.agentId,
|
|
616
|
+
note: 'Dev is still running post-approve check; fix dispatch deferred to avoid prompt collision. PostApproveCompletion cleared to block auto-merge. Operator: wait for post-approve marker to complete, then re-trigger REQUEST_CHANGES manually or cancel the task.',
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const ready = await manager
|
|
621
|
+
.releaseAgentForTask(task.agentId, task.id, 'waiting')
|
|
622
|
+
.catch(err => {
|
|
623
|
+
console.error(`[EventHandler] REQUEST_CHANGES releaseAgentForTask(dev=${task.agentId}) before fix failed:`, err);
|
|
624
|
+
return false;
|
|
625
|
+
});
|
|
626
|
+
if (!ready) {
|
|
627
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
628
|
+
phase: 'post-approve-dev-wait-gate-failed-before-fix',
|
|
629
|
+
devAgentId: task.agentId,
|
|
630
|
+
});
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (nextRound > config.review.rounds) {
|
|
635
|
+
const result = await manager.transitionTaskStatus(event.taskId, 'max_rounds', { fromStatus: ['review', 'approved'] }, prPatch);
|
|
636
|
+
if (!result)
|
|
637
|
+
return;
|
|
638
|
+
const { task: transitioned, previousStatus } = result;
|
|
639
|
+
await manager.clearPostApproveCompletion(transitioned.id);
|
|
640
|
+
if (previousStatus === 'review' && transitioned.qaAgentId) {
|
|
641
|
+
await manager.releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle')
|
|
642
|
+
.catch(err => console.error(`[EventHandler] max_rounds releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err));
|
|
643
|
+
}
|
|
644
|
+
if (transitioned.agentId) {
|
|
645
|
+
await manager.releaseAgentForTask(transitioned.agentId, transitioned.id, 'idle')
|
|
646
|
+
.catch(err => console.error(`[EventHandler] max_rounds releaseAgentForTask(dev=${transitioned.agentId}) failed:`, err));
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
await bus.emit({
|
|
650
|
+
id: '',
|
|
651
|
+
type: 'review.max_rounds',
|
|
652
|
+
timestamp: new Date().toISOString(),
|
|
653
|
+
projectId: transitioned.projectId,
|
|
654
|
+
agentId: transitioned.agentId,
|
|
655
|
+
taskId: transitioned.id,
|
|
656
|
+
data: { reviewRound: transitioned.reviewRound },
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
catch (emitErr) {
|
|
660
|
+
console.warn(`[EventHandler] max_rounds emit failed:`, emitErr);
|
|
661
|
+
}
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const result = await manager.transitionTaskStatus(event.taskId, 'fixing', { fromStatus: ['review', 'approved'] }, { ...prPatch, reviewRound: nextRound });
|
|
665
|
+
if (!result)
|
|
666
|
+
return;
|
|
667
|
+
const { task: transitioned, previousStatus } = result;
|
|
668
|
+
await manager.clearPostApproveCompletion(transitioned.id);
|
|
669
|
+
let qaReleased = true;
|
|
670
|
+
if (previousStatus === 'review' && transitioned.qaAgentId) {
|
|
671
|
+
// outcome 到达 = QA turn 完成,allowAwaitingHuman 让 Held QA (ack_unknown / dev_wait_gate_failed) 也可放行。
|
|
672
|
+
qaReleased = await manager
|
|
673
|
+
.releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle', { allowAwaitingHuman: true })
|
|
674
|
+
.catch(err => {
|
|
675
|
+
console.error(`[EventHandler] REQUEST_CHANGES releaseAgentForTask(QA=${transitioned.qaAgentId}) failed:`, err);
|
|
676
|
+
return false;
|
|
677
|
+
});
|
|
678
|
+
if (!qaReleased) {
|
|
679
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
680
|
+
phase: 'qa-release-failed-but-dev-dispatched',
|
|
681
|
+
qaAgentId: transitioned.qaAgentId,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// Explicit acquire short-circuits if a concurrent DELETE/restart-repl released the lock.
|
|
686
|
+
const acquired = await manager.acquireAgentForTask(transitioned.agentId, transitioned.id, 'fix');
|
|
687
|
+
if (!acquired) {
|
|
688
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
689
|
+
phase: 'dev-acquire-failed-fix',
|
|
690
|
+
devAgentId: transitioned.agentId,
|
|
691
|
+
});
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
let resumed = false;
|
|
695
|
+
let dispatchErr = null;
|
|
696
|
+
try {
|
|
697
|
+
resumed = await manager.continueSession(transitioned.id, transitioned.agentId, 'fix');
|
|
698
|
+
}
|
|
699
|
+
catch (err) {
|
|
700
|
+
dispatchErr = err;
|
|
701
|
+
console.error(`[EventHandler] REQUEST_CHANGES continueSession(dev=${transitioned.agentId}, fix) failed:`, err);
|
|
702
|
+
}
|
|
703
|
+
if (!resumed) {
|
|
704
|
+
console.warn(`[EventHandler] REQUEST_CHANGES dev=${transitioned.agentId} not resumable for task=${transitioned.id}; ` +
|
|
705
|
+
`task remains in 'fixing' but no dev session is attached`);
|
|
706
|
+
if (dispatchErr instanceof DispatchTerminalError) {
|
|
707
|
+
await manager.failTaskForDispatchError(transitioned.id, 'fix', transitioned.agentId, dispatchErr);
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
await manager.markAgentWaiting(transitioned.agentId, transitioned.id)
|
|
711
|
+
.catch(err => {
|
|
712
|
+
console.error(`[EventHandler] REQUEST_CHANGES markAgentWaiting(dev=${transitioned.agentId}) rollback failed:`, err);
|
|
713
|
+
return false;
|
|
714
|
+
});
|
|
715
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
716
|
+
phase: 'fix-resume-failed',
|
|
717
|
+
reviewRound: transitioned.reviewRound,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
bus.on('spec.ready', async (event) => {
|
|
724
|
+
if (!event.taskId)
|
|
725
|
+
return;
|
|
726
|
+
const task = await manager.getTask(event.taskId);
|
|
727
|
+
if (!task)
|
|
728
|
+
return;
|
|
729
|
+
// Freshness gate: 拒迟到 / scrollback 复活的 stale spec-ready marker。
|
|
730
|
+
// 期望 task 仍在 pre-spec 阶段 (phase undefined, status in_progress) 且 token 匹配。
|
|
731
|
+
const eventToken = event.data?.token;
|
|
732
|
+
const stale = task.phase !== undefined
|
|
733
|
+
|| task.status !== 'in_progress'
|
|
734
|
+
|| !eventToken
|
|
735
|
+
|| eventToken !== task.specMarkerToken;
|
|
736
|
+
if (stale) {
|
|
737
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
738
|
+
phase: 'spec-ready-event-stale',
|
|
739
|
+
taskPhase: task.phase ?? null,
|
|
740
|
+
taskStatus: task.status,
|
|
741
|
+
});
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
await manager.dispatchSpecReviewToQa(event.taskId);
|
|
746
|
+
}
|
|
747
|
+
catch (err) {
|
|
748
|
+
console.error(`[EventHandler] spec.ready dispatchSpecReviewToQa(${event.taskId}) failed:`, err);
|
|
749
|
+
await emitIntervention(bus, event.projectId, event.agentId ?? '', event.taskId, {
|
|
750
|
+
phase: 'spec-ready-dispatch-failed',
|
|
751
|
+
error: err instanceof Error ? err.message : String(err),
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
bus.on('spec.review.submitted', async (event) => {
|
|
756
|
+
if (!event.taskId)
|
|
757
|
+
return;
|
|
758
|
+
const task = await manager.getTask(event.taskId);
|
|
759
|
+
if (!task)
|
|
760
|
+
return;
|
|
761
|
+
// Freshness gate: stale marker would inject old findings into a code-phase session.
|
|
762
|
+
const eventToken = event.data?.token;
|
|
763
|
+
const stale = task.phase !== 'spec'
|
|
764
|
+
|| task.status !== 'review'
|
|
765
|
+
|| !eventToken
|
|
766
|
+
|| eventToken !== task.specMarkerToken;
|
|
767
|
+
if (stale) {
|
|
768
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
769
|
+
phase: 'spec-review-event-stale',
|
|
770
|
+
taskPhase: task.phase ?? null,
|
|
771
|
+
taskStatus: task.status,
|
|
772
|
+
});
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const round = task.specReviewRound ?? 1;
|
|
776
|
+
const fileName = `round-${round}-findings.json`;
|
|
777
|
+
let raw = null;
|
|
778
|
+
try {
|
|
779
|
+
raw = await manager.readSpecReviewFile(event.taskId, fileName);
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
console.error(`[EventHandler] spec.review.submitted readSpecReviewFile(${event.taskId}) failed:`, err);
|
|
783
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
784
|
+
phase: 'spec-review-findings-read-failed',
|
|
785
|
+
round,
|
|
786
|
+
error: err instanceof Error ? err.message : String(err),
|
|
787
|
+
});
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (raw === null) {
|
|
791
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
792
|
+
phase: 'spec-review-findings-missing',
|
|
793
|
+
round,
|
|
794
|
+
fileName,
|
|
795
|
+
});
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
let parsed;
|
|
799
|
+
try {
|
|
800
|
+
parsed = JSON.parse(raw);
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
804
|
+
phase: 'spec-review-findings-invalid-json',
|
|
805
|
+
round,
|
|
806
|
+
error: err instanceof Error ? err.message : String(err),
|
|
807
|
+
});
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
// Round mismatch ⇒ QA wrote wrong-round content into current findings file.
|
|
811
|
+
if (typeof parsed.round !== 'number' || parsed.round !== round) {
|
|
812
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
813
|
+
phase: 'spec-review-findings-round-mismatch',
|
|
814
|
+
round,
|
|
815
|
+
parsedRound: parsed.round ?? null,
|
|
816
|
+
});
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (parsed.verdict === 'approve') {
|
|
820
|
+
try {
|
|
821
|
+
await manager.transitionToCodePhase(task.id);
|
|
822
|
+
}
|
|
823
|
+
catch (err) {
|
|
824
|
+
console.error(`[EventHandler] spec.review approve transitionToCodePhase(${task.id}) failed:`, err);
|
|
825
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
826
|
+
phase: 'spec-review-approve-transition-failed',
|
|
827
|
+
error: err instanceof Error ? err.message : String(err),
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (parsed.verdict !== 'changes-requested') {
|
|
833
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
834
|
+
phase: 'spec-review-findings-unknown-verdict',
|
|
835
|
+
round,
|
|
836
|
+
verdict: parsed.verdict ?? null,
|
|
837
|
+
});
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const cap = config.review.rounds;
|
|
841
|
+
if (round >= cap) {
|
|
842
|
+
const result = await manager.transitionTaskStatus(event.taskId, 'max_rounds', { fromStatus: ['review'] });
|
|
843
|
+
if (!result)
|
|
844
|
+
return;
|
|
845
|
+
const { task: transitioned } = result;
|
|
846
|
+
manager.stopSpecMarkerWatcher(transitioned.id);
|
|
847
|
+
// release 失败时 binding 残留 → 后续 acquire 会拒;emit intervention 让 stale binding 可见。
|
|
848
|
+
if (transitioned.qaAgentId) {
|
|
849
|
+
const qaReleased = await manager
|
|
850
|
+
.releaseAgentForTask(transitioned.qaAgentId, transitioned.id, 'idle')
|
|
851
|
+
.catch(() => false);
|
|
852
|
+
if (!qaReleased) {
|
|
853
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.qaAgentId, transitioned.id, {
|
|
854
|
+
phase: 'spec-review-max-rounds-qa-release-failed',
|
|
855
|
+
qaAgentId: transitioned.qaAgentId,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (transitioned.agentId) {
|
|
860
|
+
const devReleased = await manager
|
|
861
|
+
.releaseAgentForTask(transitioned.agentId, transitioned.id, 'idle')
|
|
862
|
+
.catch(() => false);
|
|
863
|
+
if (!devReleased) {
|
|
864
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
865
|
+
phase: 'spec-review-max-rounds-dev-release-failed',
|
|
866
|
+
devAgentId: transitioned.agentId,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
await emitIntervention(bus, transitioned.projectId, transitioned.agentId, transitioned.id, {
|
|
871
|
+
phase: 'spec-review-max-rounds',
|
|
872
|
+
round,
|
|
873
|
+
cap,
|
|
874
|
+
});
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
// changes-requested 必须有非空 findings — 提前 fail-loud。
|
|
878
|
+
if (!Array.isArray(parsed.findings) || parsed.findings.length === 0) {
|
|
879
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
880
|
+
phase: 'spec-review-findings-invalid-shape',
|
|
881
|
+
round,
|
|
882
|
+
findingsType: Array.isArray(parsed.findings) ? 'empty-array' : typeof parsed.findings,
|
|
883
|
+
});
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
// 每条 finding 必须有唯一非空 id — coverage 校验依赖它。fail-closed。
|
|
887
|
+
const findingsArr = parsed.findings;
|
|
888
|
+
const idSet = new Set();
|
|
889
|
+
for (const f of findingsArr) {
|
|
890
|
+
const id = f?.id;
|
|
891
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
892
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
893
|
+
phase: 'spec-review-findings-missing-id',
|
|
894
|
+
round,
|
|
895
|
+
});
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
if (idSet.has(id)) {
|
|
899
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
900
|
+
phase: 'spec-review-findings-duplicate-id',
|
|
901
|
+
round,
|
|
902
|
+
duplicateId: id,
|
|
903
|
+
});
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
idSet.add(id);
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
909
|
+
await manager.dispatchSpecFixToDev(task.id, raw);
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
console.error(`[EventHandler] spec.review dispatchSpecFixToDev(${task.id}) failed:`, err);
|
|
913
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
914
|
+
phase: 'spec-fix-dispatch-failed',
|
|
915
|
+
error: err instanceof Error ? err.message : String(err),
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
bus.on('spec.fix.submitted', async (event) => {
|
|
920
|
+
if (!event.taskId)
|
|
921
|
+
return;
|
|
922
|
+
const task = await manager.getTask(event.taskId);
|
|
923
|
+
if (!task)
|
|
924
|
+
return;
|
|
925
|
+
// Freshness gate: stale marker may re-trigger dispatch after spec phase exit.
|
|
926
|
+
const eventToken = event.data?.token;
|
|
927
|
+
const stale = task.phase !== 'spec'
|
|
928
|
+
|| task.status !== 'fixing'
|
|
929
|
+
|| !eventToken
|
|
930
|
+
|| eventToken !== task.specMarkerToken;
|
|
931
|
+
if (stale) {
|
|
932
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
933
|
+
phase: 'spec-fix-event-stale',
|
|
934
|
+
taskPhase: task.phase ?? null,
|
|
935
|
+
taskStatus: task.status,
|
|
936
|
+
});
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const round = task.specReviewRound ?? 1;
|
|
940
|
+
const fileName = `round-${round}-response.json`;
|
|
941
|
+
let raw = null;
|
|
942
|
+
try {
|
|
943
|
+
raw = await manager.readSpecReviewFile(event.taskId, fileName);
|
|
944
|
+
}
|
|
945
|
+
catch (err) {
|
|
946
|
+
console.error(`[EventHandler] spec.fix.submitted readSpecReviewFile(${event.taskId}) failed:`, err);
|
|
947
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
948
|
+
phase: 'spec-fix-response-read-failed',
|
|
949
|
+
round,
|
|
950
|
+
error: err instanceof Error ? err.message : String(err),
|
|
951
|
+
});
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
if (raw === null) {
|
|
955
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
956
|
+
phase: 'spec-fix-response-missing',
|
|
957
|
+
round,
|
|
958
|
+
fileName,
|
|
959
|
+
});
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
let parsed;
|
|
963
|
+
try {
|
|
964
|
+
parsed = JSON.parse(raw);
|
|
965
|
+
}
|
|
966
|
+
catch (err) {
|
|
967
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
968
|
+
phase: 'spec-fix-response-invalid-json',
|
|
969
|
+
round,
|
|
970
|
+
error: err instanceof Error ? err.message : String(err),
|
|
971
|
+
});
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (typeof parsed.round !== 'number' || parsed.round !== round) {
|
|
975
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
976
|
+
phase: 'spec-fix-response-round-mismatch',
|
|
977
|
+
round,
|
|
978
|
+
parsedRound: parsed.round ?? null,
|
|
979
|
+
});
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (!Array.isArray(parsed.responses)) {
|
|
983
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
984
|
+
phase: 'spec-fix-response-invalid-shape',
|
|
985
|
+
round,
|
|
986
|
+
});
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const responses = parsed.responses;
|
|
990
|
+
if (responses.length === 0) {
|
|
991
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
992
|
+
phase: 'spec-fix-response-empty',
|
|
993
|
+
round,
|
|
994
|
+
});
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
for (const r of responses) {
|
|
998
|
+
if (r?.action !== 'fix' && r?.action !== 'reject') {
|
|
999
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
1000
|
+
phase: 'spec-fix-response-invalid-action',
|
|
1001
|
+
round,
|
|
1002
|
+
action: r?.action ?? null,
|
|
1003
|
+
});
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// Coverage check: response must cover every finding.id; all branches fail-closed
|
|
1008
|
+
// (read/parse/round-mismatch all emit + return — fail-open would let all-reject sneak into code phase).
|
|
1009
|
+
let findingsRaw;
|
|
1010
|
+
try {
|
|
1011
|
+
findingsRaw = await manager.readSpecReviewFile(event.taskId, `round-${round}-findings.json`);
|
|
1012
|
+
}
|
|
1013
|
+
catch (err) {
|
|
1014
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
1015
|
+
phase: 'spec-fix-coverage-findings-read-failed',
|
|
1016
|
+
round,
|
|
1017
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1018
|
+
});
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (findingsRaw === null) {
|
|
1022
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
1023
|
+
phase: 'spec-fix-coverage-findings-missing',
|
|
1024
|
+
round,
|
|
1025
|
+
});
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
let findingsParsed;
|
|
1029
|
+
try {
|
|
1030
|
+
findingsParsed = JSON.parse(findingsRaw);
|
|
1031
|
+
}
|
|
1032
|
+
catch (err) {
|
|
1033
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
1034
|
+
phase: 'spec-fix-coverage-findings-invalid-json',
|
|
1035
|
+
round,
|
|
1036
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1037
|
+
});
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (typeof findingsParsed.round !== 'number' || findingsParsed.round !== round) {
|
|
1041
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
1042
|
+
phase: 'spec-fix-coverage-findings-round-mismatch',
|
|
1043
|
+
round,
|
|
1044
|
+
parsedRound: findingsParsed.round ?? null,
|
|
1045
|
+
});
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
// 独立 fail-closed schema 校验 — 上一阶段的校验对本 handler 不可信。
|
|
1049
|
+
if (!Array.isArray(findingsParsed.findings) || findingsParsed.findings.length === 0) {
|
|
1050
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
1051
|
+
phase: 'spec-fix-coverage-findings-invalid-shape',
|
|
1052
|
+
round,
|
|
1053
|
+
findingsType: Array.isArray(findingsParsed.findings)
|
|
1054
|
+
? 'empty-array'
|
|
1055
|
+
: typeof findingsParsed.findings,
|
|
1056
|
+
});
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const findingIdsList = [];
|
|
1060
|
+
for (const f of findingsParsed.findings) {
|
|
1061
|
+
const id = f?.id;
|
|
1062
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1063
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
1064
|
+
phase: 'spec-fix-coverage-findings-missing-id',
|
|
1065
|
+
round,
|
|
1066
|
+
});
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (findingIdsList.includes(id)) {
|
|
1070
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
1071
|
+
phase: 'spec-fix-coverage-findings-duplicate-id',
|
|
1072
|
+
round,
|
|
1073
|
+
duplicateId: id,
|
|
1074
|
+
});
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
findingIdsList.push(id);
|
|
1078
|
+
}
|
|
1079
|
+
const findingIds = new Set(findingIdsList);
|
|
1080
|
+
const responseIds = new Set(responses
|
|
1081
|
+
.map(r => r?.findingId)
|
|
1082
|
+
.filter((id) => typeof id === 'string'));
|
|
1083
|
+
const missing = [...findingIds].filter(id => !responseIds.has(id));
|
|
1084
|
+
const unknown = [...responseIds].filter(id => !findingIds.has(id));
|
|
1085
|
+
if (missing.length > 0 || unknown.length > 0) {
|
|
1086
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
1087
|
+
phase: 'spec-fix-response-coverage-mismatch',
|
|
1088
|
+
round,
|
|
1089
|
+
missingFindingIds: missing,
|
|
1090
|
+
unknownFindingIds: unknown,
|
|
1091
|
+
});
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const hasAnyFix = responses.some(r => r.action === 'fix');
|
|
1095
|
+
if (!hasAnyFix) {
|
|
1096
|
+
// 全 reject → dev 直接进 code phase;不再 qa review。
|
|
1097
|
+
try {
|
|
1098
|
+
await manager.transitionToCodePhase(task.id);
|
|
1099
|
+
}
|
|
1100
|
+
catch (err) {
|
|
1101
|
+
console.error(`[EventHandler] spec.fix all-reject transitionToCodePhase(${task.id}) failed:`, err);
|
|
1102
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
1103
|
+
phase: 'spec-fix-all-reject-transition-failed',
|
|
1104
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
try {
|
|
1110
|
+
await manager.dispatchSpecReviewToQa(task.id);
|
|
1111
|
+
}
|
|
1112
|
+
catch (err) {
|
|
1113
|
+
console.error(`[EventHandler] spec.fix dispatchSpecReviewToQa(${task.id}) failed:`, err);
|
|
1114
|
+
await emitIntervention(bus, task.projectId, task.agentId, task.id, {
|
|
1115
|
+
phase: 'spec-fix-redispatch-failed',
|
|
1116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
//# sourceMappingURL=handlers.js.map
|