baxian 2.0.2 → 2.0.4
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/README.md +2 -2
- package/README.zh-CN.md +3 -3
- package/dist/agent/bootstrap.js +4 -4
- package/dist/agent/branch.d.ts.map +1 -1
- package/dist/agent/branch.js +0 -7
- package/dist/agent/branch.js.map +1 -1
- package/dist/agent/detect/region.d.ts.map +1 -1
- package/dist/agent/detect/region.js +0 -3
- package/dist/agent/detect/region.js.map +1 -1
- package/dist/agent/dispatch-reconciler.d.ts +2 -0
- package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
- package/dist/agent/dispatch-reconciler.js +48 -55
- package/dist/agent/dispatch-reconciler.js.map +1 -1
- package/dist/agent/lineage.d.ts.map +1 -1
- package/dist/agent/lineage.js +0 -9
- package/dist/agent/lineage.js.map +1 -1
- package/dist/agent/manager.d.ts +42 -8
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +717 -375
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/net-exec.d.ts.map +1 -1
- package/dist/agent/net-exec.js +0 -5
- package/dist/agent/net-exec.js.map +1 -1
- package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
- package/dist/agent/pane-streamer-manager.js +0 -36
- package/dist/agent/pane-streamer-manager.js.map +1 -1
- package/dist/agent/pane-streamer.d.ts +6 -0
- package/dist/agent/pane-streamer.d.ts.map +1 -1
- package/dist/agent/pane-streamer.js +126 -83
- package/dist/agent/pane-streamer.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +2 -77
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +0 -1
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +0 -17
- package/dist/agent/phase-signal.js.map +1 -1
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +0 -18
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +0 -1
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +0 -38
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +0 -2
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tmux-install.js +0 -1
- package/dist/agent/tmux-install.js.map +1 -1
- package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
- package/dist/agent/tmux-probe-poller.js +4 -11
- package/dist/agent/tmux-probe-poller.js.map +1 -1
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +0 -86
- package/dist/agent/tmux.js.map +1 -1
- package/dist/agent/vt-visible-text.d.ts +46 -1
- package/dist/agent/vt-visible-text.d.ts.map +1 -1
- package/dist/agent/vt-visible-text.js +639 -63
- package/dist/agent/vt-visible-text.js.map +1 -1
- package/dist/api/agents.d.ts.map +1 -1
- package/dist/api/agents.js +3 -49
- package/dist/api/agents.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +10 -59
- package/dist/api/config.js.map +1 -1
- package/dist/api/hosts.d.ts.map +1 -1
- package/dist/api/hosts.js +8 -63
- package/dist/api/hosts.js.map +1 -1
- package/dist/api/platform-guard.d.ts.map +1 -1
- package/dist/api/platform-guard.js +2 -3
- package/dist/api/platform-guard.js.map +1 -1
- package/dist/api/probe.d.ts.map +1 -1
- package/dist/api/probe.js +0 -1
- package/dist/api/probe.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +15 -28
- package/dist/api/projects.js.map +1 -1
- package/dist/api/tasks.d.ts.map +1 -1
- package/dist/api/tasks.js +94 -58
- package/dist/api/tasks.js.map +1 -1
- package/dist/app.d.ts +2 -2
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +0 -6
- package/dist/app.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -31
- package/dist/cli.js.map +1 -1
- package/dist/config/hot-reload.d.ts.map +1 -1
- package/dist/config/hot-reload.js +0 -4
- package/dist/config/hot-reload.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +6 -29
- package/dist/config/loader.js.map +1 -1
- package/dist/config/normalizer.d.ts.map +1 -1
- package/dist/config/normalizer.js +0 -19
- package/dist/config/normalizer.js.map +1 -1
- package/dist/config/validator.d.ts.map +1 -1
- package/dist/config/validator.js +31 -229
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +3 -11
- package/dist/event/handlers.js.map +1 -1
- package/dist/event/log.js +0 -2
- package/dist/event/log.js.map +1 -1
- package/dist/event/ws.d.ts.map +1 -1
- package/dist/event/ws.js +3 -3
- package/dist/event/ws.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -46
- package/dist/index.js.map +1 -1
- package/dist/platform/body-digest.d.ts.map +1 -1
- package/dist/platform/body-digest.js +0 -4
- package/dist/platform/body-digest.js.map +1 -1
- package/dist/platform/command-renderer.d.ts.map +1 -1
- package/dist/platform/command-renderer.js +0 -3
- package/dist/platform/command-renderer.js.map +1 -1
- package/dist/platform/comment-cursor.d.ts.map +1 -1
- package/dist/platform/comment-cursor.js +0 -21
- package/dist/platform/comment-cursor.js.map +1 -1
- package/dist/platform/driver-spec.d.ts.map +1 -1
- package/dist/platform/driver-spec.js +1 -42
- package/dist/platform/driver-spec.js.map +1 -1
- package/dist/platform/feedback.d.ts +5 -1
- package/dist/platform/feedback.d.ts.map +1 -1
- package/dist/platform/feedback.js +7 -1
- package/dist/platform/feedback.js.map +1 -1
- package/dist/platform/field-mapper.d.ts.map +1 -1
- package/dist/platform/field-mapper.js +3 -11
- package/dist/platform/field-mapper.js.map +1 -1
- package/dist/platform/git-driver.d.ts.map +1 -1
- package/dist/platform/git-driver.js +0 -20
- package/dist/platform/git-driver.js.map +1 -1
- package/dist/platform/manifest.d.ts.map +1 -1
- package/dist/platform/manifest.js +0 -3
- package/dist/platform/manifest.js.map +1 -1
- package/dist/platform/markers.d.ts.map +1 -1
- package/dist/platform/markers.js +0 -19
- package/dist/platform/markers.js.map +1 -1
- package/dist/platform/platform-poller.d.ts +5 -1
- package/dist/platform/platform-poller.d.ts.map +1 -1
- package/dist/platform/platform-poller.js +8 -49
- package/dist/platform/platform-poller.js.map +1 -1
- package/dist/platform/plugin-registry.d.ts.map +1 -1
- package/dist/platform/plugin-registry.js +1 -12
- package/dist/platform/plugin-registry.js.map +1 -1
- package/dist/platform/pr-conversation-cache.d.ts +12 -7
- package/dist/platform/pr-conversation-cache.d.ts.map +1 -1
- package/dist/platform/pr-conversation-cache.js +60 -40
- package/dist/platform/pr-conversation-cache.js.map +1 -1
- package/dist/platform/preflight-exec.d.ts.map +1 -1
- package/dist/platform/preflight-exec.js +0 -9
- package/dist/platform/preflight-exec.js.map +1 -1
- package/dist/platform/response-parser.d.ts.map +1 -1
- package/dist/platform/response-parser.js +0 -4
- package/dist/platform/response-parser.js.map +1 -1
- package/dist/platform/review-timeline.d.ts +17 -2
- package/dist/platform/review-timeline.d.ts.map +1 -1
- package/dist/platform/review-timeline.js +93 -106
- package/dist/platform/review-timeline.js.map +1 -1
- package/dist/platform/row-schema.d.ts.map +1 -1
- package/dist/platform/row-schema.js +0 -11
- package/dist/platform/row-schema.js.map +1 -1
- package/dist/platform/startup.d.ts.map +1 -1
- package/dist/platform/startup.js +1 -12
- package/dist/platform/startup.js.map +1 -1
- package/dist/platform/types.d.ts.map +1 -1
- package/dist/platform/types.js +0 -5
- package/dist/platform/types.js.map +1 -1
- package/dist/platform/verdict-engine.d.ts.map +1 -1
- package/dist/platform/verdict-engine.js +0 -15
- package/dist/platform/verdict-engine.js.map +1 -1
- package/dist/shared/concurrency.d.ts.map +1 -1
- package/dist/shared/concurrency.js +0 -2
- package/dist/shared/concurrency.js.map +1 -1
- package/dist/shared/constants.d.ts +0 -2
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +0 -3
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/git-url.d.ts.map +1 -1
- package/dist/shared/git-url.js +0 -9
- package/dist/shared/git-url.js.map +1 -1
- package/dist/shared/types.d.ts +31 -12
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +14 -0
- package/dist/shared/types.js.map +1 -1
- package/dist/skill/registry.d.ts.map +1 -1
- package/dist/skill/registry.js +0 -5
- package/dist/skill/registry.js.map +1 -1
- package/dist/skills/baxian-signals/SKILL.md +0 -6
- package/dist/state/agent-store.d.ts.map +1 -1
- package/dist/state/agent-store.js +0 -6
- package/dist/state/agent-store.js.map +1 -1
- package/dist/state/error-record-store.d.ts.map +1 -1
- package/dist/state/error-record-store.js +0 -5
- package/dist/state/error-record-store.js.map +1 -1
- package/dist/state/init.d.ts.map +1 -1
- package/dist/state/init.js +0 -1
- package/dist/state/init.js.map +1 -1
- package/dist/state/lock.d.ts.map +1 -1
- package/dist/state/lock.js +0 -1
- package/dist/state/lock.js.map +1 -1
- package/dist/state/snapshot.d.ts +0 -1
- package/dist/state/snapshot.d.ts.map +1 -1
- package/dist/state/snapshot.js +0 -13
- package/dist/state/snapshot.js.map +1 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +96 -11
- package/dist/state/task-store.js.map +1 -1
- package/dist/terminal/attach.d.ts.map +1 -1
- package/dist/terminal/attach.js +0 -4
- package/dist/terminal/attach.js.map +1 -1
- package/dist/terminal/stream-ws.js +0 -5
- package/dist/terminal/stream-ws.js.map +1 -1
- package/dist/web/assets/index-CzVzThuT.js +15 -0
- package/dist/web/assets/{index-BS2adAfI.css → index-Rmp4wT_5.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/agent/root-agent-runtime.d.ts +0 -80
- package/dist/agent/root-agent-runtime.d.ts.map +0 -1
- package/dist/agent/root-agent-runtime.js +0 -637
- package/dist/agent/root-agent-runtime.js.map +0 -1
- package/dist/agent/root-recovery-coordinator.d.ts +0 -124
- package/dist/agent/root-recovery-coordinator.d.ts.map +0 -1
- package/dist/agent/root-recovery-coordinator.js +0 -1557
- package/dist/agent/root-recovery-coordinator.js.map +0 -1
- package/dist/state/root-recovery-store.d.ts +0 -107
- package/dist/state/root-recovery-store.d.ts.map +0 -1
- package/dist/state/root-recovery-store.js +0 -400
- package/dist/state/root-recovery-store.js.map +0 -1
- package/dist/web/assets/index-BKiM0MSM.js +0 -15
package/dist/agent/manager.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
4
|
import { join, normalize } from 'node:path';
|
|
4
5
|
import { tmpdir } from 'node:os';
|
|
5
6
|
import { createSignalToken, PASSIVE_VERDICT_WATCH } from './phase-signal.js';
|
|
6
7
|
import { tmuxInstallHint } from './preflight.js';
|
|
7
|
-
import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, effectiveTaskReviewRound, isSpecStagePhase, isTaskOpen, repoIdentityKey, taskGenerationGuard, taskReviewRound, taskMatchesGeneration, } from '../shared/index.js';
|
|
8
|
+
import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES_AGENT_BOUND_TO_TASK, TASK_TERMINAL_STATUSES as TERMINAL_STATUSES, TASK_ACTIVE_STATUS_SET as ACTIVE_TASK_STATUSES, TASK_OWNER_ROLES, effectiveTaskReviewRound, isSpecStagePhase, isTaskOpen, repoIdentityKey, taskAttentionGeneration, taskGenerationGuard, taskReviewRound, taskMatchesGeneration, } from '../shared/index.js';
|
|
8
9
|
import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
|
|
9
10
|
import { DriverOpError, buildDriverRunContext, GitDriver, safeDriverErrorText } from '../platform/git-driver.js';
|
|
11
|
+
import { versionTimeOf } from '../platform/row-schema.js';
|
|
10
12
|
import { checkOpenPrBinding, checkPrBinding } from '../platform/pr-binding.js';
|
|
11
13
|
import { collectPendingFeedback, scanCommentSourcesOnce } from '../platform/feedback.js';
|
|
12
|
-
import {
|
|
14
|
+
import { prReviewCacheRevision } from '../platform/pr-conversation-cache.js';
|
|
15
|
+
import { deadTokens, recheckPassProvenance } from '../platform/verdict-engine.js';
|
|
13
16
|
import { platformBindingMismatch, taskNeedsPlatformBindingAudit, } from '../platform/startup.js';
|
|
14
17
|
import { AGENT_STORE_NOOP } from '../state/agent-store.js';
|
|
15
18
|
import { SkillRegistry } from '../skill/registry.js';
|
|
@@ -27,7 +30,7 @@ import { resolveProjectTool } from '../config/validator.js';
|
|
|
27
30
|
import { SHA_HEX_SOURCE } from '../platform/types.js';
|
|
28
31
|
import { bodyDigest } from '../platform/body-digest.js';
|
|
29
32
|
import { validateCommentBody } from '../platform/command-renderer.js';
|
|
30
|
-
|
|
33
|
+
import { buildReviewTokenLine, extractReviewTokens, rowTokens } from '../platform/markers.js';
|
|
31
34
|
const BENIGN_DISPATCH_CONFLICT_CODES = new Set(['dispatch-superseded', 'dispatch-in-flight']);
|
|
32
35
|
export function isBenignDispatchConflict(err) {
|
|
33
36
|
return err instanceof ApiError
|
|
@@ -40,6 +43,16 @@ function requireTaskQaAgentId(task, operation) {
|
|
|
40
43
|
throw new Error(`${operation}: task ${task.id} has no QA participant`);
|
|
41
44
|
return task.qaAgentId;
|
|
42
45
|
}
|
|
46
|
+
function samePassProvenance(left, right) {
|
|
47
|
+
return left !== undefined
|
|
48
|
+
&& right !== undefined
|
|
49
|
+
&& left.sourceKey === right.sourceKey
|
|
50
|
+
&& left.id === right.id
|
|
51
|
+
&& left.bodyDigest === right.bodyDigest
|
|
52
|
+
&& left.token === right.token
|
|
53
|
+
&& left.failToken === right.failToken
|
|
54
|
+
&& left.anchorSha === right.anchorSha;
|
|
55
|
+
}
|
|
43
56
|
export class EnsureSessionError extends Error {
|
|
44
57
|
partial;
|
|
45
58
|
constructor(partial, message) {
|
|
@@ -81,10 +94,6 @@ export function buildLaunchCommand(agent) {
|
|
|
81
94
|
segments.push('--dangerously-bypass-approvals-and-sandbox');
|
|
82
95
|
break;
|
|
83
96
|
case 'opencode':
|
|
84
|
-
// opencode materializes skills into .agents/skills (shared with codex; see skillSubdirFor).
|
|
85
|
-
// Its .agents/.claude compatibility scans can't both be disabled by env, so also turn off
|
|
86
|
-
// the .claude/skills scan — that closes the last collision source: a claude-code agent's
|
|
87
|
-
// .claude/skills/baxian-* in a shared base repo.
|
|
88
97
|
segments.push('env OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 opencode');
|
|
89
98
|
if (yolo)
|
|
90
99
|
segments.push('--auto');
|
|
@@ -98,7 +107,6 @@ export function buildLaunchCommand(agent) {
|
|
|
98
107
|
if (agent.model) {
|
|
99
108
|
segments.push(`--model ${shellQuote(agent.model)}`);
|
|
100
109
|
}
|
|
101
|
-
// opencode has no --add-dir; extra roots go through its permission model instead (validator rejects the pairing).
|
|
102
110
|
if (agent.addDirs && agent.addDirs.length > 0 && agent.runtime !== 'opencode') {
|
|
103
111
|
for (const dir of agent.addDirs) {
|
|
104
112
|
segments.push(`--add-dir ${shellQuote(dir)}`);
|
|
@@ -106,15 +114,12 @@ export function buildLaunchCommand(agent) {
|
|
|
106
114
|
}
|
|
107
115
|
return segments.join(' ');
|
|
108
116
|
}
|
|
109
|
-
// cd re-resolves by pathname: a reused pane shell may sit on a deleted-and-recreated cwd inode.
|
|
110
117
|
export function launchCommandIn(dir, agent) {
|
|
111
118
|
return `cd ${shellQuote(dir)} && ${buildLaunchCommand(agent)}`;
|
|
112
119
|
}
|
|
113
120
|
function agentRuntimeKindFor(agent) {
|
|
114
121
|
return agent.runtime;
|
|
115
122
|
}
|
|
116
|
-
// restart-repl drives the REPL back to a shell before relaunch. Each runtime exits
|
|
117
|
-
// via its own slash command (a bare "exit" would be sent to the model as chat text).
|
|
118
123
|
const REPL_EXIT_COMMAND = {
|
|
119
124
|
'claude-code': '/exit',
|
|
120
125
|
codex: '/quit',
|
|
@@ -146,11 +151,6 @@ export function skillSubdirFor(runtime) {
|
|
|
146
151
|
switch (runtime) {
|
|
147
152
|
case 'codex':
|
|
148
153
|
case 'opencode':
|
|
149
|
-
// opencode has no way to disable its .agents/skills compatibility scan (only .claude via
|
|
150
|
-
// env), and when it shares a base repo with a codex agent it loads the codex copy of a
|
|
151
|
-
// same-named baxian-* skill (verified: the .agents copy wins over .opencode). So opencode
|
|
152
|
-
// materializes into .agents/skills too — both runtimes share one runtime-agnostic copy
|
|
153
|
-
// (serialized by the same skill-dir lock) instead of colliding on duplicate names.
|
|
154
154
|
return '.agents/skills';
|
|
155
155
|
case 'qodercli':
|
|
156
156
|
return '.qoder/skills';
|
|
@@ -161,8 +161,6 @@ export function skillSubdirFor(runtime) {
|
|
|
161
161
|
const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
|
|
162
162
|
const DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS = 5_000;
|
|
163
163
|
const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
|
|
164
|
-
// DELETE runtime cleanup runs while holding the deletion tombstone + rotated lock; an unbounded remote
|
|
165
|
-
// probe/kill would block rollback/finally forever, so every tmux round-trip in it carries this deadline.
|
|
166
164
|
const DELETE_CLEANUP_TIMEOUT_MS = 15_000;
|
|
167
165
|
const PLATFORM_HEAD_SHA_RE = new RegExp(`^${SHA_HEX_SOURCE}$`);
|
|
168
166
|
function createSignalTokenExcluding(...excluded) {
|
|
@@ -186,7 +184,6 @@ const RUNTIME_LIVENESS_SAMPLES = 3;
|
|
|
186
184
|
export function canDispatchWithBinding(binding) {
|
|
187
185
|
return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
|
|
188
186
|
}
|
|
189
|
-
// 派发期 hold:prompt 从未注入、pane 无在途回合,重派安全;ack_unknown 一类不在此列
|
|
190
187
|
export const RECOVERABLE_QA_DISPATCH_HOLD_PHASES = new Set([
|
|
191
188
|
'checkout-preparation-failed',
|
|
192
189
|
'dirty-workdir',
|
|
@@ -220,8 +217,6 @@ function shouldReleaseHeldBinding(state, boundTask) {
|
|
|
220
217
|
if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase))
|
|
221
218
|
return false;
|
|
222
219
|
const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
|
|
223
|
-
// 任务已退回 verdict 门禁且当前持有者不是本 agent:这次绑定是转码失败的残留
|
|
224
|
-
// (fresh-acquire 释放被脏树挡下),Resume 时补完释放,否则 dev 被永久占住
|
|
225
220
|
const staleCodeHandoff = state.awaitingPhase != null
|
|
226
221
|
&& CHECKOUT_HOLD_PHASES.has(state.awaitingPhase)
|
|
227
222
|
&& !!boundTask
|
|
@@ -245,6 +240,7 @@ export class AgentManager {
|
|
|
245
240
|
paneStreamerManager;
|
|
246
241
|
pluginRegistry;
|
|
247
242
|
platformDefaultBranchOf;
|
|
243
|
+
prConversationCache;
|
|
248
244
|
platformDriverExec;
|
|
249
245
|
phaseSignalWatcher;
|
|
250
246
|
errorRecordStore;
|
|
@@ -253,6 +249,7 @@ export class AgentManager {
|
|
|
253
249
|
dispatchSettleTimeoutMs;
|
|
254
250
|
dispatchAckResendIntervalMs = 3_000;
|
|
255
251
|
taskMutationQueue = Promise.resolve();
|
|
252
|
+
taskMutationScope = new AsyncLocalStorage();
|
|
256
253
|
agentOperationQueues = new Map();
|
|
257
254
|
agentIndex;
|
|
258
255
|
platformRunner;
|
|
@@ -273,10 +270,10 @@ export class AgentManager {
|
|
|
273
270
|
cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
|
|
274
271
|
markCompleteInFlight = new Set();
|
|
275
272
|
specVerdictInFlight = new Set();
|
|
276
|
-
|
|
273
|
+
codeVerdictInFlight = new Set();
|
|
274
|
+
retryTaskInFlight = new Set();
|
|
277
275
|
cancelCleanupInFlight = new Map();
|
|
278
276
|
deletionInFlight = new Set();
|
|
279
|
-
// Diverged ids live in a separate set the DELETE finally never clears: a post-commit replaceConfig throw must fail-stop for the whole process.
|
|
280
277
|
divergedAgents = new Set();
|
|
281
278
|
deletionGeneration = new Map();
|
|
282
279
|
compactInFlight = new Set();
|
|
@@ -294,6 +291,7 @@ export class AgentManager {
|
|
|
294
291
|
this.paneStreamerManager = deps.paneStreamerManager;
|
|
295
292
|
this.pluginRegistry = deps.pluginRegistry;
|
|
296
293
|
this.platformDefaultBranchOf = deps.platformDefaultBranchOf;
|
|
294
|
+
this.prConversationCache = deps.prConversationCache;
|
|
297
295
|
this.errorRecordStore = deps.errorRecordStore;
|
|
298
296
|
this.phaseSignalWatcher = deps.phaseSignalWatcher
|
|
299
297
|
?? (deps.paneStreamerManager
|
|
@@ -320,10 +318,26 @@ export class AgentManager {
|
|
|
320
318
|
};
|
|
321
319
|
}
|
|
322
320
|
withTaskLock(fn) {
|
|
323
|
-
const
|
|
321
|
+
const inherited = this.taskMutationScope.getStore();
|
|
322
|
+
if (inherited?.active)
|
|
323
|
+
return fn();
|
|
324
|
+
const next = this.taskMutationQueue.then(() => {
|
|
325
|
+
const scope = { active: true };
|
|
326
|
+
return this.taskMutationScope.run(scope, async () => {
|
|
327
|
+
try {
|
|
328
|
+
return await fn();
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
scope.active = false;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
324
335
|
this.taskMutationQueue = next.catch(() => undefined);
|
|
325
336
|
return next;
|
|
326
337
|
}
|
|
338
|
+
outsideTaskMutationScope(fn) {
|
|
339
|
+
return this.taskMutationScope.exit(fn);
|
|
340
|
+
}
|
|
327
341
|
withAgentOperationLease(agentId, fn) {
|
|
328
342
|
const previous = this.agentOperationQueues.get(agentId) ?? Promise.resolve();
|
|
329
343
|
const next = previous.then(fn);
|
|
@@ -386,7 +400,7 @@ export class AgentManager {
|
|
|
386
400
|
return true;
|
|
387
401
|
}
|
|
388
402
|
catch (err) {
|
|
389
|
-
console.warn(`[AgentManager] safeEmit ${event.type} failed
|
|
403
|
+
console.warn(`[AgentManager] safeEmit ${event.type} failed; persistence or handler delivery may be incomplete:`, err);
|
|
390
404
|
return false;
|
|
391
405
|
}
|
|
392
406
|
}
|
|
@@ -401,7 +415,6 @@ export class AgentManager {
|
|
|
401
415
|
data,
|
|
402
416
|
});
|
|
403
417
|
}
|
|
404
|
-
// 配置层 intervention(无 agent/task 上下文):如 repo-conflict——离线绕过锁的同 repo 冲突项目。
|
|
405
418
|
async emitConfigIntervention(projectId, data) {
|
|
406
419
|
const key = this.configInterventionKey(projectId, data);
|
|
407
420
|
if (this.configInterventionKeys.has(key))
|
|
@@ -487,7 +500,6 @@ export class AgentManager {
|
|
|
487
500
|
this.deletionInFlight.add(id);
|
|
488
501
|
return null;
|
|
489
502
|
}
|
|
490
|
-
// Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an open-task scan that a concurrent commit then orphans.
|
|
491
503
|
async scanOpenThenClaimDeletion(targets) {
|
|
492
504
|
return this.withTaskLock(async () => {
|
|
493
505
|
for (const id of targets) {
|
|
@@ -519,7 +531,6 @@ export class AgentManager {
|
|
|
519
531
|
for (const id of agentIds)
|
|
520
532
|
this.deletionInFlight.delete(id);
|
|
521
533
|
}
|
|
522
|
-
// strict 读取:守卫扫描不能把读失败折叠成「无活跃任务」放行提交
|
|
523
534
|
async listActiveParticipantSeats() {
|
|
524
535
|
const tasks = await this.taskStore.listStrict();
|
|
525
536
|
return tasks
|
|
@@ -541,21 +552,18 @@ export class AgentManager {
|
|
|
541
552
|
return { taskId: task.id, projectId: task.projectId, participants: [...byId.values()] };
|
|
542
553
|
});
|
|
543
554
|
}
|
|
544
|
-
// A completed DELETE keeps blocking this id past tombstone release; the process dies with any suspended flow, so no persistence is needed.
|
|
545
555
|
bumpDeletionGeneration(agentId) {
|
|
546
556
|
this.deletionGeneration.set(agentId, this.deletionGenerationOf(agentId) + 1);
|
|
547
557
|
}
|
|
548
558
|
deletionGenerationOf(agentId) {
|
|
549
559
|
return this.deletionGeneration.get(agentId) ?? 0;
|
|
550
560
|
}
|
|
551
|
-
// Boolean tombstone alone is ABA-prone (delete→recreate same id); commits must hold both the tombstone-clear and an unchanged generation from entry.
|
|
552
561
|
deletionGateOpen(agentId, genAtEntry) {
|
|
553
562
|
return !this.isDeletionInFlight(agentId) && this.deletionGenerationOf(agentId) === genAtEntry;
|
|
554
563
|
}
|
|
555
564
|
isDeletionInFlight(agentId) {
|
|
556
565
|
return this.deletionInFlight.has(agentId) || this.divergedAgents.has(agentId);
|
|
557
566
|
}
|
|
558
|
-
// replaceConfig 抛错后的 fail-stop:进入 divergedAgents(不随 DELETE finally 释放),重建/分配持续被拒直到重启。
|
|
559
567
|
retainTombstoneForDivergence(agentId) {
|
|
560
568
|
this.divergedAgents.add(agentId);
|
|
561
569
|
}
|
|
@@ -574,7 +582,6 @@ export class AgentManager {
|
|
|
574
582
|
}
|
|
575
583
|
const runner = this.createRunnerFor(agent);
|
|
576
584
|
const tmux = new TmuxManager(runner);
|
|
577
|
-
// Side-effects run inside the lifecycle section (serialized with DELETE cleanup) behind a re-checked generation gate; the entry gate is only a fast-fail.
|
|
578
585
|
return this.runUnderSessionLifecycle(agentId, () => this.ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry));
|
|
579
586
|
}
|
|
580
587
|
async ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry) {
|
|
@@ -592,7 +599,6 @@ export class AgentManager {
|
|
|
592
599
|
catch (err) {
|
|
593
600
|
throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
594
601
|
}
|
|
595
|
-
// Gate the Workdir write too: a DELETE→recreate during the slow ensureWorkdir must not persist a stale Workdir onto the new incarnation.
|
|
596
602
|
assertGenerationOpen();
|
|
597
603
|
await this.agentStore.update(agentId, (existing) => {
|
|
598
604
|
if (!existing || !this.deletionGateOpen(agentId, genAtEntry))
|
|
@@ -615,7 +621,6 @@ export class AgentManager {
|
|
|
615
621
|
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux probe failed: ${err instanceof Error ? err.message : String(err)}; ` +
|
|
616
622
|
`if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
|
|
617
623
|
}
|
|
618
|
-
// Re-gate after the probe await: a DELETE may have tombstoned mid-probe, and build/adopt would else create a session cleanup can only reap later.
|
|
619
624
|
assertGenerationOpen();
|
|
620
625
|
if (!snapshot) {
|
|
621
626
|
return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
|
|
@@ -651,11 +656,6 @@ export class AgentManager {
|
|
|
651
656
|
const subdir = skillSubdirFor(agent.runtime);
|
|
652
657
|
const destRoot = `${workdir}/${subdir}`;
|
|
653
658
|
await this.excludeInjectedSkills(runner, workdir, subdir);
|
|
654
|
-
// opencode's skills are model-invoked, not slash commands; the dispatched /<skill> sigil
|
|
655
|
-
// reaches the model as message text and it loads the skill from .opencode/skills. We do NOT
|
|
656
|
-
// wrap skills as .opencode/commands: opencode expands $ARGUMENTS in a command template and
|
|
657
|
-
// would execute a `!`cmd`` / inline a @path embedded in an untrusted task title/description
|
|
658
|
-
// before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
|
|
659
659
|
await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
|
|
660
660
|
await this.ensureSkillDirSafe(runner, workdir, subdir, [
|
|
661
661
|
...this.skillRegistry.names(),
|
|
@@ -664,7 +664,6 @@ export class AgentManager {
|
|
|
664
664
|
await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot, scope);
|
|
665
665
|
});
|
|
666
666
|
}
|
|
667
|
-
// A rollback only kills if no adoption bumped the generation since its create.
|
|
668
667
|
sessionLifecycleChain = new Map();
|
|
669
668
|
adoptGeneration = new Map();
|
|
670
669
|
runUnderSessionLifecycle(agentId, fn) {
|
|
@@ -678,7 +677,6 @@ export class AgentManager {
|
|
|
678
677
|
});
|
|
679
678
|
return cur;
|
|
680
679
|
}
|
|
681
|
-
// Inside the lifecycle lock, a generation bump since create means handoff — stand down.
|
|
682
680
|
async rollbackCreatedSession(agentId, partial, reason, opts = {}) {
|
|
683
681
|
await this.runUnderSessionLifecycle(agentId, async () => {
|
|
684
682
|
try {
|
|
@@ -715,7 +713,6 @@ export class AgentManager {
|
|
|
715
713
|
return;
|
|
716
714
|
}
|
|
717
715
|
const runner = this.createRunnerFor(cfg);
|
|
718
|
-
// Rollback may run before or after the claim write, so accept empty-or-ours.
|
|
719
716
|
const outcome = await new TmuxManager(runner).killSessionRef(ref, { kind: 'emptyOr', claim: agentId });
|
|
720
717
|
if (outcome === 'refused') {
|
|
721
718
|
console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux generation/claim changed — leaving session ${ref.sessionId} untouched`);
|
|
@@ -766,7 +763,7 @@ export class AgentManager {
|
|
|
766
763
|
const top = subdir.split('/')[0];
|
|
767
764
|
const keep = keepNames.map((n) => `! -name ${shellQuote(n)}`).join(' ');
|
|
768
765
|
const inner =
|
|
769
|
-
//
|
|
766
|
+
// Keep the canonical Workdir check and find/rm in one command to fail closed on a rebound ancestor.
|
|
770
767
|
`${canonicalSelfGuard(workdir)} && ` +
|
|
771
768
|
`cd ${shellQuote(workdir)} && ` +
|
|
772
769
|
`for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
|
|
@@ -795,9 +792,7 @@ export class AgentManager {
|
|
|
795
792
|
return tagged !== this.skillRegistry.contentHash(this.agentSkillScope(agentId));
|
|
796
793
|
}
|
|
797
794
|
async excludeInjectedSkills(runner, workdir, subdir) {
|
|
798
|
-
const inner =
|
|
799
|
-
// canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor too, so the info/exclude write can't escape.
|
|
800
|
-
`${canonicalSelfGuard(workdir)} && ` +
|
|
795
|
+
const inner = `${canonicalSelfGuard(workdir)} && ` +
|
|
801
796
|
`cd ${shellQuote(workdir)} && ` +
|
|
802
797
|
`if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
|
|
803
798
|
`tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
|
|
@@ -828,15 +823,12 @@ export class AgentManager {
|
|
|
828
823
|
['mouse', 'on'],
|
|
829
824
|
];
|
|
830
825
|
}
|
|
831
|
-
// A name (or even a bare $id) can rebind to a same-name successor mid-flight;
|
|
832
|
-
// every option write goes through the identity-checked by-ref batch.
|
|
833
826
|
async setSessionOptions(tmux, agentId, ref, entries, expectedClaim = agentId) {
|
|
834
827
|
const outcome = await tmux.setSessionOptionsIfAlive(ref, entries, { expectedClaim });
|
|
835
828
|
if (outcome === 'gone') {
|
|
836
829
|
throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
|
|
837
830
|
}
|
|
838
831
|
}
|
|
839
|
-
// Snapshot's claim check is a filter; the returned PaneRef re-proves generation+claim on every op.
|
|
840
832
|
async resolveClaimedPane(tmux, agentId, expectedPaneId) {
|
|
841
833
|
const snapshot = await tmux.getSessionSnapshot(agentId);
|
|
842
834
|
if (!snapshot)
|
|
@@ -861,7 +853,6 @@ export class AgentManager {
|
|
|
861
853
|
try {
|
|
862
854
|
createdRef = await tmux.createSession(agentId, sessionDir);
|
|
863
855
|
createdSession = true;
|
|
864
|
-
// First batch writes the claim itself, so it must prove the session is still unclaimed.
|
|
865
856
|
await this.setSessionOptions(tmux, agentId, createdRef, [
|
|
866
857
|
['@baxian-agent-id', agentId],
|
|
867
858
|
['@baxian-runtime', agent.runtime],
|
|
@@ -955,7 +946,7 @@ export class AgentManager {
|
|
|
955
946
|
}
|
|
956
947
|
}
|
|
957
948
|
await this.markDialogPending(agentId, creationToken);
|
|
958
|
-
void this.slowPollDialogPending(agentId, creationToken).catch((pollErr) => {
|
|
949
|
+
void this.outsideTaskMutationScope(() => this.slowPollDialogPending(agentId, creationToken)).catch((pollErr) => {
|
|
959
950
|
console.warn(`[bootstrap] slowPoll for ${agentId} crashed:`, pollErr);
|
|
960
951
|
});
|
|
961
952
|
return;
|
|
@@ -1038,7 +1029,6 @@ export class AgentManager {
|
|
|
1038
1029
|
const agent = this.getAgentConfig(agentId);
|
|
1039
1030
|
if (!agent)
|
|
1040
1031
|
return false;
|
|
1041
|
-
// Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the greeting handshake to the new session.
|
|
1042
1032
|
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
1043
1033
|
const state = await this.agentStore.get(agentId);
|
|
1044
1034
|
if (state?.awaitingPhase !== 'greeting_failed')
|
|
@@ -1170,7 +1160,6 @@ export class AgentManager {
|
|
|
1170
1160
|
if (!(err instanceof EnsureSessionError) || !err.partial.dialogPending) {
|
|
1171
1161
|
return false;
|
|
1172
1162
|
}
|
|
1173
|
-
// The error belongs to the caller's incarnation; gate every side-effect so a DELETE→recreate reusing %0 can't write the stale dialog/fail onto the new agent.
|
|
1174
1163
|
const gateOpen = () => opts.expectedGeneration === undefined || this.deletionGateOpen(agentId, opts.expectedGeneration);
|
|
1175
1164
|
let state = await this.agentStore.get(agentId);
|
|
1176
1165
|
if (!state || !gateOpen())
|
|
@@ -1230,10 +1219,10 @@ export class AgentManager {
|
|
|
1230
1219
|
err.partial.handled = true;
|
|
1231
1220
|
const snapshotPaneId = state.paneId;
|
|
1232
1221
|
const snapshotTaskId = state.taskId;
|
|
1233
|
-
void this.slowPollDialogPending(agentId, state.creationToken, {
|
|
1222
|
+
void this.outsideTaskMutationScope(() => this.slowPollDialogPending(agentId, state.creationToken, {
|
|
1234
1223
|
...(snapshotPaneId !== undefined ? { expectedPaneId: snapshotPaneId } : {}),
|
|
1235
1224
|
expectedTaskId: snapshotTaskId,
|
|
1236
|
-
}).catch((pollErr) => {
|
|
1225
|
+
})).catch((pollErr) => {
|
|
1237
1226
|
console.warn(`[runtime] slowPoll for ${agentId} crashed:`, pollErr);
|
|
1238
1227
|
});
|
|
1239
1228
|
return true;
|
|
@@ -1301,6 +1290,7 @@ export class AgentManager {
|
|
|
1301
1290
|
return;
|
|
1302
1291
|
const now = new Date().toISOString();
|
|
1303
1292
|
let projectIdForEmit = '';
|
|
1293
|
+
let taskIdForEmit;
|
|
1304
1294
|
let wrote = false;
|
|
1305
1295
|
const isBootstrapPath = creationToken !== undefined;
|
|
1306
1296
|
if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, pane))) {
|
|
@@ -1316,6 +1306,7 @@ export class AgentManager {
|
|
|
1316
1306
|
return AGENT_STORE_NOOP;
|
|
1317
1307
|
}
|
|
1318
1308
|
projectIdForEmit = fresh.projectId;
|
|
1309
|
+
taskIdForEmit = fresh.taskId;
|
|
1319
1310
|
wrote = true;
|
|
1320
1311
|
if (isBootstrapPath) {
|
|
1321
1312
|
return {
|
|
@@ -1329,11 +1320,15 @@ export class AgentManager {
|
|
|
1329
1320
|
updatedAt: now,
|
|
1330
1321
|
};
|
|
1331
1322
|
}
|
|
1323
|
+
const awaitingReason = fresh.taskId
|
|
1324
|
+
? `Runtime dialog resolved; agent REPL ready. Open task ${fresh.taskId}: cancel it if it is still active, ` +
|
|
1325
|
+
'otherwise Resume can release the binding; Delete the agent if the binding cannot be recovered.'
|
|
1326
|
+
: 'Runtime dialog resolved; agent REPL ready. Click Resume to release the binding and let baxian pick the next task.';
|
|
1332
1327
|
return {
|
|
1333
1328
|
...fresh,
|
|
1334
1329
|
paneId,
|
|
1335
1330
|
awaitingPhase: 'agent_dialog_resolved_runtime',
|
|
1336
|
-
awaitingReason
|
|
1331
|
+
awaitingReason,
|
|
1337
1332
|
updatedAt: now,
|
|
1338
1333
|
};
|
|
1339
1334
|
});
|
|
@@ -1350,9 +1345,13 @@ export class AgentManager {
|
|
|
1350
1345
|
});
|
|
1351
1346
|
}
|
|
1352
1347
|
else {
|
|
1353
|
-
|
|
1348
|
+
const note = taskIdForEmit
|
|
1349
|
+
? `Runtime dialog resolved; agent REPL ready. Open task ${taskIdForEmit}: cancel it if it is still active, ` +
|
|
1350
|
+
'otherwise Resume can release the binding; Delete the agent if the binding cannot be recovered.'
|
|
1351
|
+
: 'Runtime dialog resolved; agent REPL ready. Click Resume to continue.';
|
|
1352
|
+
await this.emitIntervention(projectIdForEmit, agentId, taskIdForEmit, {
|
|
1354
1353
|
phase: 'agent_dialog_resolved_runtime',
|
|
1355
|
-
note
|
|
1354
|
+
note,
|
|
1356
1355
|
});
|
|
1357
1356
|
}
|
|
1358
1357
|
return;
|
|
@@ -1363,7 +1362,7 @@ export class AgentManager {
|
|
|
1363
1362
|
return;
|
|
1364
1363
|
const controller = new AbortController();
|
|
1365
1364
|
this.runtimeMenuWatchers.set(agentId, controller);
|
|
1366
|
-
void this.runtimeMenuWatchLoop(agentId, controller.signal)
|
|
1365
|
+
void this.outsideTaskMutationScope(() => this.runtimeMenuWatchLoop(agentId, controller.signal))
|
|
1367
1366
|
.catch((err) => {
|
|
1368
1367
|
console.warn(`[runtimeMenuWatch] ${agentId} loop crashed:`, err);
|
|
1369
1368
|
})
|
|
@@ -1424,6 +1423,10 @@ export class AgentManager {
|
|
|
1424
1423
|
}
|
|
1425
1424
|
else if (!onMenu && pendingMenu) {
|
|
1426
1425
|
pendingMenu = false;
|
|
1426
|
+
await this.emitIntervention(fresh.projectId, agentId, fresh.taskId, {
|
|
1427
|
+
phase: 'agent_runtime_menu_resolved',
|
|
1428
|
+
previousPhase: 'agent_runtime_menu_pending',
|
|
1429
|
+
});
|
|
1427
1430
|
}
|
|
1428
1431
|
}
|
|
1429
1432
|
}
|
|
@@ -1440,7 +1443,6 @@ export class AgentManager {
|
|
|
1440
1443
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
1441
1444
|
});
|
|
1442
1445
|
}
|
|
1443
|
-
// Runs inside the caller's lifecycle section (ensureSessionLocked routed the snapshot here).
|
|
1444
1446
|
async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir, snapshot) {
|
|
1445
1447
|
const sessionRef = snapshot.ref;
|
|
1446
1448
|
this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
|
|
@@ -1582,8 +1584,6 @@ export class AgentManager {
|
|
|
1582
1584
|
throw new EnsureSessionError({ createdSession: false, agentId }, `REPL relaunch failed: ${relErr instanceof Error ? relErr.message : String(relErr)}`);
|
|
1583
1585
|
}
|
|
1584
1586
|
}
|
|
1585
|
-
// Frees a bootstrap blocked on its predecessor's half-created session (nonce
|
|
1586
|
-
// env present but claim never written); anything else is left alone.
|
|
1587
1587
|
async reclaimHalfCreatedSession(tmux, agentId, knownSnapshot) {
|
|
1588
1588
|
let snapshot;
|
|
1589
1589
|
try {
|
|
@@ -1607,7 +1607,6 @@ export class AgentManager {
|
|
|
1607
1607
|
return false;
|
|
1608
1608
|
let outcome;
|
|
1609
1609
|
try {
|
|
1610
|
-
// claim-still-empty rides the kill itself — a claim racing the probes makes the server refuse.
|
|
1611
1610
|
outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' });
|
|
1612
1611
|
}
|
|
1613
1612
|
catch (err) {
|
|
@@ -1642,14 +1641,10 @@ export class AgentManager {
|
|
|
1642
1641
|
console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
|
|
1643
1642
|
}
|
|
1644
1643
|
}
|
|
1645
|
-
// Create-on-null only under this predicate (evaluated inside the store's serialized commit): closes the delete→recreate ABA yet keeps lazy first-allocation for config-only agents.
|
|
1646
1644
|
bindingInitPredicate(agentId, genAtEntry) {
|
|
1647
1645
|
return this.deletionGateOpen(agentId, genAtEntry) && this.getAgentConfig(agentId) !== undefined;
|
|
1648
1646
|
}
|
|
1649
|
-
async acquireAgentForTask(agentId, taskId, phase,
|
|
1650
|
-
// 锁代必须由 acquire 原子交出:事后重读 claim/binding 的窗口里并发派发方可以
|
|
1651
|
-
// release + re-acquire 同一 agent/同一 task,读到的会是 successor 的代
|
|
1652
|
-
opts = {}) {
|
|
1647
|
+
async acquireAgentForTask(agentId, taskId, phase, opts = {}) {
|
|
1653
1648
|
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
1654
1649
|
if (this.isDeletionInFlight(agentId))
|
|
1655
1650
|
return false;
|
|
@@ -1769,15 +1764,12 @@ export class AgentManager {
|
|
|
1769
1764
|
return false;
|
|
1770
1765
|
}
|
|
1771
1766
|
}
|
|
1772
|
-
// expectedHold 是 hold 世代快照的严格 CAS(全 undefined 表示“期望无 hold”):
|
|
1773
|
-
// hold 消失(被并发 Resume 清掉)或换代同样意味着接管,不得继续释放
|
|
1774
1767
|
const matchesExpectedHold = (binding) => {
|
|
1775
1768
|
const held = binding.status === 'awaiting_human';
|
|
1776
1769
|
return (held ? binding.awaitingPhase : undefined) === opts.expectedHold.phase
|
|
1777
1770
|
&& (held ? binding.awaitingSince : undefined) === opts.expectedHold.since
|
|
1778
1771
|
&& (held ? binding.awaitingNonce : undefined) === opts.expectedHold.nonce;
|
|
1779
1772
|
};
|
|
1780
|
-
// 世代门前置:hold 换代时任何 pane/workdir 副作用都不得发生(末尾 CAS 只兜最后窗口)
|
|
1781
1773
|
if (opts.expectedHold && !matchesExpectedHold(state)) {
|
|
1782
1774
|
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed or cleared before release; refusing`);
|
|
1783
1775
|
return false;
|
|
@@ -1801,7 +1793,6 @@ export class AgentManager {
|
|
|
1801
1793
|
if (!latest)
|
|
1802
1794
|
return AGENT_STORE_NOOP;
|
|
1803
1795
|
if (opts.clearAwaitingHuman && latest.status === 'awaiting_human') {
|
|
1804
|
-
// The hold-generation CAS lives inside the atomic update: a hold written after any pre-read survives.
|
|
1805
1796
|
if (opts.expectedHold
|
|
1806
1797
|
&& (latest.awaitingPhase !== opts.expectedHold.phase
|
|
1807
1798
|
|| latest.awaitingSince !== opts.expectedHold.since
|
|
@@ -1819,8 +1810,6 @@ export class AgentManager {
|
|
|
1819
1810
|
...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
|
|
1820
1811
|
...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
|
|
1821
1812
|
...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
|
|
1822
|
-
// Release voids the open question: strip the watermark, bump the epoch so
|
|
1823
|
-
// any in-flight/queued commit of the old generation fences server-side.
|
|
1824
1813
|
needInput: { epoch: (latest.needInput?.epoch ?? 0) + 1 },
|
|
1825
1814
|
};
|
|
1826
1815
|
return cleared;
|
|
@@ -1852,7 +1841,6 @@ export class AgentManager {
|
|
|
1852
1841
|
|| !await this.lockManager.isOwner(agentId, expectedTaskId, lockToken)) {
|
|
1853
1842
|
return false;
|
|
1854
1843
|
}
|
|
1855
|
-
// 释放中被写入的新 hold(如 ack_unknown)比 cleanup 失败信息更关键,不得覆盖
|
|
1856
1844
|
if (!holdMatchesExpected(latest)) {
|
|
1857
1845
|
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed during release; not overwriting with branch-cleanup-pending`);
|
|
1858
1846
|
return false;
|
|
@@ -1918,7 +1906,6 @@ export class AgentManager {
|
|
|
1918
1906
|
sessionConfirmedAbsent = true;
|
|
1919
1907
|
}
|
|
1920
1908
|
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1921
|
-
// 仍重验持久化世代,防护当前进程租约之外的状态写入。
|
|
1922
1909
|
if (opts.expectedHold) {
|
|
1923
1910
|
const beforeBranchOps = await this.agentStore.get(agentId);
|
|
1924
1911
|
if (!beforeBranchOps || !matchesExpectedHold(beforeBranchOps)) {
|
|
@@ -1990,9 +1977,6 @@ export class AgentManager {
|
|
|
1990
1977
|
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1991
1978
|
}
|
|
1992
1979
|
catch (err) {
|
|
1993
|
-
// REPL 忙碌不是清理失败:pane 完好、绑定未动、非 dev 无分支凭据可留。落
|
|
1994
|
-
// branch-cleanup-pending(不在可恢复白名单)会把常态忙碌变成永久 hold(#558 验收 1),
|
|
1995
|
-
// 故拒绝释放而不落 hold,由调用方按「仍绑定且未 hold」判定重新排队本 pass
|
|
1996
1980
|
if (opts.deferWhenBusy && err instanceof ReplNotReadyError && cfg.role !== 'dev') {
|
|
1997
1981
|
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} REPL is busy; refusing release without a hold ` +
|
|
1998
1982
|
`(the pass can be re-queued): ${err.message}`);
|
|
@@ -2019,7 +2003,6 @@ export class AgentManager {
|
|
|
2019
2003
|
|| (state.workdir !== undefined && existing.workdir !== state.workdir)) {
|
|
2020
2004
|
return AGENT_STORE_NOOP;
|
|
2021
2005
|
}
|
|
2022
|
-
// 世代 CAS 在原子写入内判定:预读后被重写或清除的 hold 都不得被这次释放清掉
|
|
2023
2006
|
if (opts.expectedHold && !matchesExpectedHold(existing)) {
|
|
2024
2007
|
holdGenerationChanged = true;
|
|
2025
2008
|
return AGENT_STORE_NOOP;
|
|
@@ -2125,7 +2108,6 @@ export class AgentManager {
|
|
|
2125
2108
|
if (candidateTasks.length === 0)
|
|
2126
2109
|
continue;
|
|
2127
2110
|
const candidateByBranch = new Map(candidateTasks.map(task => [task.branch, task]));
|
|
2128
|
-
// Pin the incarnation before the first state/workdir/refs snapshot so a DELETE→same-id recreate (possibly a different workdir) in the read→gate window is rejected.
|
|
2129
2111
|
const genAtEntry = this.deletionGenerationOf(cfg.id);
|
|
2130
2112
|
const state = await this.agentStore.get(cfg.id);
|
|
2131
2113
|
if (!state || !canDispatchWithBinding(state))
|
|
@@ -2224,20 +2206,11 @@ export class AgentManager {
|
|
|
2224
2206
|
}
|
|
2225
2207
|
}
|
|
2226
2208
|
}
|
|
2227
|
-
// ---- need-input watermark: epoch-fenced monotonic persistence + retry convergence ----
|
|
2228
2209
|
needInputRetry = new Map();
|
|
2229
2210
|
needInputRetryInFlight = new Set();
|
|
2230
2211
|
gitReviewRecoveryAlerted = new Set();
|
|
2231
|
-
// Watermark writes currently in the store chain. A restore bump snapshots these along
|
|
2232
|
-
// with the queue: an answer whose write has not settled yet would otherwise be invisible
|
|
2233
|
-
// to the migration and its error-enqueue would land after the arm already cleared the
|
|
2234
|
-
// queue — deleting the only proof the user answered.
|
|
2235
2212
|
needInputWritesInFlight = new Map();
|
|
2236
2213
|
needInputWriteSeq = 0;
|
|
2237
|
-
// Serializes the probe→bump→queue-clear section of concurrent arms for one
|
|
2238
|
-
// (agentId, taskId): without it a stale replay probed before a successor's bump can
|
|
2239
|
-
// itself bump afterwards, ghost-fencing the installed successor. All locked steps are
|
|
2240
|
-
// bounded (memory probe + one store update); subscribe stays outside the lock.
|
|
2241
2214
|
needInputArmLocks = new Map();
|
|
2242
2215
|
needInputRetryTimer = null;
|
|
2243
2216
|
needInputRetryKey(agentId, taskId, epoch) {
|
|
@@ -2252,12 +2225,6 @@ export class AgentManager {
|
|
|
2252
2225
|
return null;
|
|
2253
2226
|
return { agentId, taskId, epoch };
|
|
2254
2227
|
}
|
|
2255
|
-
// Standard commit entry: one agentStore.update, NEVER called from inside another
|
|
2256
|
-
// updater (the store's per-id chain is non-reentrant).
|
|
2257
|
-
// The ledger entry lives until the result is fully handed over — an error is IN the
|
|
2258
|
-
// retry queue before deregistration, in the same synchronous continuation. Splitting
|
|
2259
|
-
// those two across promise jobs would open a window where neither the ledger nor the
|
|
2260
|
-
// queue holds the intent and a concurrent restore snapshot loses the answer.
|
|
2261
2228
|
async commitNeedInputWatermark(intent) {
|
|
2262
2229
|
const writeId = ++this.needInputWriteSeq;
|
|
2263
2230
|
this.needInputWritesInFlight.set(writeId, intent);
|
|
@@ -2325,8 +2292,6 @@ export class AgentManager {
|
|
|
2325
2292
|
});
|
|
2326
2293
|
this.ensureNeedInputRetryTimer();
|
|
2327
2294
|
}
|
|
2328
|
-
// ok deletes only when the queued item is still covered by what was written
|
|
2329
|
-
// (a concurrent edge may have raised it mid-flight); fenced is terminal either way.
|
|
2330
2295
|
settleNeedInputRetry(key, wroteAskSeq, wroteAnsweredSeq, result) {
|
|
2331
2296
|
const cur = this.needInputRetry.get(key);
|
|
2332
2297
|
if (!cur)
|
|
@@ -2356,7 +2321,7 @@ export class AgentManager {
|
|
|
2356
2321
|
if (this.needInputRetryTimer)
|
|
2357
2322
|
return;
|
|
2358
2323
|
this.needInputRetryTimer = setInterval(() => {
|
|
2359
|
-
void this.needInputRetryPass();
|
|
2324
|
+
void this.outsideTaskMutationScope(() => this.needInputRetryPass());
|
|
2360
2325
|
}, this.needInputRetryIntervalMs);
|
|
2361
2326
|
this.needInputRetryTimer.unref?.();
|
|
2362
2327
|
}
|
|
@@ -2411,9 +2376,6 @@ export class AgentManager {
|
|
|
2411
2376
|
}
|
|
2412
2377
|
return owed;
|
|
2413
2378
|
}
|
|
2414
|
-
// Only the generation this arm directly succeeds (bumps are +1 and serialized per key).
|
|
2415
|
-
// Anything older was already superseded — a fresh arm in between stripped its question
|
|
2416
|
-
// on purpose, and transplanting it would relight a prompt nobody asked.
|
|
2417
2379
|
collectPredecessorNeedInputDelta(agentId, taskId, epoch) {
|
|
2418
2380
|
let delta = null;
|
|
2419
2381
|
const merge = (wm) => {
|
|
@@ -2449,14 +2411,7 @@ export class AgentManager {
|
|
|
2449
2411
|
});
|
|
2450
2412
|
return run;
|
|
2451
2413
|
}
|
|
2452
|
-
// 'restore' must also merge queued retry seqs of the CURRENT persisted epoch: after
|
|
2453
|
-
// session-gone the queue can hold the only proof an answer happened (the entry is
|
|
2454
|
-
// gone, so needInputInherit has nothing to merge), and the arm clears the queue.
|
|
2455
2414
|
async bumpNeedInputEpochForArm(agentId, taskId, mode) {
|
|
2456
|
-
// Contributions are collected INSIDE the updater: it runs when the store chain
|
|
2457
|
-
// reaches this bump, so an answer whose commit started while the bump was queued
|
|
2458
|
-
// (registered in the ledger, or already error-queued) is still visible. A snapshot
|
|
2459
|
-
// taken at call time would miss exactly that window.
|
|
2460
2415
|
const collectContributions = () => {
|
|
2461
2416
|
const queued = new Map();
|
|
2462
2417
|
if (mode !== 'restore')
|
|
@@ -2507,16 +2462,10 @@ export class AgentManager {
|
|
|
2507
2462
|
}
|
|
2508
2463
|
catch (err) {
|
|
2509
2464
|
console.warn(`[AgentManager] needInput epoch bump failed (agent=${agentId} task=${taskId}):`, err);
|
|
2510
|
-
// Fall back to the persisted watermark for a restore: without it the entry would
|
|
2511
|
-
// start at 0/0 and swallow the answer to a question that is still open in the
|
|
2512
|
-
// store, leaving the badge lit forever. No bump means no generation advance —
|
|
2513
|
-
// the caller must not treat this as a fresh generation.
|
|
2514
2465
|
if (mode === 'restore') {
|
|
2515
2466
|
try {
|
|
2516
2467
|
const cur = await this.agentStore.get(agentId);
|
|
2517
2468
|
if (cur?.taskId === taskId && cur.needInput) {
|
|
2518
|
-
// Same-generation intents still owed to the store hold questions the
|
|
2519
|
-
// persisted watermark has not caught up with; merge them in.
|
|
2520
2469
|
const owed = this.collectSameEpochNeedInput(agentId, taskId, cur.needInput.epoch);
|
|
2521
2470
|
return {
|
|
2522
2471
|
wm: {
|
|
@@ -2536,16 +2485,12 @@ export class AgentManager {
|
|
|
2536
2485
|
}
|
|
2537
2486
|
return { wm, bumped: wm !== null };
|
|
2538
2487
|
}
|
|
2539
|
-
// Single entry for arming phase-signal watches — every path must flow through the
|
|
2540
|
-
// epoch bump, or stale-generation commits could survive the re-arm.
|
|
2541
2488
|
async startPhaseSignalWatch(args) {
|
|
2542
2489
|
if (!this.phaseSignalWatcher)
|
|
2543
2490
|
return true;
|
|
2544
2491
|
const { needInputMode, ...rest } = args;
|
|
2545
2492
|
const mode = needInputMode ?? 'restore';
|
|
2546
2493
|
const watcher = this.phaseSignalWatcher;
|
|
2547
|
-
// Claim ownership before bumping: a rejected arm must not bump (it would fence the
|
|
2548
|
-
// owner), and the claim keeps this arm visible to later probes while it subscribes.
|
|
2549
2494
|
const gateResult = await this.withNeedInputArmLock(args.agentId, args.taskId, async () => {
|
|
2550
2495
|
const claim = watcher.claimArm?.({
|
|
2551
2496
|
taskId: args.taskId,
|
|
@@ -2558,8 +2503,6 @@ export class AgentManager {
|
|
|
2558
2503
|
if (claim === null)
|
|
2559
2504
|
return 'rejected';
|
|
2560
2505
|
const bump = await this.bumpNeedInputEpochForArm(args.agentId, args.taskId, mode);
|
|
2561
|
-
// Only a real generation advance may drop the queue: same-generation intents
|
|
2562
|
-
// (bump failed, watermark read back) are still owed to the store.
|
|
2563
2506
|
if (bump.bumped)
|
|
2564
2507
|
this.clearNeedInputRetryFor(args.agentId, args.taskId);
|
|
2565
2508
|
return { ...bump, claim };
|
|
@@ -2567,28 +2510,22 @@ export class AgentManager {
|
|
|
2567
2510
|
if (gateResult === 'rejected')
|
|
2568
2511
|
return false;
|
|
2569
2512
|
const { wm, bumped, claim } = gateResult;
|
|
2570
|
-
// Same synchronous section as the bump result: old-generation answers must not slip
|
|
2571
|
-
// between the bump and the handoff (start() migrates the predecessor synchronously).
|
|
2572
2513
|
if (wm !== null && bumped && mode === 'restore') {
|
|
2573
2514
|
const delta = this.collectPredecessorNeedInputDelta(args.agentId, args.taskId, wm.epoch);
|
|
2574
2515
|
if (delta) {
|
|
2575
|
-
void this.commitNeedInputWatermark({
|
|
2516
|
+
void this.outsideTaskMutationScope(() => this.commitNeedInputWatermark({
|
|
2576
2517
|
agentId: args.agentId,
|
|
2577
2518
|
taskId: args.taskId,
|
|
2578
2519
|
epoch: wm.epoch,
|
|
2579
2520
|
askSeq: Math.max(delta.askSeq, wm.askSeq),
|
|
2580
2521
|
answeredSeq: Math.max(delta.answeredSeq, wm.answeredSeq),
|
|
2581
|
-
});
|
|
2522
|
+
}));
|
|
2582
2523
|
}
|
|
2583
2524
|
}
|
|
2584
|
-
// wm === null (no binding / store error): arm anyway with badge commits disabled —
|
|
2585
|
-
// the badge is display-only, the signal watch drives task progress.
|
|
2586
2525
|
try {
|
|
2587
2526
|
return await watcher.start({
|
|
2588
2527
|
...rest,
|
|
2589
2528
|
...(claim != null ? { armClaimId: claim } : {}),
|
|
2590
|
-
// Inherit is about restore semantics, not about having a generation: a degraded
|
|
2591
|
-
// restore still has to keep the predecessor's pending question in memory.
|
|
2592
2529
|
needInputInherit: mode === 'restore',
|
|
2593
2530
|
...(wm !== null ? { needInput: wm } : {}),
|
|
2594
2531
|
});
|
|
@@ -2597,10 +2534,6 @@ export class AgentManager {
|
|
|
2597
2534
|
watcher.releaseArm?.(claim);
|
|
2598
2535
|
}
|
|
2599
2536
|
}
|
|
2600
|
-
// Web-fallback confirm: settles the newest known question (store ∪ retry queue) as
|
|
2601
|
-
// answered in ONE store update — usable when no watch entry exists (pre-recovery,
|
|
2602
|
-
// arm failure, post-stop). Queue contributions are snapshotted synchronously; the
|
|
2603
|
-
// updater derives everything else from `existing`, so no nested store calls.
|
|
2604
2537
|
async confirmNeedInputAnswered(agentId) {
|
|
2605
2538
|
const queueAsk = new Map();
|
|
2606
2539
|
for (const [key, wm] of this.needInputRetry) {
|
|
@@ -2654,15 +2587,7 @@ export class AgentManager {
|
|
|
2654
2587
|
this.settleNeedInputRetry(this.needInputRetryKey(agentId, s.taskId, s.epoch), s.askSeq, s.askSeq, 'ok');
|
|
2655
2588
|
}
|
|
2656
2589
|
}
|
|
2657
|
-
// The user typed into the agent's terminal — its open question counts as answered.
|
|
2658
|
-
// The store fallback runs only when no watch entry exists (pre-recovery, arm failure,
|
|
2659
|
-
// post-stop): with a live entry it would race the entry's in-flight writes and could
|
|
2660
|
-
// confirm an ask that arrived after this input.
|
|
2661
2590
|
async notifyHumanTerminalInput(agentId) {
|
|
2662
|
-
// Rearm first: it settles entry state synchronously, so a question printed during
|
|
2663
|
-
// this call cannot be confirmed by mistake. The binding read only decides whether a
|
|
2664
|
-
// watcher of the CURRENT task took the input — a leftover entry of a finished task
|
|
2665
|
-
// must not suppress the store fallback.
|
|
2666
2591
|
const handled = await this.phaseSignalWatcher?.rearmNeedInput(agentId);
|
|
2667
2592
|
let taskId;
|
|
2668
2593
|
try {
|
|
@@ -2813,11 +2738,8 @@ export class AgentManager {
|
|
|
2813
2738
|
}
|
|
2814
2739
|
if ((state.awaitingPhase === 'dirty-workdir' || state.awaitingPhase === 'checkout-preparation-failed')
|
|
2815
2740
|
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
|
|
2816
|
-
// verdict 门禁状态没有在途派发:清掉 hold 后由 spec verdict 重新走完整转码,Resume 放行
|
|
2817
2741
|
&& boundTask.status !== 'spec-ready' && boundTask.status !== 'max_rounds') {
|
|
2818
2742
|
if (state.taskId && boundTask.qaAgentId === agentId) {
|
|
2819
|
-
// 停驻的 review pass 已死(任务离开 review):重派会伪造新 pass,只清 hold 并释放绑定;
|
|
2820
|
-
// 快照 hold/task 世代供释放路径 CAS,且不得借用 cancel-cleanup 的绕行凭据
|
|
2821
2743
|
if (boundTask.status !== 'review') {
|
|
2822
2744
|
return {
|
|
2823
2745
|
resumed: true,
|
|
@@ -2992,8 +2914,6 @@ export class AgentManager {
|
|
|
2992
2914
|
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
2993
2915
|
return { resumed: false, releasedBinding: false, reason };
|
|
2994
2916
|
}
|
|
2995
|
-
// 重派与首派同样不经 startSession 的 post-ack finalize:残留的 bootstrap 标记
|
|
2996
|
-
// 会让下一次 recover 把已送达的 code prompt 再判成未送达
|
|
2997
2917
|
const lockToken = (await this.agentStore.get(agentId))?.lockToken;
|
|
2998
2918
|
await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
|
|
2999
2919
|
}
|
|
@@ -3055,7 +2975,6 @@ export class AgentManager {
|
|
|
3055
2975
|
console.error(`[AgentManager] resumeAgent review redispatch failed for ${agentId}:`, err);
|
|
3056
2976
|
const reason = `QA review redispatch on Resume failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
3057
2977
|
const reviewTaskId = result.redispatchReviewTaskId;
|
|
3058
|
-
// 锁代未变(未被 successor re-acquire)才允许补挂;markAwaitingHuman 内的 CAS 原子复核同一条件
|
|
3059
2978
|
const restoreHoldIfStillOurs = async (phase, holdReason) => {
|
|
3060
2979
|
const after = await this.agentStore.get(agentId);
|
|
3061
2980
|
if (after?.taskId === reviewTaskId
|
|
@@ -3067,7 +2986,6 @@ export class AgentManager {
|
|
|
3067
2986
|
});
|
|
3068
2987
|
}
|
|
3069
2988
|
};
|
|
3070
|
-
// handled 只代表 startSession 已尝试落 hold;落库可能已失败,必须核实并按需补挂
|
|
3071
2989
|
if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
3072
2990
|
try {
|
|
3073
2991
|
await restoreHoldIfStillOurs(result.previousPhase ?? 'checkout-preparation-failed', reason);
|
|
@@ -3083,7 +3001,6 @@ export class AgentManager {
|
|
|
3083
3001
|
};
|
|
3084
3002
|
}
|
|
3085
3003
|
}
|
|
3086
|
-
// ack_unknown:prompt 可能已在跑,必须恢复 ack hold 的可见性,而非按普通失败补挂 checkout hold
|
|
3087
3004
|
if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
|
|
3088
3005
|
try {
|
|
3089
3006
|
await restoreHoldIfStillOurs('dispatch-failed:ack_unknown', `${err.message}. Prompt may still be running in the pane; verify before resuming.`);
|
|
@@ -3099,7 +3016,6 @@ export class AgentManager {
|
|
|
3099
3016
|
};
|
|
3100
3017
|
}
|
|
3101
3018
|
}
|
|
3102
|
-
// pass 被接管(token 轮换或任务离开 review)说明该 pass 已死,补挂会把新 pass 的 QA 打成 hold
|
|
3103
3019
|
let passSuperseded = false;
|
|
3104
3020
|
let recoveryReadFailed = false;
|
|
3105
3021
|
try {
|
|
@@ -3114,7 +3030,6 @@ export class AgentManager {
|
|
|
3114
3030
|
return { resumed: false, releasedBinding: false, reason };
|
|
3115
3031
|
try {
|
|
3116
3032
|
if (recoveryReadFailed) {
|
|
3117
|
-
// 读失败时无法证明接管,优先恢复可见性:锁代/绑定 CAS 全部由 markAwaitingHuman 原子判定
|
|
3118
3033
|
await this.markAwaitingHuman(agentId, result.previousPhase ?? 'checkout-preparation-failed', reason, {
|
|
3119
3034
|
expectedTaskId: reviewTaskId,
|
|
3120
3035
|
...(recoveryLockToken !== undefined ? { expectedLockToken: recoveryLockToken } : {}),
|
|
@@ -3391,7 +3306,6 @@ export class AgentManager {
|
|
|
3391
3306
|
const out = [];
|
|
3392
3307
|
for (const t of tasks) {
|
|
3393
3308
|
const bound = t.agentId === agentId || t.qaAgentId === agentId;
|
|
3394
|
-
// spec-ready 不豁免:approve/打回都依赖 dev Workdir,dev 丢失后停驻只是假活
|
|
3395
3309
|
if (t.status === 'merge-ready')
|
|
3396
3310
|
continue;
|
|
3397
3311
|
if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
|
|
@@ -3456,7 +3370,6 @@ export class AgentManager {
|
|
|
3456
3370
|
return last;
|
|
3457
3371
|
}
|
|
3458
3372
|
async restartReplOnly(agentId, opts = {}) {
|
|
3459
|
-
// Re-verify the entry generation so a request that slept across a DELETE→recreate can't restart the NEW incarnation's REPL (claim check alone can't tell).
|
|
3460
3373
|
const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
|
|
3461
3374
|
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
3462
3375
|
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
@@ -3491,7 +3404,6 @@ export class AgentManager {
|
|
|
3491
3404
|
const project = this.getProjectConfig(cfg.projectId);
|
|
3492
3405
|
if (!project)
|
|
3493
3406
|
throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
|
|
3494
|
-
// Re-verify before Workdir/skill/session-option side-effects: pane writes fail-closed on a stale PaneRef, but ensureWorkdir/skills would write old-cfg files under a recreated id.
|
|
3495
3407
|
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
3496
3408
|
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
3497
3409
|
}
|
|
@@ -3520,7 +3432,6 @@ export class AgentManager {
|
|
|
3520
3432
|
await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
|
|
3521
3433
|
};
|
|
3522
3434
|
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
3523
|
-
// Final writeback is generation-gated too, so a DELETE→recreate reusing %0 can't persist the old incarnation's paneId onto the new agent.
|
|
3524
3435
|
await this.agentStore.update(agentId, (state) => {
|
|
3525
3436
|
if (!state || !this.deletionGateOpen(agentId, genAtEntry))
|
|
3526
3437
|
return AGENT_STORE_NOOP;
|
|
@@ -3531,7 +3442,6 @@ export class AgentManager {
|
|
|
3531
3442
|
};
|
|
3532
3443
|
});
|
|
3533
3444
|
}
|
|
3534
|
-
// Holds only the pass captured in the task tuple: a successor pass (rotated token/phase/status) is never overwritten.
|
|
3535
3445
|
async holdReplayFailureIfCurrent(agentId, taskAtEntry, holdPhase, reason, entryHold, extraCurrent) {
|
|
3536
3446
|
return this.withTaskLock(async () => {
|
|
3537
3447
|
const fresh = await this.taskStore.get(taskAtEntry.id);
|
|
@@ -3547,7 +3457,6 @@ export class AgentManager {
|
|
|
3547
3457
|
expectedHold: entryHold,
|
|
3548
3458
|
}))
|
|
3549
3459
|
return true;
|
|
3550
|
-
// Entry hold gone (Resume raced): write over the empty generation, never over a foreign hold.
|
|
3551
3460
|
if (await this.markAwaitingHuman(agentId, holdPhase, reason, {
|
|
3552
3461
|
expectedTaskId: taskAtEntry.id,
|
|
3553
3462
|
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
@@ -3678,14 +3587,11 @@ export class AgentManager {
|
|
|
3678
3587
|
retireOldOnce();
|
|
3679
3588
|
}
|
|
3680
3589
|
}
|
|
3681
|
-
async redispatchTaskPromptAfterReplRestart(agentId, taskId
|
|
3590
|
+
async redispatchTaskPromptAfterReplRestart(agentId, taskId) {
|
|
3682
3591
|
const agent = this.getAgentConfig(agentId);
|
|
3683
3592
|
const loadedTask = await this.taskStore.get(taskId);
|
|
3684
3593
|
if (!agent || !loadedTask || !TASK_OWNER_ROLES.has(agent.role))
|
|
3685
3594
|
return false;
|
|
3686
|
-
if (expected && !taskMatchesGeneration(loadedTask, expected))
|
|
3687
|
-
return false;
|
|
3688
|
-
const rootGenerationMatches = (fresh, signalToken = expected?.signalToken) => !expected || taskMatchesGeneration(fresh, { ...expected, signalToken });
|
|
3689
3595
|
const task = loadedTask;
|
|
3690
3596
|
if (task.agentId !== agentId)
|
|
3691
3597
|
return false;
|
|
@@ -3702,8 +3608,7 @@ export class AgentManager {
|
|
|
3702
3608
|
return fresh?.agentId === agentId
|
|
3703
3609
|
&& fresh.status === task.status
|
|
3704
3610
|
&& fresh.phase === task.phase
|
|
3705
|
-
&& fresh.signalToken === signalToken
|
|
3706
|
-
&& rootGenerationMatches(fresh, signalToken);
|
|
3611
|
+
&& fresh.signalToken === signalToken;
|
|
3707
3612
|
};
|
|
3708
3613
|
const clearEntryHold = async () => this.withTaskLock(async () => {
|
|
3709
3614
|
if (!(await taskStillCurrent()))
|
|
@@ -3714,10 +3619,10 @@ export class AgentManager {
|
|
|
3714
3619
|
}
|
|
3715
3620
|
return this.clearAwaitingHuman(agentId, { expectedHold: entryHold });
|
|
3716
3621
|
});
|
|
3717
|
-
const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold
|
|
3622
|
+
const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold);
|
|
3718
3623
|
try {
|
|
3719
3624
|
if (task.status === 'approved' && agent.role === 'dev') {
|
|
3720
|
-
return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure
|
|
3625
|
+
return await this.replayApprovedHolderAfterReplRestart(agentId, task, entryHold, { clearEntryHold, holdReplayFailure });
|
|
3721
3626
|
}
|
|
3722
3627
|
const mapped = this.mapTaskStateToExpectedWatcher(task);
|
|
3723
3628
|
if (!mapped || mapped.agentId !== agentId)
|
|
@@ -3736,8 +3641,7 @@ export class AgentManager {
|
|
|
3736
3641
|
if (fresh?.agentId !== agentId
|
|
3737
3642
|
|| fresh.status !== task.status
|
|
3738
3643
|
|| fresh.phase !== task.phase
|
|
3739
|
-
|| fresh.signalToken !== signalToken
|
|
3740
|
-
|| !rootGenerationMatches(fresh, signalToken))
|
|
3644
|
+
|| fresh.signalToken !== signalToken)
|
|
3741
3645
|
return false;
|
|
3742
3646
|
const rotatePendingPr = mapped.expectedKinds.includes('pr-created')
|
|
3743
3647
|
&& fresh.pendingPrSignalToken === signalToken;
|
|
@@ -3750,7 +3654,7 @@ export class AgentManager {
|
|
|
3750
3654
|
return true;
|
|
3751
3655
|
},
|
|
3752
3656
|
stillCurrent: taskStillCurrent,
|
|
3753
|
-
holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold
|
|
3657
|
+
holdFailure: (newToken, reason) => this.holdReplayFailureIfCurrent(agentId, { ...task, signalToken: newToken }, 'restart-redispatch-failed', reason, entryHold),
|
|
3754
3658
|
continueWith: (newToken, fence) => this.continueSession(taskId, agentId, phase, {
|
|
3755
3659
|
...opts,
|
|
3756
3660
|
signalToken: newToken,
|
|
@@ -3773,7 +3677,6 @@ export class AgentManager {
|
|
|
3773
3677
|
});
|
|
3774
3678
|
};
|
|
3775
3679
|
if (task.status === 'in_progress' && task.phase === undefined && agent.role === 'dev') {
|
|
3776
|
-
// An undelivered bootstrap never started work, so the fresh-dispatch clean gate still applies.
|
|
3777
3680
|
const delivered = (await this.agentStore.get(agentId))?.bootstrappingTaskId !== taskId;
|
|
3778
3681
|
return await replayInitialBootstrap('develop', {
|
|
3779
3682
|
...(delivered ? { allowDirtyWorkdir: true } : {}),
|
|
@@ -3796,7 +3699,6 @@ export class AgentManager {
|
|
|
3796
3699
|
...(agent.role === 'dev' ? { allowDirtyWorkdir: true } : {}),
|
|
3797
3700
|
});
|
|
3798
3701
|
}
|
|
3799
|
-
// A direct (no-SDD) pass never persists a phase, so fixing + undefined is the plain code-feedback round.
|
|
3800
3702
|
if (task.status === 'fixing'
|
|
3801
3703
|
&& (task.phase === 'code' || task.phase === undefined)
|
|
3802
3704
|
&& agent.role === 'dev') {
|
|
@@ -3809,7 +3711,6 @@ export class AgentManager {
|
|
|
3809
3711
|
return false;
|
|
3810
3712
|
}
|
|
3811
3713
|
catch (err) {
|
|
3812
|
-
// A post-entry throw may land after clearEntryHold; only a tuple-checked hold on the live generation is safe.
|
|
3813
3714
|
const message = err instanceof Error ? err.message : String(err);
|
|
3814
3715
|
if (await holdReplayFailure('restart-redispatch-failed', `REPL restarted but replaying the task prompt failed: ${message}. Resume to retry, or cancel the task.`)) {
|
|
3815
3716
|
return true;
|
|
@@ -3817,49 +3718,9 @@ export class AgentManager {
|
|
|
3817
3718
|
throw err;
|
|
3818
3719
|
}
|
|
3819
3720
|
}
|
|
3820
|
-
async redispatchCurrentTaskPhase(taskId, expected) {
|
|
3821
|
-
const current = await this.taskStore.get(taskId);
|
|
3822
|
-
if (!current || !taskMatchesGeneration(current, expected))
|
|
3823
|
-
return 'stale';
|
|
3824
|
-
if (current.status === 'review') {
|
|
3825
|
-
let changedByRedispatch = false;
|
|
3826
|
-
try {
|
|
3827
|
-
await this.dispatchReviewToQa(taskId, {
|
|
3828
|
-
fromStatus: ['review'],
|
|
3829
|
-
bumpRound: false,
|
|
3830
|
-
expectSignalToken: current.signalToken,
|
|
3831
|
-
expectPhase: current.phase,
|
|
3832
|
-
expectedTask: expected,
|
|
3833
|
-
onSideEffect: () => { changedByRedispatch = true; },
|
|
3834
|
-
});
|
|
3835
|
-
return 'dispatched';
|
|
3836
|
-
}
|
|
3837
|
-
catch (err) {
|
|
3838
|
-
if (!changedByRedispatch && isBenignDispatchConflict(err))
|
|
3839
|
-
return 'stale';
|
|
3840
|
-
if (!changedByRedispatch
|
|
3841
|
-
&& err instanceof ApiError
|
|
3842
|
-
&& err.code === 'dispatch-unsupported') {
|
|
3843
|
-
return 'unsupported';
|
|
3844
|
-
}
|
|
3845
|
-
if (changedByRedispatch)
|
|
3846
|
-
throw err;
|
|
3847
|
-
const fresh = await this.taskStore.get(taskId);
|
|
3848
|
-
if (!fresh || !taskMatchesGeneration(fresh, expected))
|
|
3849
|
-
return 'stale';
|
|
3850
|
-
throw err;
|
|
3851
|
-
}
|
|
3852
|
-
}
|
|
3853
|
-
const redispatched = await this.redispatchTaskPromptAfterReplRestart(current.agentId, taskId, expected);
|
|
3854
|
-
if (redispatched)
|
|
3855
|
-
return 'dispatched';
|
|
3856
|
-
const fresh = await this.taskStore.get(taskId);
|
|
3857
|
-
return !fresh || !taskMatchesGeneration(fresh, expected) ? 'stale' : 'unsupported';
|
|
3858
|
-
}
|
|
3859
|
-
// approved hosts the post-approve holder pass.
|
|
3860
3721
|
async replayApprovedHolderAfterReplRestart(agentId, task, entryHold, holdGen) {
|
|
3861
3722
|
const taskId = task.id;
|
|
3862
|
-
const { clearEntryHold, holdReplayFailure
|
|
3723
|
+
const { clearEntryHold, holdReplayFailure } = holdGen;
|
|
3863
3724
|
if (task.postApproveRevoked) {
|
|
3864
3725
|
return holdReplayFailure('restart-redispatch-failed', `The post-approve completion was deliberately revoked (${task.postApproveRevoked.reason}); ` +
|
|
3865
3726
|
'the block stays effective, so the pass is not replayed. Re-request changes to restart the review round, or cancel the task.');
|
|
@@ -3871,7 +3732,6 @@ export class AgentManager {
|
|
|
3871
3732
|
return holdReplayFailure('restart-redispatch-failed', 'REPL restarted during the post-approve pass but the stored completion cannot be tied to a persisted approved head; re-request the review verdict or cancel the task.');
|
|
3872
3733
|
}
|
|
3873
3734
|
if (stored.approvedHeadSha !== task.postApproveHeadSha) {
|
|
3874
|
-
// Provably a dead episode's residue: retire it by token, then rebuild for the current head.
|
|
3875
3735
|
await this.clearPostApproveCompletionIfMatches(taskId, { generation: stored.generation, token: stored.token, headSha: stored.approvedHeadSha });
|
|
3876
3736
|
completion = await this.holdIncompleteGitPostApproveEpisode(holdReplayFailure);
|
|
3877
3737
|
}
|
|
@@ -3891,7 +3751,6 @@ export class AgentManager {
|
|
|
3891
3751
|
&& fresh.status === task.status
|
|
3892
3752
|
&& fresh.phase === task.phase
|
|
3893
3753
|
&& fresh.signalToken === task.signalToken
|
|
3894
|
-
&& rootGenerationMatches(fresh, task.signalToken)
|
|
3895
3754
|
&& fresh.postApproveRevoked === undefined
|
|
3896
3755
|
&& fresh.postApproveHeadSha === task.postApproveHeadSha
|
|
3897
3756
|
&& fresh.postApproveGeneration === task.postApproveGeneration
|
|
@@ -3952,8 +3811,8 @@ export class AgentManager {
|
|
|
3952
3811
|
const project = this.getProjectConfig(cfg.projectId);
|
|
3953
3812
|
if (!project)
|
|
3954
3813
|
throw new Error(`Unknown project: ${cfg.projectId}`);
|
|
3955
|
-
const
|
|
3956
|
-
return { targets:
|
|
3814
|
+
const team = this.findAgentTeam(agentId);
|
|
3815
|
+
return { targets: team?.map(agent => agent.id) ?? [agentId] };
|
|
3957
3816
|
}
|
|
3958
3817
|
async cleanupRemovedAgentRuntime(targets) {
|
|
3959
3818
|
const failures = [];
|
|
@@ -3963,7 +3822,6 @@ export class AgentManager {
|
|
|
3963
3822
|
continue;
|
|
3964
3823
|
const runner = this.createRunnerFor(cfg);
|
|
3965
3824
|
const tmux = new TmuxManager(runner);
|
|
3966
|
-
// No releaseAgentForTask: the binding dies with the state file in phase 3, and deletability was already enforced by the phase-1 API gates — so cleanup only stops watchers and kills the session.
|
|
3967
3825
|
this.stopRuntimeMenuWatch(id);
|
|
3968
3826
|
if (this.paneStreamerManager) {
|
|
3969
3827
|
try {
|
|
@@ -3990,7 +3848,6 @@ export class AgentManager {
|
|
|
3990
3848
|
return;
|
|
3991
3849
|
}
|
|
3992
3850
|
if (snapshot.claim === null) {
|
|
3993
|
-
// A nonce-owned unclaimed session is this project's own half-created leftover — reclaim it.
|
|
3994
3851
|
let nonceOwned;
|
|
3995
3852
|
try {
|
|
3996
3853
|
nonceOwned = await tmux.hasCreationNonce(id, cleanupOpts);
|
|
@@ -4054,13 +3911,13 @@ export class AgentManager {
|
|
|
4054
3911
|
const now = new Date().toISOString();
|
|
4055
3912
|
if (cfg.role !== 'dev')
|
|
4056
3913
|
throw new Error(`Agent ${cfg.id} is not a dev agent`);
|
|
4057
|
-
const
|
|
3914
|
+
const team = this.findAgentTeam(cfg.id);
|
|
4058
3915
|
const devAgentId = cfg.id;
|
|
4059
3916
|
const qaAgentId = input.preferredAgentId === ''
|
|
4060
3917
|
? '_unassigned_preview_qa'
|
|
4061
|
-
:
|
|
3918
|
+
: team?.find(agent => agent.role === 'qa')?.id;
|
|
4062
3919
|
if (!qaAgentId)
|
|
4063
|
-
throw new Error(`Agent ${cfg.id} has no qa agent in its
|
|
3920
|
+
throw new Error(`Agent ${cfg.id} has no qa agent in its Agent Team`);
|
|
4064
3921
|
const fakeTask = {
|
|
4065
3922
|
id: 'task-9999999999',
|
|
4066
3923
|
projectId,
|
|
@@ -4102,11 +3959,11 @@ export class AgentManager {
|
|
|
4102
3959
|
return undefined;
|
|
4103
3960
|
return buildProjectDriver(project, this.pluginRegistry, this.platformDriverExec);
|
|
4104
3961
|
}
|
|
4105
|
-
|
|
3962
|
+
findAgentTeam(anchorAgentId) {
|
|
4106
3963
|
for (const project of this.config.project) {
|
|
4107
|
-
for (const
|
|
4108
|
-
if (
|
|
4109
|
-
return
|
|
3964
|
+
for (const team of project.agent) {
|
|
3965
|
+
if (team.some(agent => agent.id === anchorAgentId))
|
|
3966
|
+
return team;
|
|
4110
3967
|
}
|
|
4111
3968
|
}
|
|
4112
3969
|
return undefined;
|
|
@@ -4117,8 +3974,8 @@ export class AgentManager {
|
|
|
4117
3974
|
return undefined;
|
|
4118
3975
|
let longest;
|
|
4119
3976
|
let longestBytes = -1;
|
|
4120
|
-
for (const
|
|
4121
|
-
for (const a of
|
|
3977
|
+
for (const team of project.agent) {
|
|
3978
|
+
for (const a of team) {
|
|
4122
3979
|
if (a.role !== 'dev' || !a.workdir)
|
|
4123
3980
|
continue;
|
|
4124
3981
|
const bytes = Buffer.byteLength(a.workdir, 'utf8');
|
|
@@ -4133,6 +3990,99 @@ export class AgentManager {
|
|
|
4133
3990
|
async getTask(taskId) {
|
|
4134
3991
|
return this.taskStore.get(taskId);
|
|
4135
3992
|
}
|
|
3993
|
+
async recordTaskAttention(event) {
|
|
3994
|
+
if (!event.taskId)
|
|
3995
|
+
return;
|
|
3996
|
+
await this.withTaskLock(async () => {
|
|
3997
|
+
const task = await this.taskStore.get(event.taskId);
|
|
3998
|
+
if (!task)
|
|
3999
|
+
return;
|
|
4000
|
+
const reasonValue = typeof event.data.phase === 'string'
|
|
4001
|
+
? event.data.phase
|
|
4002
|
+
: typeof event.data.reason === 'string'
|
|
4003
|
+
? event.data.reason
|
|
4004
|
+
: 'human-intervention';
|
|
4005
|
+
const reason = reasonValue.trim() || 'human-intervention';
|
|
4006
|
+
const previousPhase = typeof event.data.previousPhase === 'string'
|
|
4007
|
+
? event.data.previousPhase.trim()
|
|
4008
|
+
: '';
|
|
4009
|
+
const clearedReason = reason === 'mr-reopened'
|
|
4010
|
+
? 'mr-closed-unmerged'
|
|
4011
|
+
: reason === 'resumed'
|
|
4012
|
+
|| reason === 'git-review-dispatch-hold-cleared'
|
|
4013
|
+
|| reason === 'agent_runtime_menu_resolved'
|
|
4014
|
+
? previousPhase
|
|
4015
|
+
: '';
|
|
4016
|
+
if (clearedReason) {
|
|
4017
|
+
if (task.attention?.reason !== clearedReason
|
|
4018
|
+
|| Date.parse(event.timestamp) < Date.parse(task.attention.occurredAt))
|
|
4019
|
+
return;
|
|
4020
|
+
const next = { ...task, updatedAt: new Date().toISOString() };
|
|
4021
|
+
delete next.attention;
|
|
4022
|
+
await this.taskStore.set(next);
|
|
4023
|
+
return;
|
|
4024
|
+
}
|
|
4025
|
+
if (reason === 'resumed'
|
|
4026
|
+
|| reason === 'git-review-dispatch-hold-cleared'
|
|
4027
|
+
|| reason === 'agent_runtime_menu_resolved')
|
|
4028
|
+
return;
|
|
4029
|
+
const runbookValue = [
|
|
4030
|
+
event.data.note,
|
|
4031
|
+
event.data.recommendation,
|
|
4032
|
+
event.data.message,
|
|
4033
|
+
event.data.error,
|
|
4034
|
+
...(typeof event.data.phase === 'string' ? [event.data.reason] : []),
|
|
4035
|
+
]
|
|
4036
|
+
.find(value => typeof value === 'string' && value.trim() !== '');
|
|
4037
|
+
const runbook = typeof runbookValue === 'string'
|
|
4038
|
+
? runbookValue.trim()
|
|
4039
|
+
: `Task ${task.id} requires human intervention for ${reason}. Inspect the task and its linked agent before choosing an action.`;
|
|
4040
|
+
const occurredAt = Number.isFinite(Date.parse(event.timestamp))
|
|
4041
|
+
? event.timestamp
|
|
4042
|
+
: new Date().toISOString();
|
|
4043
|
+
const generation = taskAttentionGeneration(task);
|
|
4044
|
+
let recommendedActions;
|
|
4045
|
+
if (task.status === 'max_rounds') {
|
|
4046
|
+
recommendedActions = ['verdict', 'cancel'];
|
|
4047
|
+
}
|
|
4048
|
+
else if (TERMINAL_STATUSES.includes(task.status)) {
|
|
4049
|
+
recommendedActions = task.remoteCleanup !== undefined
|
|
4050
|
+
&& task.remoteCleanup.stage !== 'manual'
|
|
4051
|
+
? ['cancel']
|
|
4052
|
+
: ['retry'];
|
|
4053
|
+
}
|
|
4054
|
+
else if (task.status === 'review') {
|
|
4055
|
+
recommendedActions = isSpecStagePhase(task.phase)
|
|
4056
|
+
? ['advance', 'cancel']
|
|
4057
|
+
: ['advance', 'verdict', 'cancel'];
|
|
4058
|
+
}
|
|
4059
|
+
else if (task.status === 'spec-ready' || task.status === 'merge-ready') {
|
|
4060
|
+
recommendedActions = ['verdict', 'cancel'];
|
|
4061
|
+
}
|
|
4062
|
+
else {
|
|
4063
|
+
recommendedActions = ['advance', 'cancel'];
|
|
4064
|
+
}
|
|
4065
|
+
if (task.attention
|
|
4066
|
+
&& Date.parse(task.attention.occurredAt) > Date.parse(occurredAt))
|
|
4067
|
+
return;
|
|
4068
|
+
if (task.attention
|
|
4069
|
+
&& task.attention.reason === reason
|
|
4070
|
+
&& task.attention.runbook === runbook
|
|
4071
|
+
&& Date.parse(task.attention.occurredAt) >= Date.parse(occurredAt))
|
|
4072
|
+
return;
|
|
4073
|
+
await this.taskStore.set({
|
|
4074
|
+
...task,
|
|
4075
|
+
attention: {
|
|
4076
|
+
reason,
|
|
4077
|
+
runbook,
|
|
4078
|
+
occurredAt,
|
|
4079
|
+
recommendedActions,
|
|
4080
|
+
generation,
|
|
4081
|
+
},
|
|
4082
|
+
updatedAt: new Date().toISOString(),
|
|
4083
|
+
});
|
|
4084
|
+
});
|
|
4085
|
+
}
|
|
4136
4086
|
async getAgentState(agentId) {
|
|
4137
4087
|
return this.agentStore.get(agentId);
|
|
4138
4088
|
}
|
|
@@ -4143,8 +4093,6 @@ export class AgentManager {
|
|
|
4143
4093
|
const task = await this.taskStore.get(taskId);
|
|
4144
4094
|
return task ? this.completionOf(task) : null;
|
|
4145
4095
|
}
|
|
4146
|
-
// completion 与效果字段同居 TaskState:consumedFeedback/pendingRedispatch 与 token CAS
|
|
4147
|
-
// 必须同一次持久化(spec §6 双写协议)。
|
|
4148
4096
|
completionOf(task) {
|
|
4149
4097
|
if (task.postApproveToken === undefined || task.postApproveHeadSha === undefined
|
|
4150
4098
|
|| task.postApproveGeneration === undefined || task.postApprovePhase === undefined)
|
|
@@ -4221,15 +4169,30 @@ export class AgentManager {
|
|
|
4221
4169
|
delete next.reviewDispatch;
|
|
4222
4170
|
delete next.reviewRoundPending;
|
|
4223
4171
|
}
|
|
4224
|
-
if (nextStatus !== task.status
|
|
4225
|
-
|
|
4226
|
-
if (outbox.length > 0)
|
|
4227
|
-
next.outbox = outbox;
|
|
4228
|
-
else
|
|
4229
|
-
delete next.outbox;
|
|
4230
|
-
}
|
|
4172
|
+
if (nextStatus !== task.status)
|
|
4173
|
+
this.stripGitVerdictOutbox(next);
|
|
4231
4174
|
return next;
|
|
4232
4175
|
}
|
|
4176
|
+
stripGitVerdictOutbox(task) {
|
|
4177
|
+
if (!task.outbox)
|
|
4178
|
+
return;
|
|
4179
|
+
const outbox = task.outbox.filter(entry => entry.type !== 'git.spec-verdict' && entry.type !== 'git.code-verdict');
|
|
4180
|
+
if (outbox.length > 0)
|
|
4181
|
+
task.outbox = outbox;
|
|
4182
|
+
else
|
|
4183
|
+
delete task.outbox;
|
|
4184
|
+
}
|
|
4185
|
+
stripCodeVerdictOutboxForAnchor(task, anchorSha) {
|
|
4186
|
+
if (!task.outbox)
|
|
4187
|
+
return;
|
|
4188
|
+
const normalizedAnchor = anchorSha.toLowerCase();
|
|
4189
|
+
const outbox = task.outbox.filter(entry => entry.type !== 'git.code-verdict'
|
|
4190
|
+
|| entry.data.anchorSha.toLowerCase() === normalizedAnchor);
|
|
4191
|
+
if (outbox.length > 0)
|
|
4192
|
+
task.outbox = outbox;
|
|
4193
|
+
else
|
|
4194
|
+
delete task.outbox;
|
|
4195
|
+
}
|
|
4233
4196
|
async confirmPostApprovePromptDelivered(taskId, expected) {
|
|
4234
4197
|
await this.withTaskLock(async () => {
|
|
4235
4198
|
const task = await this.taskStore.get(taskId);
|
|
@@ -4268,7 +4231,6 @@ export class AgentManager {
|
|
|
4268
4231
|
this.phaseSignalWatcher?.stop(taskId);
|
|
4269
4232
|
});
|
|
4270
4233
|
}
|
|
4271
|
-
// CAS for an existing pass: never creates a record nor touches the marker, so it cannot resurrect a revoked completion.
|
|
4272
4234
|
async updatePostApproveCompletionIfToken(taskId, expectedToken, patch, opts = {}) {
|
|
4273
4235
|
return this.withTaskLock(async () => {
|
|
4274
4236
|
const task = await this.taskStore.get(taskId);
|
|
@@ -4289,13 +4251,11 @@ export class AgentManager {
|
|
|
4289
4251
|
return true;
|
|
4290
4252
|
});
|
|
4291
4253
|
}
|
|
4292
|
-
// Deliberate clear: the persisted marker keeps a restart replay from rebuilding the completion and bypassing the block.
|
|
4293
4254
|
async revokePostApproveCompletion(taskId, reason, opts = {}) {
|
|
4294
4255
|
return this.withTaskLock(async () => {
|
|
4295
4256
|
const task = await this.taskStore.get(taskId);
|
|
4296
4257
|
if (!task || task.status !== 'approved')
|
|
4297
4258
|
return false;
|
|
4298
|
-
// Head binds the revoke to one approved episode; an unpersisted head cannot disprove it, so blocking stays fail closed.
|
|
4299
4259
|
if (opts.expectedHeadSha !== undefined
|
|
4300
4260
|
&& task.postApproveHeadSha !== undefined
|
|
4301
4261
|
&& task.postApproveHeadSha !== opts.expectedHeadSha)
|
|
@@ -4333,7 +4293,6 @@ export class AgentManager {
|
|
|
4333
4293
|
return true;
|
|
4334
4294
|
});
|
|
4335
4295
|
}
|
|
4336
|
-
// Marker/token/head/pending are re-checked inside the committing critical section, so a concurrent revoke never loses.
|
|
4337
4296
|
async completeApprovedPassToMergeReady(taskId, expected) {
|
|
4338
4297
|
return this.withTaskLock(async () => {
|
|
4339
4298
|
const task = await this.taskStore.get(taskId);
|
|
@@ -4427,6 +4386,9 @@ export class AgentManager {
|
|
|
4427
4386
|
specVerdictOutbox(task) {
|
|
4428
4387
|
return task.outbox?.find((entry) => entry.type === 'git.spec-verdict');
|
|
4429
4388
|
}
|
|
4389
|
+
codeVerdictOutbox(task) {
|
|
4390
|
+
return task.outbox?.find((entry) => entry.type === 'git.code-verdict');
|
|
4391
|
+
}
|
|
4430
4392
|
async mutateTaskOutboxEntry(taskId, key, mutate) {
|
|
4431
4393
|
return this.withTaskLock(async () => {
|
|
4432
4394
|
const task = await this.taskStore.get(taskId);
|
|
@@ -4456,6 +4418,10 @@ export class AgentManager {
|
|
|
4456
4418
|
await this.deliverSpecVerdictOutbox(taskId, entry.key);
|
|
4457
4419
|
continue;
|
|
4458
4420
|
}
|
|
4421
|
+
if (entry.type === 'git.code-verdict') {
|
|
4422
|
+
await this.deliverCodeVerdictOutbox(taskId, entry.key);
|
|
4423
|
+
continue;
|
|
4424
|
+
}
|
|
4459
4425
|
await this.eventBus.emit({
|
|
4460
4426
|
id: `outbox:${entry.key}`,
|
|
4461
4427
|
type: 'human.intervention',
|
|
@@ -4498,8 +4464,6 @@ export class AgentManager {
|
|
|
4498
4464
|
headSha: completion.approvedHeadSha,
|
|
4499
4465
|
});
|
|
4500
4466
|
}
|
|
4501
|
-
// 启动期按「未引用」跳过 skill 池的插件,被热配置首次引用时同步重扫;仍不可物化即拒绝提交——
|
|
4502
|
-
// 可解析但物化为空的 tool 会让派发要求加载一个不存在的 skill。
|
|
4503
4467
|
async ensurePluginSkillPools(next) {
|
|
4504
4468
|
if (!this.pluginRegistry)
|
|
4505
4469
|
return;
|
|
@@ -4508,7 +4472,6 @@ export class AgentManager {
|
|
|
4508
4472
|
if (tool === undefined)
|
|
4509
4473
|
continue;
|
|
4510
4474
|
const plugin = this.pluginRegistry.resolveTool(tool);
|
|
4511
|
-
// 静默放行会存下一个「无 driver、无平台 skill、重启即 fatal」的配置(启动期同判据)
|
|
4512
4475
|
if (plugin === undefined) {
|
|
4513
4476
|
throw new Error(`project '${project.id}': no git-driver plugin provides tool '${tool}' — install it under ~/.baxian/plugins/ first`);
|
|
4514
4477
|
}
|
|
@@ -4550,7 +4513,6 @@ export class AgentManager {
|
|
|
4550
4513
|
&& task.failToken === lease.failToken
|
|
4551
4514
|
&& lease.effectiveRound === effectiveTaskReviewRound(task);
|
|
4552
4515
|
}
|
|
4553
|
-
// startSession 按 agent 的 projectId 解析 repo/workdir,项目归属不入判会跨仓库检出
|
|
4554
4516
|
gitReviewQaConfigUsable(task) {
|
|
4555
4517
|
const qaAgentId = requireTaskQaAgentId(task, 'gitReviewQaConfigUsable');
|
|
4556
4518
|
const config = this.getAgentConfig(qaAgentId);
|
|
@@ -4581,6 +4543,10 @@ export class AgentManager {
|
|
|
4581
4543
|
return null;
|
|
4582
4544
|
if (Object.hasOwn(opts, 'expectPhase') && current.phase !== opts.expectPhase)
|
|
4583
4545
|
return null;
|
|
4546
|
+
if (current.status === 'review'
|
|
4547
|
+
&& current.reviewHeadAnchorSha?.toLowerCase() === opts.headSha.toLowerCase()
|
|
4548
|
+
&& this.codeVerdictOutbox(current) !== undefined)
|
|
4549
|
+
return null;
|
|
4584
4550
|
if (opts.patch?.branch && opts.patch.branch !== current.branch) {
|
|
4585
4551
|
const existing = await this.findTaskByBranch(opts.patch.branch, current.projectId);
|
|
4586
4552
|
if (existing && existing.id !== taskId)
|
|
@@ -4601,6 +4567,7 @@ export class AgentManager {
|
|
|
4601
4567
|
? 'review'
|
|
4602
4568
|
: 'recheck');
|
|
4603
4569
|
const next = this.stripPostApproveEpisodeFields(current);
|
|
4570
|
+
this.stripGitVerdictOutbox(next);
|
|
4604
4571
|
delete next.passProvenance;
|
|
4605
4572
|
Object.assign(next, opts.patch ?? {}, {
|
|
4606
4573
|
status: 'review',
|
|
@@ -4670,6 +4637,7 @@ export class AgentManager {
|
|
|
4670
4637
|
return null;
|
|
4671
4638
|
const now = new Date().toISOString();
|
|
4672
4639
|
const next = this.stripPostApproveEpisodeFields(current);
|
|
4640
|
+
this.stripGitVerdictOutbox(next);
|
|
4673
4641
|
delete next.reviewDispatch;
|
|
4674
4642
|
delete next.reviewRoundPending;
|
|
4675
4643
|
const postApproveGeneration = createSignalToken();
|
|
@@ -4769,6 +4737,7 @@ export class AgentManager {
|
|
|
4769
4737
|
},
|
|
4770
4738
|
updatedAt: now,
|
|
4771
4739
|
};
|
|
4740
|
+
this.stripCodeVerdictOutboxForAnchor(next, verifiedHeadSha);
|
|
4772
4741
|
await this.taskStore.set(next);
|
|
4773
4742
|
if (qaHold !== undefined) {
|
|
4774
4743
|
const cleared = await this.clearAwaitingHuman(qaId, {
|
|
@@ -4814,6 +4783,9 @@ export class AgentManager {
|
|
|
4814
4783
|
}
|
|
4815
4784
|
next.reviewDispatchedAt = now;
|
|
4816
4785
|
delete next.reviewDispatch;
|
|
4786
|
+
if (next.attention?.reason === 'git-review-dispatch-recovery-uncertain') {
|
|
4787
|
+
delete next.attention;
|
|
4788
|
+
}
|
|
4817
4789
|
await this.taskStore.set(next);
|
|
4818
4790
|
return true;
|
|
4819
4791
|
});
|
|
@@ -4935,7 +4907,6 @@ export class AgentManager {
|
|
|
4935
4907
|
continue;
|
|
4936
4908
|
const qaAgentId = requireTaskQaAgentId(task, 'retryPendingGitReviewDispatches');
|
|
4937
4909
|
if (!this.gitReviewQaConfigUsable(task)) {
|
|
4938
|
-
// 快照可能已过期:告警前 live 复核;按 lease 代际告警一次,emit 成功才落闩
|
|
4939
4910
|
const latch = `qa-config:${task.id}:${lease.generation}`;
|
|
4940
4911
|
if (!this.gitReviewRecoveryAlerted.has(latch)) {
|
|
4941
4912
|
try {
|
|
@@ -5109,7 +5080,6 @@ export class AgentManager {
|
|
|
5109
5080
|
const tasks = await this.taskStore.list({ status: 'approved' });
|
|
5110
5081
|
for (const task of tasks) {
|
|
5111
5082
|
if (task.postApproveRevoked) {
|
|
5112
|
-
// Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
|
|
5113
5083
|
try {
|
|
5114
5084
|
if (task.postApproveToken !== undefined) {
|
|
5115
5085
|
await this.taskStore.set({
|
|
@@ -5230,7 +5200,6 @@ export class AgentManager {
|
|
|
5230
5200
|
}
|
|
5231
5201
|
}
|
|
5232
5202
|
}
|
|
5233
|
-
// 创建期快照(spec §4):git 任务在建任务那一刻定身份。
|
|
5234
5203
|
platformBindingFields(projectId) {
|
|
5235
5204
|
return { platformBinding: this.platformBindingFor(projectId) };
|
|
5236
5205
|
}
|
|
@@ -5247,7 +5216,6 @@ export class AgentManager {
|
|
|
5247
5216
|
tool,
|
|
5248
5217
|
};
|
|
5249
5218
|
}
|
|
5250
|
-
// agent 面一律 PATH 解析 tool;server 面 binary/env 不进入该轨。
|
|
5251
5219
|
agentGitPreflightContext(projectId) {
|
|
5252
5220
|
const project = this.getProjectConfig(projectId);
|
|
5253
5221
|
if (!project || !this.pluginRegistry)
|
|
@@ -5268,8 +5236,6 @@ export class AgentManager {
|
|
|
5268
5236
|
driverFor: (exec) => new GitDriver(plugin, renderCtx, exec),
|
|
5269
5237
|
};
|
|
5270
5238
|
}
|
|
5271
|
-
// cli 字段族每次派发实时渲染,不做创建时快照。渲染前按持久化 binding 校验身份——
|
|
5272
|
-
// 离线漂移的配置会把旧任务的 PR 号/分支/令牌带到新仓库,与平台操作同一守卫。
|
|
5273
5239
|
platformCliContextOf(task) {
|
|
5274
5240
|
this.assertPlatformBinding(task);
|
|
5275
5241
|
const project = this.getProjectConfig(task.projectId);
|
|
@@ -5287,13 +5253,11 @@ export class AgentManager {
|
|
|
5287
5253
|
...(project.gitCli?.notes !== undefined ? { notes: project.gitCli.notes } : {}),
|
|
5288
5254
|
};
|
|
5289
5255
|
}
|
|
5290
|
-
// base 快照一经持久化不可变;无快照保持缺省,由 agent 现场显式查询。
|
|
5291
5256
|
async ensureGitBaseSnapshot(task, phase) {
|
|
5292
5257
|
if (phase !== 'develop' && phase !== 'code')
|
|
5293
5258
|
return task;
|
|
5294
5259
|
if (task.baseBranch !== undefined)
|
|
5295
5260
|
return task;
|
|
5296
|
-
// 读默认分支与落快照都以 binding 为前提:失配任务先写入新仓库的 base 会永久污染不可变快照
|
|
5297
5261
|
this.assertPlatformBinding(task);
|
|
5298
5262
|
const base = this.platformDefaultBranchOf?.(task.projectId);
|
|
5299
5263
|
if (base === undefined)
|
|
@@ -5310,20 +5274,28 @@ export class AgentManager {
|
|
|
5310
5274
|
return next;
|
|
5311
5275
|
});
|
|
5312
5276
|
}
|
|
5313
|
-
async noteReviewConversationRevision(taskId) {
|
|
5277
|
+
async noteReviewConversationRevision(taskId, conversation) {
|
|
5314
5278
|
await this.withTaskLock(async () => {
|
|
5315
5279
|
const task = await this.taskStore.get(taskId);
|
|
5316
5280
|
if (!task || TERMINAL_STATUSES.includes(task.status))
|
|
5317
5281
|
return;
|
|
5318
5282
|
const now = new Date().toISOString();
|
|
5319
|
-
|
|
5283
|
+
const next = { ...task, reviewConversationUpdatedAt: now, updatedAt: now };
|
|
5284
|
+
const repoKey = next.platformBinding?.repoKey;
|
|
5285
|
+
if (conversation !== undefined
|
|
5286
|
+
&& this.prConversationCache !== undefined
|
|
5287
|
+
&& repoKey !== undefined
|
|
5288
|
+
&& next.prNumber === conversation.prNumber) {
|
|
5289
|
+
this.prConversationCache.put(next.id, prReviewCacheRevision(next, repoKey), conversation.payload, next.reviewConversationUpdatedAt ?? '');
|
|
5290
|
+
}
|
|
5291
|
+
await this.taskStore.set(next);
|
|
5320
5292
|
});
|
|
5321
5293
|
}
|
|
5322
5294
|
assertPlatformBinding(task) {
|
|
5323
5295
|
const mismatch = platformBindingMismatch(this.config, task);
|
|
5324
5296
|
if (mismatch === undefined)
|
|
5325
5297
|
return;
|
|
5326
|
-
void this.emitPlatformBindingIntervention(task, mismatch);
|
|
5298
|
+
void this.outsideTaskMutationScope(() => this.emitPlatformBindingIntervention(task, mismatch));
|
|
5327
5299
|
if (mismatch.reason === 'missing-binding-snapshot') {
|
|
5328
5300
|
throw new Error(`platform binding missing for task ${task.id}; refusing platform operations`);
|
|
5329
5301
|
}
|
|
@@ -5339,7 +5311,6 @@ export class AgentManager {
|
|
|
5339
5311
|
throw new Error(`no git driver resolvable for project ${task.projectId}`);
|
|
5340
5312
|
return { task, driver };
|
|
5341
5313
|
}
|
|
5342
|
-
// 配置锁也覆盖终态任务尚未完成的远端清理;否则 cancel 落 intent 后即可改写/删除其 binding。
|
|
5343
5314
|
async listActiveGitTasks(projectId) {
|
|
5344
5315
|
const tasks = await this.taskStore.listStrict(projectId !== undefined ? { projectId } : undefined);
|
|
5345
5316
|
return tasks.filter(t => (t.remoteCleanup !== undefined && t.remoteCleanup.stage !== 'manual')
|
|
@@ -5352,8 +5323,6 @@ export class AgentManager {
|
|
|
5352
5323
|
const [row] = await driver.runOp('prView', { prNumber: task.prNumber });
|
|
5353
5324
|
return row;
|
|
5354
5325
|
}
|
|
5355
|
-
// git 模式的 head 刷新一律经任务解析出的 driver(spec §4 两执行面):QA checkout 的
|
|
5356
|
-
// moved-head fallback 不得旁路到硬编码 gh,否则自定义/非 github driver 会错误调用 gh。
|
|
5357
5326
|
async platformFetchPrHeadSha(taskId) {
|
|
5358
5327
|
const headSha = String((await this.platformFetchPrView(taskId)).headSha);
|
|
5359
5328
|
if (!PLATFORM_HEAD_SHA_RE.test(headSha)) {
|
|
@@ -5863,8 +5832,12 @@ export class AgentManager {
|
|
|
5863
5832
|
return null;
|
|
5864
5833
|
if (task.prNumber !== undefined && task.prNumber !== observation.prNumber)
|
|
5865
5834
|
return null;
|
|
5835
|
+
const repairsDeliveryConfirmation = confirmation?.source === 'human'
|
|
5836
|
+
&& (task.status === 'review' || task.status === 'fixing')
|
|
5837
|
+
&& Object.hasOwn(confirmation, 'expectedSignalToken');
|
|
5866
5838
|
if (confirmation !== undefined) {
|
|
5867
5839
|
if (task.status !== 'in_progress'
|
|
5840
|
+
&& !repairsDeliveryConfirmation
|
|
5868
5841
|
&& (task.phase !== confirmation.phase
|
|
5869
5842
|
|| task.deliveryConfirmation?.phase !== confirmation.phase))
|
|
5870
5843
|
return null;
|
|
@@ -5891,7 +5864,7 @@ export class AgentManager {
|
|
|
5891
5864
|
...(confirmation !== undefined
|
|
5892
5865
|
? {
|
|
5893
5866
|
phase: confirmation.phase,
|
|
5894
|
-
deliveryConfirmation: task.status === 'in_progress'
|
|
5867
|
+
deliveryConfirmation: task.status === 'in_progress' || repairsDeliveryConfirmation
|
|
5895
5868
|
? { phase: confirmation.phase, source: confirmation.source, at: now }
|
|
5896
5869
|
: task.deliveryConfirmation,
|
|
5897
5870
|
...(confirmation.branch !== undefined ? { branch: confirmation.branch } : {}),
|
|
@@ -5997,8 +5970,6 @@ export class AgentManager {
|
|
|
5997
5970
|
return this.repoCache;
|
|
5998
5971
|
}
|
|
5999
5972
|
async rollbackFailedDispatch(taskId, agentId, reason, expectedLockToken) {
|
|
6000
|
-
// Pin the incarnation: task rollback / intervention below await, and a DELETE→same-id recreate in that
|
|
6001
|
-
// window must not let the final state write-back resurrect the deleted agent.
|
|
6002
5973
|
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
6003
5974
|
const existing = await this.agentStore.get(agentId);
|
|
6004
5975
|
if (existing && existing.taskId !== undefined && existing.taskId !== taskId) {
|
|
@@ -6046,8 +6017,6 @@ export class AgentManager {
|
|
|
6046
6017
|
else {
|
|
6047
6018
|
const now = new Date().toISOString();
|
|
6048
6019
|
await this.agentStore.update(agentId, (latest) => {
|
|
6049
|
-
// Never resurrect a deleted/recreated incarnation: if the state is gone or the generation moved
|
|
6050
|
-
// since entry, this rollback is stale — clear nothing rather than rebuild from the old snapshot.
|
|
6051
6020
|
if (!latest || !this.deletionGateOpen(agentId, genAtEntry))
|
|
6052
6021
|
return AGENT_STORE_NOOP;
|
|
6053
6022
|
if (latest.taskId === taskId && latest.lockToken !== expectedLockToken)
|
|
@@ -6145,17 +6114,15 @@ export class AgentManager {
|
|
|
6145
6114
|
};
|
|
6146
6115
|
return this.persistQueuedTask(unassigned, 'unassigned');
|
|
6147
6116
|
}
|
|
6148
|
-
// Capture generation before the first config/group read so a DELETE→same-id recreate in the read→commit window fails the gate.
|
|
6149
6117
|
const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
|
|
6150
6118
|
const target = await this.pickAgent(projectId, input.preferredAgentId);
|
|
6151
|
-
const
|
|
6152
|
-
const dev =
|
|
6119
|
+
const team = this.findAgentTeam(input.preferredAgentId);
|
|
6120
|
+
const dev = team?.find(agent => agent.role === 'dev');
|
|
6153
6121
|
if (!dev)
|
|
6154
|
-
throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its
|
|
6155
|
-
const qa =
|
|
6122
|
+
throw new ApiError(409, `Agent ${input.preferredAgentId} has no dev agent in its Agent Team`);
|
|
6123
|
+
const qa = team?.find(agent => agent.role === 'qa');
|
|
6156
6124
|
if (!qa)
|
|
6157
|
-
throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its
|
|
6158
|
-
// Pin EVERY participant's generation: a member (e.g. QA) deleted→reintroduced after the snapshot leaves isDeletionInFlight false and the preferred generation unchanged, yet the task would still carry its stale id.
|
|
6125
|
+
throw new ApiError(409, `Agent ${input.preferredAgentId} has no qa agent in its Agent Team`);
|
|
6159
6126
|
const participantGens = new Map([dev.id, qa.id]
|
|
6160
6127
|
.map(id => [id, this.deletionGenerationOf(id)]));
|
|
6161
6128
|
const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
|
|
@@ -6168,11 +6135,9 @@ export class AgentManager {
|
|
|
6168
6135
|
qaAgentId: qa.id,
|
|
6169
6136
|
status: 'pending',
|
|
6170
6137
|
};
|
|
6171
|
-
// A DELETE→recreate of the preferred OR any participant after the group snapshot must not persist a
|
|
6172
|
-
// pending task carrying its stale devAgentId/qaAgentId; reject and let the client re-select.
|
|
6173
6138
|
const persistQueuedOrReject = (reason) => {
|
|
6174
6139
|
if (!participantsFresh()) {
|
|
6175
|
-
throw new ApiError(409, `Agent ${input.preferredAgentId} or
|
|
6140
|
+
throw new ApiError(409, `Agent ${input.preferredAgentId} or an Agent Team member is being deleted or recreated during task creation; please retry`);
|
|
6176
6141
|
}
|
|
6177
6142
|
return this.persistQueuedTask(queued, reason, input.preferredAgentId);
|
|
6178
6143
|
};
|
|
@@ -6186,7 +6151,6 @@ export class AgentManager {
|
|
|
6186
6151
|
if (!lockToken) {
|
|
6187
6152
|
return persistQueuedOrReject('agent_locked');
|
|
6188
6153
|
}
|
|
6189
|
-
// Three-step transaction (queued task → binding → in_progress): any failure leaves at worst a queued task and/or a stale binding to it, both reclaimable — never an active task with no binding.
|
|
6190
6154
|
const releaseLock = async () => {
|
|
6191
6155
|
try {
|
|
6192
6156
|
await this.lockManager.releaseIfOwner(target.id, taskId, lockToken);
|
|
@@ -6219,7 +6183,6 @@ export class AgentManager {
|
|
|
6219
6183
|
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
6220
6184
|
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
6221
6185
|
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
6222
|
-
// A new task never inherits the previous one's question: void the watermark.
|
|
6223
6186
|
needInput: { epoch: (existing?.needInput?.epoch ?? 0) + 1 },
|
|
6224
6187
|
};
|
|
6225
6188
|
});
|
|
@@ -6232,7 +6195,6 @@ export class AgentManager {
|
|
|
6232
6195
|
await releaseLock();
|
|
6233
6196
|
return (await this.taskStore.get(taskId)) ?? queued;
|
|
6234
6197
|
}
|
|
6235
|
-
// Binding-before-active: a DELETE slipping in after the binding commit sees a still-'pending' task and can rotate the lock; re-verify lock ownership + preferred/participant generations before the active write, else roll back.
|
|
6236
6198
|
if (this.isDeletionInFlight(target.id)
|
|
6237
6199
|
|| !this.deletionGateOpen(target.id, genAtEntry)
|
|
6238
6200
|
|| !participantsFresh()
|
|
@@ -6285,6 +6247,9 @@ export class AgentManager {
|
|
|
6285
6247
|
}
|
|
6286
6248
|
async createAndStartTask(projectId, input, opts = {}) {
|
|
6287
6249
|
const task = await this.createTask(projectId, input);
|
|
6250
|
+
return this.startCreatedTask(task, opts);
|
|
6251
|
+
}
|
|
6252
|
+
async startCreatedTask(task, opts = {}) {
|
|
6288
6253
|
if (task.status === 'in_progress' && task.agentId) {
|
|
6289
6254
|
const signalToken = createSignalToken();
|
|
6290
6255
|
await this.updateTask(task.id, this.dispatchTokenFields(signalToken));
|
|
@@ -6378,8 +6343,6 @@ export class AgentManager {
|
|
|
6378
6343
|
const cfg = this.getAgentConfig(agentId);
|
|
6379
6344
|
if (!cfg)
|
|
6380
6345
|
throw new ApiError(404, `Unknown agent: ${agentId}`);
|
|
6381
|
-
// Pin the incarnation before reading paneId: paneId equality is ABA-blind (a recreate reuses %0), so a
|
|
6382
|
-
// DELETE→same-id recreate during the upload would otherwise paste the stale image into the new session.
|
|
6383
6346
|
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
6384
6347
|
const state = await this.agentStore.get(agentId);
|
|
6385
6348
|
const paneId = state?.paneId;
|
|
@@ -6413,7 +6376,6 @@ export class AgentManager {
|
|
|
6413
6376
|
await assertUploadStillValid();
|
|
6414
6377
|
const tmux = new TmuxManager(runner);
|
|
6415
6378
|
const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
|
|
6416
|
-
// resolveClaimedPane resolved by the CURRENT same-name session; re-verify once more before injecting so a DELETE→recreate in that gap can't take the paste.
|
|
6417
6379
|
await assertUploadStillValid();
|
|
6418
6380
|
await tmux.injectPrompt(pane, `${path} `, agentId);
|
|
6419
6381
|
return { path };
|
|
@@ -6465,7 +6427,7 @@ export class AgentManager {
|
|
|
6465
6427
|
await tmux.sendKeysLiteral(pane, command);
|
|
6466
6428
|
await tmux.sendEnter(pane);
|
|
6467
6429
|
guardHandedOff = true;
|
|
6468
|
-
void this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs)
|
|
6430
|
+
void this.outsideTaskMutationScope(() => this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs))
|
|
6469
6431
|
.catch(err => {
|
|
6470
6432
|
console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) post idle wait failed:`, err);
|
|
6471
6433
|
})
|
|
@@ -6486,9 +6448,6 @@ export class AgentManager {
|
|
|
6486
6448
|
}
|
|
6487
6449
|
registerPendingDispatchRetry(taskId, entry, budgetOverride) {
|
|
6488
6450
|
const prev = this.pendingDispatchRetryByTask.get(taskId);
|
|
6489
|
-
// 预算随登记代走:同 kind 但 pass 换代(token/agent 变化)时必须重置 since 与告警标志。
|
|
6490
|
-
// budgetOverride 供对账内部重排延续同一笔欠投递的预算——它只经 startSession 的 armedToken
|
|
6491
|
-
// fence 注入,外部 successor 的登记结构上拿不到
|
|
6492
6451
|
const sameGeneration = prev?.kind === entry.kind
|
|
6493
6452
|
&& prev.agentId === entry.agentId
|
|
6494
6453
|
&& prev.signalToken === entry.signalToken;
|
|
@@ -6511,6 +6470,16 @@ export class AgentManager {
|
|
|
6511
6470
|
clearPendingDispatchRetry(taskId) {
|
|
6512
6471
|
this.pendingDispatchRetryByTask.delete(taskId);
|
|
6513
6472
|
}
|
|
6473
|
+
resetPendingDispatchRetryBudget(taskId) {
|
|
6474
|
+
const entry = this.pendingDispatchRetryByTask.get(taskId);
|
|
6475
|
+
if (!entry)
|
|
6476
|
+
return;
|
|
6477
|
+
this.pendingDispatchRetryByTask.set(taskId, {
|
|
6478
|
+
...entry,
|
|
6479
|
+
since: Date.now(),
|
|
6480
|
+
budgetAlerted: undefined,
|
|
6481
|
+
});
|
|
6482
|
+
}
|
|
6514
6483
|
clearPendingDispatchRetryIfMatches(taskId, expected) {
|
|
6515
6484
|
const entry = this.pendingDispatchRetryByTask.get(taskId);
|
|
6516
6485
|
if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
|
|
@@ -6528,8 +6497,6 @@ export class AgentManager {
|
|
|
6528
6497
|
return null;
|
|
6529
6498
|
if (phase !== 'review' && phase !== 'recheck')
|
|
6530
6499
|
return null;
|
|
6531
|
-
// 无本次派发的 pass 令牌就不登记(宁走既有失败路径,不造无 fence 重试);
|
|
6532
|
-
// 令牌漂移说明 pass 已被接管,忙碌属于旧派发,不得登记到 successor 上
|
|
6533
6500
|
const passToken = dispatch.passToken;
|
|
6534
6501
|
if (!passToken)
|
|
6535
6502
|
return null;
|
|
@@ -6652,7 +6619,6 @@ export class AgentManager {
|
|
|
6652
6619
|
error: `Task ${taskId} has no preferredAgentId; agentId is required in request body`,
|
|
6653
6620
|
};
|
|
6654
6621
|
}
|
|
6655
|
-
// Capture generation before the first config/state read so a DELETE→same-id recreate in the read→commit window fails the gate.
|
|
6656
6622
|
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
6657
6623
|
const cfg = this.getAgentConfig(agentId);
|
|
6658
6624
|
if (!cfg)
|
|
@@ -6683,22 +6649,21 @@ export class AgentManager {
|
|
|
6683
6649
|
}
|
|
6684
6650
|
const now = new Date().toISOString();
|
|
6685
6651
|
const initiallyUnassigned = fresh.preferredAgentId === '';
|
|
6686
|
-
const
|
|
6652
|
+
const team = initiallyUnassigned ? this.findAgentTeam(agentId) : undefined;
|
|
6687
6653
|
const devId = initiallyUnassigned
|
|
6688
|
-
?
|
|
6654
|
+
? team?.find(agent => agent.role === 'dev')?.id
|
|
6689
6655
|
: fresh.devAgentId;
|
|
6690
6656
|
if (!devId) {
|
|
6691
6657
|
await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
|
|
6692
|
-
return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its
|
|
6658
|
+
return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no dev agent in its Agent Team` };
|
|
6693
6659
|
}
|
|
6694
6660
|
const qaId = initiallyUnassigned
|
|
6695
|
-
?
|
|
6661
|
+
? team?.find(agent => agent.role === 'qa')?.id
|
|
6696
6662
|
: fresh.qaAgentId;
|
|
6697
6663
|
if (!qaId) {
|
|
6698
6664
|
await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
|
|
6699
|
-
return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no qa agent in its
|
|
6665
|
+
return { task: fresh, errorCode: 409, error: `Agent ${agentId} has no qa agent in its Agent Team` };
|
|
6700
6666
|
}
|
|
6701
|
-
// Pin every participant's generation (mirror createTask) so a member deleted→recreated before the active write isn't baked in.
|
|
6702
6667
|
const participantGens = new Map([devId, qaId]
|
|
6703
6668
|
.map(pid => [pid, this.deletionGenerationOf(pid)]));
|
|
6704
6669
|
const participantsFresh = () => [...participantGens].every(([pid, gen]) => this.deletionGenerationOf(pid) === gen && !this.isDeletionInFlight(pid));
|
|
@@ -6712,8 +6677,6 @@ export class AgentManager {
|
|
|
6712
6677
|
status: 'in_progress',
|
|
6713
6678
|
updatedAt: now,
|
|
6714
6679
|
};
|
|
6715
|
-
// Binding-first: the fresh task snapshot already exists on disk, so a failure at any later
|
|
6716
|
-
// step leaves at worst a stale binding to a still-unclaimed task — an existing reclaimable state.
|
|
6717
6680
|
const releaseLock = async () => {
|
|
6718
6681
|
try {
|
|
6719
6682
|
await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
|
|
@@ -6739,7 +6702,6 @@ export class AgentManager {
|
|
|
6739
6702
|
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
6740
6703
|
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
6741
6704
|
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
6742
|
-
// A new task never inherits the previous one's question: void the watermark.
|
|
6743
6705
|
needInput: { epoch: (existing?.needInput?.epoch ?? 0) + 1 },
|
|
6744
6706
|
};
|
|
6745
6707
|
});
|
|
@@ -6752,7 +6714,6 @@ export class AgentManager {
|
|
|
6752
6714
|
await releaseLock();
|
|
6753
6715
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
|
|
6754
6716
|
}
|
|
6755
|
-
// Re-verify lock ownership + preferred/participant generations before the active write, else a DELETE after the binding commit orphans it.
|
|
6756
6717
|
if (this.isDeletionInFlight(agentId)
|
|
6757
6718
|
|| !this.deletionGateOpen(agentId, genAtEntry)
|
|
6758
6719
|
|| !participantsFresh()
|
|
@@ -6883,7 +6844,6 @@ export class AgentManager {
|
|
|
6883
6844
|
const agent = this.getAgentConfig(agentId);
|
|
6884
6845
|
if (!agent)
|
|
6885
6846
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
6886
|
-
// Pin the incarnation: a DELETE completing during dispatch bumps this, and every gated commit/side-effect below refuses the stale generation.
|
|
6887
6847
|
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
6888
6848
|
const task = await this.taskStore.get(taskId);
|
|
6889
6849
|
if (!task)
|
|
@@ -6930,8 +6890,6 @@ export class AgentManager {
|
|
|
6930
6890
|
return false;
|
|
6931
6891
|
}
|
|
6932
6892
|
const assertOwner = async () => {
|
|
6933
|
-
// Also gate on the entry generation: a DELETE→same-id recreate can pass the task/lock check on the
|
|
6934
|
-
// reused binding, so every Workdir/runtime side-effect guarded by assertOwner must fail closed on it.
|
|
6935
6893
|
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
6936
6894
|
throw new Error(`Agent ${agentId} is being deleted or was recreated for task ${taskId}; operation aborted`);
|
|
6937
6895
|
}
|
|
@@ -7049,7 +7007,6 @@ export class AgentManager {
|
|
|
7049
7007
|
await this.agentStore.update(agentId, (stateNow) => {
|
|
7050
7008
|
if (!stateNow || stateNow.taskId !== taskId)
|
|
7051
7009
|
return AGENT_STORE_NOOP;
|
|
7052
|
-
// Watermark stays: the arm-time epoch bump owns superseding the old question.
|
|
7053
7010
|
return {
|
|
7054
7011
|
...stateNow,
|
|
7055
7012
|
paneId,
|
|
@@ -7131,7 +7088,6 @@ export class AgentManager {
|
|
|
7131
7088
|
cancelHoldWon = true;
|
|
7132
7089
|
return AGENT_STORE_NOOP;
|
|
7133
7090
|
}
|
|
7134
|
-
// Binding must already exist and still hold our lock; never re-create a deleted agent's state nor write under a bumped deletion generation (ABA).
|
|
7135
7091
|
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken
|
|
7136
7092
|
|| !this.deletionGateOpen(agentId, genAtEntry)) {
|
|
7137
7093
|
deletedDuringDispatch = true;
|
|
@@ -7162,10 +7118,6 @@ export class AgentManager {
|
|
|
7162
7118
|
return false;
|
|
7163
7119
|
}
|
|
7164
7120
|
agentMarkedRunning = true;
|
|
7165
|
-
// armed pass 的 fence 延伸到最终 paste:checkout//clear/artifacts 的 await 窗口里 pass 可被
|
|
7166
|
-
// push/其他 dispatcher 轮换,旧 prompt 不得注入 successor;guard 在 composer/paste/Enter
|
|
7167
|
-
// 的 task-mutation 队列槽内复核(与轮换串行),漂移即放弃投递。
|
|
7168
|
-
// cancel 只写终态不轮换 token,故 fence 须同时复核任务仍处本相位的可投递状态
|
|
7169
7121
|
const passDeliverableStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
|
|
7170
7122
|
const passStillArmed = opts.dispatchPassToken !== undefined
|
|
7171
7123
|
? async () => {
|
|
@@ -7187,8 +7139,6 @@ export class AgentManager {
|
|
|
7187
7139
|
console.warn(`[AgentManager] startSession[${phase}]: injection fenced off (pass superseded) for task ${taskId}`);
|
|
7188
7140
|
return false;
|
|
7189
7141
|
}
|
|
7190
|
-
// prompt 已确认投递:只 CAS 清理本次派发那一代的登记;successor 在窗口内登记的新 pending 不受影响,
|
|
7191
|
-
// 且清理先于可失败的审计事件——事件写失败不能把已投递的 prompt 重新变回 pending
|
|
7192
7142
|
if (opts.dispatchPassToken !== undefined) {
|
|
7193
7143
|
this.clearPendingDispatchRetryIfMatches(taskId, { agentId, signalToken: opts.dispatchPassToken });
|
|
7194
7144
|
}
|
|
@@ -7215,8 +7165,6 @@ export class AgentManager {
|
|
|
7215
7165
|
return true;
|
|
7216
7166
|
}
|
|
7217
7167
|
catch (err) {
|
|
7218
|
-
// 注入阶段的忙碌(预注入活回合检查)仍在任何按键之前:登记 pending 并保持 checkout/绑定原样,
|
|
7219
|
-
// 在途回合还在使用该工作树,park/release 会拽走它
|
|
7220
7168
|
const injectBusyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
|
|
7221
7169
|
if (injectBusyPend)
|
|
7222
7170
|
throw injectBusyPend;
|
|
@@ -7322,7 +7270,6 @@ export class AgentManager {
|
|
|
7322
7270
|
}
|
|
7323
7271
|
async injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste) {
|
|
7324
7272
|
const paneId = pane.paneId;
|
|
7325
|
-
// 静态忙信号(文本忙样式/working title)可能是残稿或 stale title 冒充,一律由帧活性仲裁;ready 视图覆盖 stale title。
|
|
7326
7273
|
const preTitle = await tmux.readPaneTitle(pane);
|
|
7327
7274
|
const preFrame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
7328
7275
|
const staticBusy = hasOscTitleWorking(preTitle)
|
|
@@ -7333,12 +7280,10 @@ export class AgentManager {
|
|
|
7333
7280
|
}
|
|
7334
7281
|
await revalidate?.();
|
|
7335
7282
|
if (guardBeforePaste) {
|
|
7336
|
-
// A stale replay must not touch the pane at all: the guard runs before the first composer scrub.
|
|
7337
7283
|
if (!(await guardBeforePaste())) {
|
|
7338
7284
|
return { acked: false, composerDelivered: false, aborted: true };
|
|
7339
7285
|
}
|
|
7340
7286
|
const staged = await tmux.stagePromptBuffer(paneId, prompt, agentId);
|
|
7341
|
-
// Fence + scrub + paste share one task-mutation-queue slot, so a rotation lands before or after, never between.
|
|
7342
7287
|
let pasted = false;
|
|
7343
7288
|
try {
|
|
7344
7289
|
pasted = await this.withTaskLock(async () => {
|
|
@@ -7350,7 +7295,6 @@ export class AgentManager {
|
|
|
7350
7295
|
});
|
|
7351
7296
|
}
|
|
7352
7297
|
catch (pasteErr) {
|
|
7353
|
-
// Buffer still present proves the paste never landed; missing/unprobeable fails closed to a composer scrub.
|
|
7354
7298
|
let bufferConsumed = true;
|
|
7355
7299
|
try {
|
|
7356
7300
|
await tmux.dropStagedBuffer(staged.buf);
|
|
@@ -7390,7 +7334,6 @@ export class AgentManager {
|
|
|
7390
7334
|
baseline = await tmux.captureSettledSnapshot(pane, { timeoutMs: this.dispatchSettleTimeoutMs });
|
|
7391
7335
|
baselineTitle = await tmux.readPaneTitle(pane);
|
|
7392
7336
|
if (guardBeforePaste) {
|
|
7393
|
-
// The Enter is the actual submission; it takes the same fenced queue slot.
|
|
7394
7337
|
const submitted = await this.withTaskLock(async () => {
|
|
7395
7338
|
if (!(await guardBeforePaste()))
|
|
7396
7339
|
return false;
|
|
@@ -7402,7 +7345,6 @@ export class AgentManager {
|
|
|
7402
7345
|
await tmux.clearComposerDraft(pane);
|
|
7403
7346
|
}
|
|
7404
7347
|
catch (err) {
|
|
7405
|
-
// Fail closed: a submit-ready stale prompt could not be confirmed gone.
|
|
7406
7348
|
const message = err instanceof Error ? err.message : String(err);
|
|
7407
7349
|
throw new Error(`fence-rejected prompt could not be scrubbed from the composer of pane ${paneId}; verify the pane before reuse: ${message}`);
|
|
7408
7350
|
}
|
|
@@ -7426,7 +7368,6 @@ export class AgentManager {
|
|
|
7426
7368
|
baselineTitle,
|
|
7427
7369
|
resend: guardBeforePaste
|
|
7428
7370
|
? async () => {
|
|
7429
|
-
// A resend is a fresh submission of whatever sits in the composer; it takes the same fence.
|
|
7430
7371
|
const resent = await this.withTaskLock(async () => {
|
|
7431
7372
|
if (!(await guardBeforePaste()))
|
|
7432
7373
|
return false;
|
|
@@ -7588,8 +7529,6 @@ export class AgentManager {
|
|
|
7588
7529
|
await branches.assertClean(verifiedWorkdir);
|
|
7589
7530
|
const actualRef = await branches.currentRef(verifiedWorkdir);
|
|
7590
7531
|
if (actualRef !== `refs/heads/${task.branch}`) {
|
|
7591
|
-
// dev 可能已被释放过(如 spec max_rounds 停驻默认分支);switchToTaskBranch 自带
|
|
7592
|
-
// assertClean,脏树时抛 DirtyWorkdirError fail-closed,不会覆盖未提交的改动
|
|
7593
7532
|
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
7594
7533
|
await branches.switchToTaskBranch(verifiedWorkdir, taskId, task.branch, task.branchCreatedByBaxian === true, {
|
|
7595
7534
|
requireExistingWork: true,
|
|
@@ -7662,7 +7601,6 @@ export class AgentManager {
|
|
|
7662
7601
|
return false;
|
|
7663
7602
|
}
|
|
7664
7603
|
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
7665
|
-
// A revoke marks before clearing and a push transitions before clearing; every half-window must abort the paste.
|
|
7666
7604
|
const postApprovePassStillLive = async () => {
|
|
7667
7605
|
const [taskFresh, completionFresh] = await Promise.all([
|
|
7668
7606
|
this.taskStore.get(taskId),
|
|
@@ -7720,9 +7658,6 @@ export class AgentManager {
|
|
|
7720
7658
|
|| latest.workdir !== verifiedWorkdir) {
|
|
7721
7659
|
return AGENT_STORE_NOOP;
|
|
7722
7660
|
}
|
|
7723
|
-
// The continuation prompt supersedes the previous question, but the arm-time
|
|
7724
|
-
// epoch bump owns that transition — stripping here would race an arm that
|
|
7725
|
-
// already re-established the watermark (post-approve arms before injecting).
|
|
7726
7661
|
return {
|
|
7727
7662
|
...latest,
|
|
7728
7663
|
paneId,
|
|
@@ -7835,7 +7770,6 @@ export class AgentManager {
|
|
|
7835
7770
|
return { ...rest, updatedAt: new Date().toISOString() };
|
|
7836
7771
|
});
|
|
7837
7772
|
}
|
|
7838
|
-
// continueSession never runs startSession's post-ack finalization; a surviving marker would make recover() roll the running task back to pending.
|
|
7839
7773
|
async finalizeReplayedBootstrap(agentId, taskId, phase, expectedLockToken) {
|
|
7840
7774
|
let cleared = false;
|
|
7841
7775
|
let deliveredWorkdir;
|
|
@@ -8045,10 +7979,10 @@ export class AgentManager {
|
|
|
8045
7979
|
if (await this.rollbackUndeliveredBootstrap(state))
|
|
8046
7980
|
continue;
|
|
8047
7981
|
await this.markDialogPending(state.id, state.creationToken);
|
|
8048
|
-
void this.slowPollDialogPending(state.id, state.creationToken, {
|
|
7982
|
+
void this.outsideTaskMutationScope(() => this.slowPollDialogPending(state.id, state.creationToken, {
|
|
8049
7983
|
...(state.paneId !== undefined ? { expectedPaneId: state.paneId } : {}),
|
|
8050
7984
|
expectedTaskId: state.taskId,
|
|
8051
|
-
}).catch((pollErr) => {
|
|
7985
|
+
})).catch((pollErr) => {
|
|
8052
7986
|
console.warn(`[recover] slowPoll for ${state.id} crashed:`, pollErr);
|
|
8053
7987
|
});
|
|
8054
7988
|
continue;
|
|
@@ -8190,8 +8124,6 @@ export class AgentManager {
|
|
|
8190
8124
|
if (this.markCompleteInFlight.has(taskId)) {
|
|
8191
8125
|
throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`);
|
|
8192
8126
|
}
|
|
8193
|
-
// 终态不早退:状态不再改写,但残留的 pane/绑定(上次清理中途失败)仍走同一条强制清理路径。
|
|
8194
|
-
// 唯一例外:清理已在进行中的重复取消——重跑只会重复中断同一批 pane,等它收尾即可。
|
|
8195
8127
|
const alreadyTerminal = TERMINAL_STATUSES.includes(task.status);
|
|
8196
8128
|
if (alreadyTerminal && this.cancelCleanupInFlight.has(taskId))
|
|
8197
8129
|
return task;
|
|
@@ -8238,10 +8170,8 @@ export class AgentManager {
|
|
|
8238
8170
|
cleanupClaimed = true;
|
|
8239
8171
|
return task;
|
|
8240
8172
|
});
|
|
8241
|
-
// stop again post-write: a rollback may have re-armed between the pre-lock stop() and the cancelled write
|
|
8242
8173
|
if (cleanupClaimed)
|
|
8243
8174
|
this.phaseSignalWatcher?.stop(taskId);
|
|
8244
|
-
// 两阶段:先中断全部 pane 再释放任一 binding,避免 dev 先空闲、被派新任务时旧任务的 qa prompt 仍在跑。
|
|
8245
8175
|
try {
|
|
8246
8176
|
const stopped = [];
|
|
8247
8177
|
for (const id of [devToRelease, qaToRelease]) {
|
|
@@ -8638,6 +8568,169 @@ export class AgentManager {
|
|
|
8638
8568
|
return dispatched;
|
|
8639
8569
|
}
|
|
8640
8570
|
}
|
|
8571
|
+
async advanceTask(taskId, opts = {}) {
|
|
8572
|
+
let task = await this.taskStore.get(taskId);
|
|
8573
|
+
if (!task)
|
|
8574
|
+
throw new ApiError(404, `Task ${taskId} not found`);
|
|
8575
|
+
const executor = opts.executor ?? (task.status === 'review' ? 'qa' : 'dev');
|
|
8576
|
+
let updated;
|
|
8577
|
+
if (task.status === 'pending') {
|
|
8578
|
+
if (executor !== 'dev')
|
|
8579
|
+
throw new ApiError(409, 'Pending tasks can only advance to Dev');
|
|
8580
|
+
const result = await this.dispatchPendingTask(taskId, opts.agentId);
|
|
8581
|
+
if (result.errorCode !== undefined)
|
|
8582
|
+
throw new ApiError(result.errorCode, result.error ?? 'Dispatch failed');
|
|
8583
|
+
if (!result.task)
|
|
8584
|
+
throw new ApiError(404, `Task ${taskId} disappeared during dispatch`);
|
|
8585
|
+
updated = result.task;
|
|
8586
|
+
}
|
|
8587
|
+
else if (executor === 'qa') {
|
|
8588
|
+
if (task.status !== 'in_progress' && task.status !== 'fixing' && task.status !== 'review') {
|
|
8589
|
+
throw new ApiError(409, `Task ${taskId} cannot advance to QA from ${task.status}`);
|
|
8590
|
+
}
|
|
8591
|
+
updated = await this.dispatchReviewToQa(taskId, {
|
|
8592
|
+
fromStatus: [task.status],
|
|
8593
|
+
confirmUncertainNotDelivered: true,
|
|
8594
|
+
...(opts.stage !== undefined ? { stage: opts.stage } : {}),
|
|
8595
|
+
...(opts.actorId !== undefined ? { actorId: opts.actorId } : {}),
|
|
8596
|
+
...(opts.prNumber !== undefined ? { prNumber: opts.prNumber } : {}),
|
|
8597
|
+
});
|
|
8598
|
+
}
|
|
8599
|
+
else if (task.status === 'review') {
|
|
8600
|
+
throw new ApiError(409, 'Review cannot advance to Dev without a request-changes verdict');
|
|
8601
|
+
}
|
|
8602
|
+
else if (task.status === 'in_progress' || task.status === 'fixing') {
|
|
8603
|
+
if (!task.devAgentId)
|
|
8604
|
+
throw new ApiError(409, `Task ${taskId} has no Dev participant`);
|
|
8605
|
+
const replayed = await this.redispatchTaskPromptAfterReplRestart(task.devAgentId, taskId);
|
|
8606
|
+
if (!replayed)
|
|
8607
|
+
throw new ApiError(409, `Task ${taskId} changed or its Dev prompt could not be replayed`);
|
|
8608
|
+
updated = (await this.taskStore.get(taskId)) ?? task;
|
|
8609
|
+
}
|
|
8610
|
+
else if (task.status === 'approved') {
|
|
8611
|
+
if (!task.devAgentId)
|
|
8612
|
+
throw new ApiError(409, `Task ${taskId} has no Dev participant`);
|
|
8613
|
+
if (task.postApproveRevoked) {
|
|
8614
|
+
if (opts.confirmRevoked !== true) {
|
|
8615
|
+
throw new ApiError(409, `Task ${taskId} post-approve replay is revoked; confirmRevoked=true is required`);
|
|
8616
|
+
}
|
|
8617
|
+
task = await this.restoreRevokedPostApprove(task);
|
|
8618
|
+
}
|
|
8619
|
+
const replayed = await this.redispatchTaskPromptAfterReplRestart(task.devAgentId, taskId);
|
|
8620
|
+
if (!replayed)
|
|
8621
|
+
throw new ApiError(409, `Task ${taskId} changed or its post-approve prompt could not be replayed`);
|
|
8622
|
+
updated = (await this.taskStore.get(taskId)) ?? task;
|
|
8623
|
+
}
|
|
8624
|
+
else {
|
|
8625
|
+
throw new ApiError(409, `Task ${taskId} cannot be advanced from ${task.status}`);
|
|
8626
|
+
}
|
|
8627
|
+
await this.auditHumanTaskOperation(updated, 'advance', executor, opts.note);
|
|
8628
|
+
return updated;
|
|
8629
|
+
}
|
|
8630
|
+
async restoreRevokedPostApprove(task) {
|
|
8631
|
+
if (!task.postApproveRevoked || task.prNumber === undefined) {
|
|
8632
|
+
throw new ApiError(409, `Task ${task.id} has no revoked post-approve episode`);
|
|
8633
|
+
}
|
|
8634
|
+
const [binding, accepted] = await Promise.all([
|
|
8635
|
+
this.platformVerifyPrBinding(task.id, task.prNumber),
|
|
8636
|
+
this.platformVerifyAcceptedPass(task.id),
|
|
8637
|
+
]);
|
|
8638
|
+
if (!binding.ok) {
|
|
8639
|
+
throw new ApiError(409, `Cannot restore task ${task.id}: PR binding ${binding.reason}`);
|
|
8640
|
+
}
|
|
8641
|
+
if (accepted.kind !== 'valid') {
|
|
8642
|
+
throw new ApiError(409, `Cannot restore task ${task.id}: ${accepted.reason}`);
|
|
8643
|
+
}
|
|
8644
|
+
if (accepted.pendingCount > 0) {
|
|
8645
|
+
throw new ApiError(409, `Cannot restore task ${task.id}: the accepted pass has pending feedback`);
|
|
8646
|
+
}
|
|
8647
|
+
const reviewedPass = task.passProvenance;
|
|
8648
|
+
if (!reviewedPass || binding.headSha.toLowerCase() !== reviewedPass.anchorSha.toLowerCase()) {
|
|
8649
|
+
throw new ApiError(409, `Cannot restore task ${task.id}: the current PR head is not the accepted head`);
|
|
8650
|
+
}
|
|
8651
|
+
return this.withTaskLock(async () => {
|
|
8652
|
+
const fresh = await this.taskStore.get(task.id);
|
|
8653
|
+
if (!fresh || fresh.status !== 'approved'
|
|
8654
|
+
|| fresh.postApproveRevoked?.generation !== task.postApproveRevoked.generation
|
|
8655
|
+
|| fresh.prNumber !== task.prNumber
|
|
8656
|
+
|| !samePassProvenance(fresh.passProvenance, reviewedPass)) {
|
|
8657
|
+
throw new ApiError(409, `Task ${task.id} changed while restoring post-approve replay`);
|
|
8658
|
+
}
|
|
8659
|
+
const generation = createSignalToken();
|
|
8660
|
+
const token = createSignalTokenExcluding(generation);
|
|
8661
|
+
const next = {
|
|
8662
|
+
...this.stripPostApproveEpisodeFields(fresh),
|
|
8663
|
+
postApproveGeneration: generation,
|
|
8664
|
+
postApproveHeadSha: binding.headSha,
|
|
8665
|
+
postApproveToken: token,
|
|
8666
|
+
postApprovePhase: 'installed',
|
|
8667
|
+
pendingRedispatch: true,
|
|
8668
|
+
redispatchCount: 0,
|
|
8669
|
+
updatedAt: new Date().toISOString(),
|
|
8670
|
+
};
|
|
8671
|
+
await this.taskStore.set(next);
|
|
8672
|
+
return next;
|
|
8673
|
+
});
|
|
8674
|
+
}
|
|
8675
|
+
async submitTaskVerdict(taskId, action, comments, note) {
|
|
8676
|
+
const task = await this.taskStore.get(taskId);
|
|
8677
|
+
if (!task)
|
|
8678
|
+
throw new ApiError(404, `Task ${taskId} not found`);
|
|
8679
|
+
let updated;
|
|
8680
|
+
if (task.status === 'spec-ready'
|
|
8681
|
+
|| (task.status === 'max_rounds' && isSpecStagePhase(task.phase))) {
|
|
8682
|
+
if (action !== 'approve' && action !== 'request-changes') {
|
|
8683
|
+
throw new ApiError(409, `Task ${taskId} requires a spec approve or request-changes verdict`);
|
|
8684
|
+
}
|
|
8685
|
+
updated = await this.submitSpecVerdict(taskId, action, comments);
|
|
8686
|
+
}
|
|
8687
|
+
else if (task.status === 'review' && !isSpecStagePhase(task.phase)) {
|
|
8688
|
+
if (action !== 'pass' && action !== 'request-changes') {
|
|
8689
|
+
throw new ApiError(409, `Task ${taskId} requires a code pass or request-changes verdict`);
|
|
8690
|
+
}
|
|
8691
|
+
updated = await this.submitCodeVerdict(taskId, action, comments);
|
|
8692
|
+
}
|
|
8693
|
+
else if (task.status === 'max_rounds') {
|
|
8694
|
+
if (action === 'continue')
|
|
8695
|
+
updated = await this.continueDevRound(taskId);
|
|
8696
|
+
else if (action === 'complete')
|
|
8697
|
+
updated = await this.markTaskComplete(taskId);
|
|
8698
|
+
else
|
|
8699
|
+
throw new ApiError(409, `Task ${taskId} requires continue or complete at code max_rounds`);
|
|
8700
|
+
}
|
|
8701
|
+
else if (task.status === 'merge-ready') {
|
|
8702
|
+
if (action !== 'confirm-merge') {
|
|
8703
|
+
throw new ApiError(409, `Task ${taskId} requires confirm-merge`);
|
|
8704
|
+
}
|
|
8705
|
+
updated = await this.markTaskComplete(taskId);
|
|
8706
|
+
}
|
|
8707
|
+
else {
|
|
8708
|
+
throw new ApiError(409, `Task ${taskId} cannot accept a verdict from ${task.status}`);
|
|
8709
|
+
}
|
|
8710
|
+
await this.auditHumanTaskOperation(updated, 'verdict', action, note);
|
|
8711
|
+
return updated;
|
|
8712
|
+
}
|
|
8713
|
+
async auditHumanTaskOperation(task, operation, action, note, details = {}) {
|
|
8714
|
+
const emitted = await this.safeEmit({
|
|
8715
|
+
id: '',
|
|
8716
|
+
type: 'task.updated',
|
|
8717
|
+
timestamp: new Date().toISOString(),
|
|
8718
|
+
projectId: task.projectId,
|
|
8719
|
+
...(task.agentId ? { agentId: task.agentId } : {}),
|
|
8720
|
+
taskId: task.id,
|
|
8721
|
+
data: {
|
|
8722
|
+
...details,
|
|
8723
|
+
operation,
|
|
8724
|
+
action,
|
|
8725
|
+
actor: 'human',
|
|
8726
|
+
provenance: 'human',
|
|
8727
|
+
note: note?.trim() || `${operation}:${action}`,
|
|
8728
|
+
},
|
|
8729
|
+
});
|
|
8730
|
+
if (!emitted) {
|
|
8731
|
+
throw new ApiError(500, `Task ${task.id} ${operation} completed, but its audit event could not be persisted; verify the task state before retrying`);
|
|
8732
|
+
}
|
|
8733
|
+
}
|
|
8641
8734
|
async dispatchGitFixToDev(taskId) {
|
|
8642
8735
|
const task = await this.taskStore.get(taskId);
|
|
8643
8736
|
if (!task || task.status !== 'fixing')
|
|
@@ -8730,7 +8823,7 @@ export class AgentManager {
|
|
|
8730
8823
|
const devState = await this.agentStore.get(devAgentId);
|
|
8731
8824
|
if (devState?.taskId !== taskId || !devState.workdir) {
|
|
8732
8825
|
throw new ApiError(409, `Dev ${devAgentId} no longer holds task ${taskId}'s Workdir (cannot continue); ` +
|
|
8733
|
-
`
|
|
8826
|
+
`submit a complete verdict to merge the PR as-is, or cancel the task`);
|
|
8734
8827
|
}
|
|
8735
8828
|
const prevReviewRound = task.reviewRound;
|
|
8736
8829
|
const transitioned = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['max_rounds'] }, { reviewRound: prevReviewRound + 1, fixDispatchedAt: new Date().toISOString() });
|
|
@@ -8822,26 +8915,53 @@ export class AgentManager {
|
|
|
8822
8915
|
const t = await this.taskStore.get(taskId);
|
|
8823
8916
|
if (!t)
|
|
8824
8917
|
throw new ApiError(404, 'Task not found');
|
|
8825
|
-
|
|
8826
|
-
|| (t.status === 'max_rounds' && isSpecStagePhase(t.phase));
|
|
8827
|
-
if (!retryable) {
|
|
8918
|
+
if (!TERMINAL_STATUSES.includes(t.status)) {
|
|
8828
8919
|
throw new ApiError(409, `Task ${taskId} cannot be retried in status "${t.status}"; cancel it first or wait for completion`);
|
|
8829
8920
|
}
|
|
8921
|
+
if (t.replacementTaskId !== undefined) {
|
|
8922
|
+
throw new ApiError(409, `Task ${taskId} was already retried as ${t.replacementTaskId}`);
|
|
8923
|
+
}
|
|
8924
|
+
if (this.retryTaskInFlight.has(taskId)) {
|
|
8925
|
+
throw new ApiError(409, `Task ${taskId} retry is already in progress`);
|
|
8926
|
+
}
|
|
8927
|
+
this.retryTaskInFlight.add(taskId);
|
|
8830
8928
|
return t;
|
|
8831
8929
|
});
|
|
8832
|
-
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
|
|
8836
|
-
|
|
8837
|
-
|
|
8838
|
-
|
|
8930
|
+
try {
|
|
8931
|
+
const input = {
|
|
8932
|
+
title: old.title,
|
|
8933
|
+
description: old.description,
|
|
8934
|
+
preferredAgentId: this.resolveRetryPreferredAgentId(old),
|
|
8935
|
+
};
|
|
8936
|
+
if (old.images?.length) {
|
|
8937
|
+
input.images = await this.readStagedImages(old.id, old.images);
|
|
8938
|
+
}
|
|
8939
|
+
await this.validateTaskDispatch(old.projectId, input);
|
|
8940
|
+
const replacement = await this.createTask(old.projectId, input);
|
|
8941
|
+
await this.withTaskLock(async () => {
|
|
8942
|
+
const source = await this.taskStore.get(taskId);
|
|
8943
|
+
if (!source)
|
|
8944
|
+
throw new ApiError(404, `Task ${taskId} disappeared while recording its retry`);
|
|
8945
|
+
if (source.replacementTaskId !== undefined && source.replacementTaskId !== replacement.id) {
|
|
8946
|
+
throw new ApiError(409, `Task ${taskId} was already retried as ${source.replacementTaskId}`);
|
|
8947
|
+
}
|
|
8948
|
+
const next = {
|
|
8949
|
+
...source,
|
|
8950
|
+
replacementTaskId: replacement.id,
|
|
8951
|
+
updatedAt: new Date().toISOString(),
|
|
8952
|
+
};
|
|
8953
|
+
if (old.attention !== undefined
|
|
8954
|
+
&& source.attention?.reason === old.attention.reason
|
|
8955
|
+
&& source.attention.occurredAt === old.attention.occurredAt) {
|
|
8956
|
+
delete next.attention;
|
|
8957
|
+
}
|
|
8958
|
+
await this.taskStore.set(next);
|
|
8959
|
+
});
|
|
8960
|
+
return this.startCreatedTask(replacement);
|
|
8839
8961
|
}
|
|
8840
|
-
|
|
8841
|
-
|
|
8842
|
-
await this.cancelTask(old.id);
|
|
8962
|
+
finally {
|
|
8963
|
+
this.retryTaskInFlight.delete(taskId);
|
|
8843
8964
|
}
|
|
8844
|
-
return this.createAndStartTask(old.projectId, input);
|
|
8845
8965
|
}
|
|
8846
8966
|
resolveRetryPreferredAgentId(task) {
|
|
8847
8967
|
const configured = this.getAgentConfig(task.preferredAgentId);
|
|
@@ -8851,7 +8971,7 @@ export class AgentManager {
|
|
|
8851
8971
|
const anchors = new Set([task.qaAgentId, task.devAgentId, task.agentId]
|
|
8852
8972
|
.filter((agentId) => typeof agentId === 'string' && agentId !== ''));
|
|
8853
8973
|
for (const anchor of anchors) {
|
|
8854
|
-
const dev = this.
|
|
8974
|
+
const dev = this.findAgentTeam(anchor)?.find(agent => TASK_OWNER_ROLES.has(agent.role));
|
|
8855
8975
|
const current = dev ? this.getAgentConfig(dev.id) : undefined;
|
|
8856
8976
|
if (current?.projectId === task.projectId)
|
|
8857
8977
|
return current.id;
|
|
@@ -8893,13 +9013,13 @@ export class AgentManager {
|
|
|
8893
9013
|
}
|
|
8894
9014
|
if (!TASK_OWNER_ROLES.has(cfg.role))
|
|
8895
9015
|
throw new ApiError(400, 'Agent is not a dev agent');
|
|
8896
|
-
const
|
|
8897
|
-
const dev =
|
|
9016
|
+
const team = this.findAgentTeam(cfg.id);
|
|
9017
|
+
const dev = team?.find(agent => agent.role === 'dev');
|
|
8898
9018
|
if (!dev)
|
|
8899
|
-
throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its
|
|
8900
|
-
const qa =
|
|
9019
|
+
throw new ApiError(409, `Agent ${cfg.id} has no dev agent in its Agent Team`);
|
|
9020
|
+
const qa = team?.find(agent => agent.role === 'qa');
|
|
8901
9021
|
if (!qa)
|
|
8902
|
-
throw new ApiError(409, `Agent ${cfg.id} has no qa agent in its
|
|
9022
|
+
throw new ApiError(409, `Agent ${cfg.id} has no qa agent in its Agent Team`);
|
|
8903
9023
|
task.preferredAgentId = patch.preferredAgentId;
|
|
8904
9024
|
task.devAgentId = dev.id;
|
|
8905
9025
|
task.qaAgentId = qa.id;
|
|
@@ -8937,22 +9057,22 @@ export class AgentManager {
|
|
|
8937
9057
|
throw new ApiError(409, `Task ${taskId} is not at max_rounds (status=${task.status})`);
|
|
8938
9058
|
}
|
|
8939
9059
|
if (isSpecStagePhase(task.phase)) {
|
|
8940
|
-
throw new ApiError(409, `
|
|
9060
|
+
throw new ApiError(409, `Complete verdict is only supported for code-phase tasks`);
|
|
8941
9061
|
}
|
|
8942
9062
|
if (!task.prNumber || !task.branch) {
|
|
8943
|
-
throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot
|
|
9063
|
+
throw new ApiError(400, `Task ${taskId} has no PR/branch; cannot complete`);
|
|
8944
9064
|
}
|
|
8945
9065
|
for (const agentId of [task.agentId, task.qaAgentId]) {
|
|
8946
9066
|
if (!agentId)
|
|
8947
9067
|
continue;
|
|
8948
9068
|
const state = await this.agentStore.get(agentId);
|
|
8949
9069
|
if (state?.status === 'awaiting_human' && state.taskId === taskId) {
|
|
8950
|
-
throw new ApiError(409, `Agent ${agentId} is awaiting human intervention on this task; resume/restart/delete it before
|
|
9070
|
+
throw new ApiError(409, `Agent ${agentId} is awaiting human intervention on this task; resume/restart/delete it before completing`);
|
|
8951
9071
|
}
|
|
8952
9072
|
}
|
|
8953
9073
|
const claimed = await this.transitionTaskStatus(taskId, 'merge-ready', { fromStatus: ['max_rounds'] });
|
|
8954
9074
|
if (!claimed) {
|
|
8955
|
-
throw new ApiError(409, `Task ${taskId} changed status during
|
|
9075
|
+
throw new ApiError(409, `Task ${taskId} changed status during complete verdict; aborted`);
|
|
8956
9076
|
}
|
|
8957
9077
|
try {
|
|
8958
9078
|
await this.mergePr(taskId, { humanOverride: true });
|
|
@@ -9005,8 +9125,6 @@ export class AgentManager {
|
|
|
9005
9125
|
const agent = this.getAgentConfig(agentId);
|
|
9006
9126
|
if (!agent)
|
|
9007
9127
|
return;
|
|
9008
|
-
// Pin the incarnation before the first state/paneId read: paneId equality is ABA-blind (a recreated
|
|
9009
|
-
// agent reuses %0), so a DELETE→same-id recreate would otherwise let post-merge /clear hit a new session.
|
|
9010
9128
|
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
9011
9129
|
const state = await this.agentStore.get(agentId);
|
|
9012
9130
|
if (!state)
|
|
@@ -9062,7 +9180,6 @@ export class AgentManager {
|
|
|
9062
9180
|
return;
|
|
9063
9181
|
}
|
|
9064
9182
|
}
|
|
9065
|
-
// A DELETE→recreate (gen bump) before this pane work must skip it, or /clear lands on the new incarnation's reused pane.
|
|
9066
9183
|
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
9067
9184
|
return;
|
|
9068
9185
|
const runner = this.createRunnerFor(agent);
|
|
@@ -9284,15 +9401,11 @@ export class AgentManager {
|
|
|
9284
9401
|
return;
|
|
9285
9402
|
}
|
|
9286
9403
|
if (Date.now() >= deadline) {
|
|
9287
|
-
// 与 stableIdle 分支同一类型:忙碌超时是「常态忙」而非故障,调用方(release/派发)
|
|
9288
|
-
// 据此延后重排;菜单/对话框/REPL 退出等仍是普通 Error,不得被当成可延后
|
|
9289
9404
|
throw new ReplNotReadyError(paneId, runtime, cap, `stayed busy past ${timeoutMs}ms`);
|
|
9290
9405
|
}
|
|
9291
9406
|
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
9292
9407
|
}
|
|
9293
9408
|
}
|
|
9294
|
-
// 注入前置门:codex 忙碌回合的工具间隙会闪现单帧 idle footer(#558 事故放大器),
|
|
9295
|
-
// 连续 K 帧稳定就绪才放行,任何忙碌/不确定帧清零计数;超时按忙碌分类抛 ReplNotReadyError。
|
|
9296
9409
|
async waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs) {
|
|
9297
9410
|
const paneId = pane.paneId;
|
|
9298
9411
|
const samples = Math.max(1, this.readyStableSamples);
|
|
@@ -9370,8 +9483,6 @@ export class AgentManager {
|
|
|
9370
9483
|
async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false) {
|
|
9371
9484
|
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
|
|
9372
9485
|
skipSnapshot,
|
|
9373
|
-
// Every post-dispatch arm follows a freshly injected prompt: the previous
|
|
9374
|
-
// question is superseded, its watermark must not carry over.
|
|
9375
9486
|
needInputMode: 'fresh',
|
|
9376
9487
|
});
|
|
9377
9488
|
if (!armed)
|
|
@@ -9471,6 +9582,247 @@ export class AgentManager {
|
|
|
9471
9582
|
}
|
|
9472
9583
|
return parked;
|
|
9473
9584
|
}
|
|
9585
|
+
async submitCodeVerdict(taskId, verdict, comments) {
|
|
9586
|
+
const trimmed = comments?.trim() ?? '';
|
|
9587
|
+
if (verdict === 'request-changes' && !trimmed) {
|
|
9588
|
+
throw new ApiError(400, 'comments is required for request-changes');
|
|
9589
|
+
}
|
|
9590
|
+
if (extractReviewTokens(trimmed).length > 0) {
|
|
9591
|
+
throw new ApiError(400, 'comments must not contain baxian review markers');
|
|
9592
|
+
}
|
|
9593
|
+
const task = await this.withTaskLock(async () => {
|
|
9594
|
+
const fresh = await this.taskStore.get(taskId);
|
|
9595
|
+
if (!fresh)
|
|
9596
|
+
throw new ApiError(404, `Task ${taskId} not found`);
|
|
9597
|
+
const pending = this.codeVerdictOutbox(fresh);
|
|
9598
|
+
const kind = verdict === 'pass' ? 'pass' : 'fail';
|
|
9599
|
+
if (pending !== undefined
|
|
9600
|
+
&& (pending.data.kind !== kind || pending.data.comments !== trimmed)) {
|
|
9601
|
+
throw new ApiError(409, `Task ${taskId} has a different code verdict already in progress`);
|
|
9602
|
+
}
|
|
9603
|
+
if (pending === undefined && (fresh.status !== 'review' || isSpecStagePhase(fresh.phase))) {
|
|
9604
|
+
throw new ApiError(409, `Task ${taskId} is not awaiting a code review verdict`);
|
|
9605
|
+
}
|
|
9606
|
+
if (this.codeVerdictInFlight.has(taskId)) {
|
|
9607
|
+
throw new ApiError(409, `Task ${taskId} code verdict is already being processed`);
|
|
9608
|
+
}
|
|
9609
|
+
this.codeVerdictInFlight.add(taskId);
|
|
9610
|
+
return fresh;
|
|
9611
|
+
});
|
|
9612
|
+
try {
|
|
9613
|
+
return await this.executeCodeVerdict(task, verdict, trimmed);
|
|
9614
|
+
}
|
|
9615
|
+
finally {
|
|
9616
|
+
this.codeVerdictInFlight.delete(taskId);
|
|
9617
|
+
}
|
|
9618
|
+
}
|
|
9619
|
+
codeVerdictCommentBody(entry) {
|
|
9620
|
+
const lines = [
|
|
9621
|
+
entry.data.kind === 'pass'
|
|
9622
|
+
? 'Human code verdict: pass'
|
|
9623
|
+
: 'Human code verdict: request changes',
|
|
9624
|
+
];
|
|
9625
|
+
if (entry.data.comments)
|
|
9626
|
+
lines.push('', entry.data.comments);
|
|
9627
|
+
lines.push('', buildReviewTokenLine({
|
|
9628
|
+
kind: entry.data.kind,
|
|
9629
|
+
anchorSha: entry.data.anchorSha,
|
|
9630
|
+
token: entry.data.token,
|
|
9631
|
+
}));
|
|
9632
|
+
const body = lines.join('\n');
|
|
9633
|
+
try {
|
|
9634
|
+
validateCommentBody(body);
|
|
9635
|
+
}
|
|
9636
|
+
catch (err) {
|
|
9637
|
+
throw new ApiError(400, `Invalid code verdict comment: ${err instanceof Error ? err.message : String(err)}`);
|
|
9638
|
+
}
|
|
9639
|
+
return body;
|
|
9640
|
+
}
|
|
9641
|
+
async scanCodeVerdictMarker(driver, entry, oppositeToken) {
|
|
9642
|
+
const scans = await this.platformScanComments(driver, entry.data.prNumber);
|
|
9643
|
+
if (scans.some(scan => !scan.ok))
|
|
9644
|
+
return 'unknown';
|
|
9645
|
+
const dead = deadTokens(scans);
|
|
9646
|
+
if (dead.has(entry.data.token))
|
|
9647
|
+
return 'dead';
|
|
9648
|
+
let present = false;
|
|
9649
|
+
let opposite = false;
|
|
9650
|
+
for (const scan of scans) {
|
|
9651
|
+
for (const row of scan.rows) {
|
|
9652
|
+
if (row.system === true || versionTimeOf(row) === undefined)
|
|
9653
|
+
continue;
|
|
9654
|
+
for (const marker of rowTokens(row)) {
|
|
9655
|
+
if (dead.has(marker.token))
|
|
9656
|
+
continue;
|
|
9657
|
+
if (marker.anchorSha.toLowerCase() !== entry.data.anchorSha.toLowerCase())
|
|
9658
|
+
continue;
|
|
9659
|
+
if (marker.kind === entry.data.kind && marker.token === entry.data.token)
|
|
9660
|
+
present = true;
|
|
9661
|
+
if (marker.kind !== entry.data.kind && marker.token === oppositeToken)
|
|
9662
|
+
opposite = true;
|
|
9663
|
+
}
|
|
9664
|
+
}
|
|
9665
|
+
}
|
|
9666
|
+
if (opposite)
|
|
9667
|
+
return 'opposite';
|
|
9668
|
+
return present ? 'present' : 'absent';
|
|
9669
|
+
}
|
|
9670
|
+
async reconcileCodeVerdictComment(driver, entry) {
|
|
9671
|
+
const expectedDigest = bodyDigest(this.codeVerdictCommentBody(entry));
|
|
9672
|
+
const scans = await this.platformScanComments(driver, entry.data.prNumber);
|
|
9673
|
+
if (scans.some(scan => !scan.ok))
|
|
9674
|
+
return 'unknown';
|
|
9675
|
+
if (deadTokens(scans).has(entry.data.token))
|
|
9676
|
+
return 'dead';
|
|
9677
|
+
if (scans.some(scan => scan.rows.some(row => row.system !== true
|
|
9678
|
+
&& versionTimeOf(row) !== undefined
|
|
9679
|
+
&& row.bodyDigest === expectedDigest)))
|
|
9680
|
+
return 'present';
|
|
9681
|
+
return 'absent';
|
|
9682
|
+
}
|
|
9683
|
+
async verifyCodeVerdictHead(taskId, prNumber, anchorSha) {
|
|
9684
|
+
try {
|
|
9685
|
+
const binding = await this.platformVerifyPrBinding(taskId, prNumber);
|
|
9686
|
+
if (!binding.ok)
|
|
9687
|
+
return { ok: false, reason: `the PR binding is ${binding.reason}` };
|
|
9688
|
+
if (binding.headSha.toLowerCase() !== anchorSha.toLowerCase()) {
|
|
9689
|
+
return { ok: false, reason: 'the PR head changed' };
|
|
9690
|
+
}
|
|
9691
|
+
return { ok: true };
|
|
9692
|
+
}
|
|
9693
|
+
catch (err) {
|
|
9694
|
+
throw new ApiError(503, `Cannot verify code verdict head for task ${taskId}: ${safeDriverErrorText(err)}`);
|
|
9695
|
+
}
|
|
9696
|
+
}
|
|
9697
|
+
async deliverCodeVerdictOutbox(taskId, key) {
|
|
9698
|
+
let task = await this.taskStore.get(taskId);
|
|
9699
|
+
let entry = task ? this.codeVerdictOutbox(task) : undefined;
|
|
9700
|
+
if (!task || !entry || entry.key !== key)
|
|
9701
|
+
return;
|
|
9702
|
+
const driver = this.platformDriverFor(task.projectId);
|
|
9703
|
+
if (!driver)
|
|
9704
|
+
throw new ApiError(503, `Git driver unavailable for task ${task.id}`);
|
|
9705
|
+
const binding = await this.verifyCodeVerdictHead(task.id, entry.data.prNumber, entry.data.anchorSha);
|
|
9706
|
+
if (!binding.ok) {
|
|
9707
|
+
await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
|
|
9708
|
+
throw new ApiError(409, `Cannot write code verdict for task ${task.id}: ${binding.reason}`);
|
|
9709
|
+
}
|
|
9710
|
+
if (entry.data.writeAttemptedAt !== undefined) {
|
|
9711
|
+
const reconciled = await this.reconcileCodeVerdictComment(driver, entry);
|
|
9712
|
+
if (reconciled === 'dead') {
|
|
9713
|
+
await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
|
|
9714
|
+
throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
|
|
9715
|
+
}
|
|
9716
|
+
if (reconciled === 'present') {
|
|
9717
|
+
return;
|
|
9718
|
+
}
|
|
9719
|
+
if (reconciled === 'unknown'
|
|
9720
|
+
|| Date.now() - Date.parse(entry.data.writeAttemptedAt) < driver.visibilityLagMs) {
|
|
9721
|
+
throw new ApiError(503, `Code verdict comment outcome is still being reconciled for task ${task.id}`);
|
|
9722
|
+
}
|
|
9723
|
+
}
|
|
9724
|
+
const expectedAttempt = entry.data.writeAttemptedAt;
|
|
9725
|
+
const claimed = await this.mutateTaskOutboxEntry(task.id, entry.key, current => current.type === 'git.code-verdict' && current.data.writeAttemptedAt === expectedAttempt
|
|
9726
|
+
? { ...current, data: { ...current.data, writeAttemptedAt: new Date().toISOString() } }
|
|
9727
|
+
: null);
|
|
9728
|
+
const claimedEntry = claimed ? this.codeVerdictOutbox(claimed) : undefined;
|
|
9729
|
+
if (!claimed || claimedEntry?.key !== entry.key) {
|
|
9730
|
+
throw new ApiError(503, `Code verdict comment write is already claimed for task ${task.id}`);
|
|
9731
|
+
}
|
|
9732
|
+
task = claimed;
|
|
9733
|
+
entry = claimedEntry;
|
|
9734
|
+
let writeFailure;
|
|
9735
|
+
try {
|
|
9736
|
+
await driver.runOp('comment', {
|
|
9737
|
+
prNumber: entry.data.prNumber,
|
|
9738
|
+
body: this.codeVerdictCommentBody(entry),
|
|
9739
|
+
});
|
|
9740
|
+
}
|
|
9741
|
+
catch (error) {
|
|
9742
|
+
writeFailure = { error };
|
|
9743
|
+
}
|
|
9744
|
+
const reconciled = await this.reconcileCodeVerdictComment(driver, entry);
|
|
9745
|
+
if (reconciled === 'dead') {
|
|
9746
|
+
await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
|
|
9747
|
+
throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
|
|
9748
|
+
}
|
|
9749
|
+
if (writeFailure !== undefined && reconciled !== 'present') {
|
|
9750
|
+
const { error } = writeFailure;
|
|
9751
|
+
const uncertain = !(error instanceof DriverOpError)
|
|
9752
|
+
|| error.info.exitCode === 255
|
|
9753
|
+
|| isTransientNetworkFailure(error.info.stderrTail ?? '')
|
|
9754
|
+
|| isTransientNetworkFailure(error.message);
|
|
9755
|
+
if (reconciled === 'unknown' || uncertain) {
|
|
9756
|
+
throw new ApiError(503, `Code verdict comment outcome is unknown for task ${task.id}; the persisted outbox will reconcile it`);
|
|
9757
|
+
}
|
|
9758
|
+
await this.mutateTaskOutboxEntry(task.id, key, current => current.type === 'git.code-verdict' ? undefined : null);
|
|
9759
|
+
throw new ApiError(502, `Code verdict comment was rejected for task ${task.id}: ${safeDriverErrorText(error)}`);
|
|
9760
|
+
}
|
|
9761
|
+
if (reconciled !== 'present') {
|
|
9762
|
+
throw new ApiError(503, `Code verdict comment is not yet visible for task ${task.id}; the persisted outbox will reconcile it`);
|
|
9763
|
+
}
|
|
9764
|
+
}
|
|
9765
|
+
async executeCodeVerdict(task, verdict, comments) {
|
|
9766
|
+
if (task.status !== 'review' || isSpecStagePhase(task.phase)
|
|
9767
|
+
|| task.prNumber === undefined || task.reviewHeadAnchorSha === undefined
|
|
9768
|
+
|| task.passToken === undefined || task.failToken === undefined) {
|
|
9769
|
+
throw new ApiError(409, `Task ${task.id} has no complete code review generation`);
|
|
9770
|
+
}
|
|
9771
|
+
const kind = verdict === 'pass' ? 'pass' : 'fail';
|
|
9772
|
+
const token = kind === 'pass' ? task.passToken : task.failToken;
|
|
9773
|
+
const existingEntry = this.codeVerdictOutbox(task);
|
|
9774
|
+
const entry = existingEntry ?? {
|
|
9775
|
+
key: token,
|
|
9776
|
+
type: 'git.code-verdict',
|
|
9777
|
+
data: {
|
|
9778
|
+
prNumber: task.prNumber,
|
|
9779
|
+
kind,
|
|
9780
|
+
anchorSha: task.reviewHeadAnchorSha,
|
|
9781
|
+
token,
|
|
9782
|
+
comments,
|
|
9783
|
+
},
|
|
9784
|
+
};
|
|
9785
|
+
this.codeVerdictCommentBody(entry);
|
|
9786
|
+
const driver = this.platformDriverFor(task.projectId);
|
|
9787
|
+
if (!driver)
|
|
9788
|
+
throw new ApiError(503, `Git driver unavailable for task ${task.id}`);
|
|
9789
|
+
const binding = await this.verifyCodeVerdictHead(task.id, task.prNumber, task.reviewHeadAnchorSha);
|
|
9790
|
+
if (!binding.ok) {
|
|
9791
|
+
throw new ApiError(409, `Cannot submit code verdict for task ${task.id}: ${binding.reason}`);
|
|
9792
|
+
}
|
|
9793
|
+
if (existingEntry === undefined) {
|
|
9794
|
+
const marker = await this.scanCodeVerdictMarker(driver, entry, kind === 'pass' ? task.failToken : task.passToken);
|
|
9795
|
+
if (marker === 'present')
|
|
9796
|
+
return task;
|
|
9797
|
+
if (marker === 'opposite') {
|
|
9798
|
+
throw new ApiError(409, `Task ${task.id} already has the opposite verdict on the current review head`);
|
|
9799
|
+
}
|
|
9800
|
+
if (marker === 'dead') {
|
|
9801
|
+
throw new ApiError(409, `Code verdict token for task ${task.id} was dismissed; advance QA to create a new review pass`);
|
|
9802
|
+
}
|
|
9803
|
+
if (marker === 'unknown') {
|
|
9804
|
+
throw new ApiError(503, `Cannot safely check existing code verdict comments for task ${task.id}`);
|
|
9805
|
+
}
|
|
9806
|
+
const created = await this.withTaskLock(async () => {
|
|
9807
|
+
const fresh = await this.taskStore.get(task.id);
|
|
9808
|
+
if (!fresh || !taskMatchesGeneration(fresh, taskGenerationGuard(task))
|
|
9809
|
+
|| this.codeVerdictOutbox(fresh) !== undefined)
|
|
9810
|
+
return null;
|
|
9811
|
+
const next = {
|
|
9812
|
+
...fresh,
|
|
9813
|
+
outbox: [...(fresh.outbox ?? []), entry],
|
|
9814
|
+
updatedAt: new Date().toISOString(),
|
|
9815
|
+
};
|
|
9816
|
+
await this.taskStore.set(next);
|
|
9817
|
+
return next;
|
|
9818
|
+
});
|
|
9819
|
+
if (!created)
|
|
9820
|
+
throw new ApiError(409, `Task ${task.id} changed while recording the code verdict`);
|
|
9821
|
+
task = created;
|
|
9822
|
+
}
|
|
9823
|
+
await this.deliverCodeVerdictOutbox(task.id, entry.key);
|
|
9824
|
+
return (await this.taskStore.get(task.id)) ?? task;
|
|
9825
|
+
}
|
|
9474
9826
|
async submitSpecVerdict(taskId, verdict, comments) {
|
|
9475
9827
|
const trimmed = comments?.trim();
|
|
9476
9828
|
if (verdict === 'request-changes' && !trimmed) {
|
|
@@ -9812,7 +10164,6 @@ export class AgentManager {
|
|
|
9812
10164
|
return null;
|
|
9813
10165
|
}
|
|
9814
10166
|
const newToken = createSignalToken();
|
|
9815
|
-
// spec 阶段的扩轮决策不延续到 code 评审:cap 共享字段,进入编码时归零
|
|
9816
10167
|
const transition = await this.transitionTaskStatus(taskId, 'in_progress', {
|
|
9817
10168
|
fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'],
|
|
9818
10169
|
...(expectedTask !== undefined ? { expectTask: expectedTask } : {}),
|
|
@@ -9868,8 +10219,6 @@ export class AgentManager {
|
|
|
9868
10219
|
}
|
|
9869
10220
|
else if (err instanceof EnsureSessionError && err.partial.handled
|
|
9870
10221
|
&& (task.status === 'spec-ready' || task.status === 'max_rounds')) {
|
|
9871
|
-
// dev 已被 checkout/dialog hold 且 prompt 未送达;resumeAgent 不重派这些 hold,
|
|
9872
|
-
// 任务停在 in_progress 会同时封死 Resume 与 verdict——退回 verdict 门禁状态
|
|
9873
10222
|
const rolledBack = await this.transitionTaskStatus(taskId, task.status, { fromStatus: ['in_progress'] }, {
|
|
9874
10223
|
...(task.phase !== undefined ? { phase: task.phase } : {}),
|
|
9875
10224
|
agentId: task.agentId,
|
|
@@ -9881,8 +10230,6 @@ export class AgentManager {
|
|
|
9881
10230
|
+ 'moved on; leaving its status as-is');
|
|
9882
10231
|
}
|
|
9883
10232
|
else if (!devBoundBefore) {
|
|
9884
|
-
// 转码前 dev 未持有本任务(如 max_rounds 重占用):任务已退回
|
|
9885
|
-
// verdict 门禁,释放本次新占用,否则 dev 会被回退后的任务永久占住
|
|
9886
10233
|
const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
|
|
9887
10234
|
.catch(() => false);
|
|
9888
10235
|
if (!released) {
|
|
@@ -9907,8 +10254,6 @@ export class AgentManager {
|
|
|
9907
10254
|
});
|
|
9908
10255
|
return null;
|
|
9909
10256
|
}
|
|
9910
|
-
// 重占用(released dev)的 acquire 会写 bootstrappingTaskId,而 continueSession 不做
|
|
9911
|
-
// startSession 的 post-ack finalize;残留标记会让 recover 把已运行的任务按未送达回滚
|
|
9912
10257
|
await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
|
|
9913
10258
|
return await this.taskStore.get(taskId);
|
|
9914
10259
|
}
|
|
@@ -9916,7 +10261,6 @@ export class AgentManager {
|
|
|
9916
10261
|
const cfg = this.getAgentConfig(agentId);
|
|
9917
10262
|
if (!cfg)
|
|
9918
10263
|
throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
|
|
9919
|
-
// Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the text to the new session.
|
|
9920
10264
|
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
9921
10265
|
await this.acquireCompactGuard(agentId);
|
|
9922
10266
|
try {
|
|
@@ -9953,7 +10297,6 @@ export class AgentManager {
|
|
|
9953
10297
|
}
|
|
9954
10298
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
9955
10299
|
const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
|
|
9956
|
-
// resolveClaimedPane resolved by the CURRENT same-name session; re-verify incarnation + binding before paste (same as the image path).
|
|
9957
10300
|
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
9958
10301
|
throw new Error(`injectTextToAgent: agent ${agentId} was deleted or recreated before paste`);
|
|
9959
10302
|
}
|
|
@@ -10119,7 +10462,6 @@ export class AgentManager {
|
|
|
10119
10462
|
}
|
|
10120
10463
|
}
|
|
10121
10464
|
startCompactionThenRelease(agentId, state, taskId) {
|
|
10122
|
-
// 人工介入/重建中的 pane 不发 /clear;confirm 语义要求解绑,落回直接释放
|
|
10123
10465
|
const expectedPaneId = state.paneId;
|
|
10124
10466
|
if (!expectedPaneId || state.status === 'awaiting_human' || state.creationToken)
|
|
10125
10467
|
return false;
|
|
@@ -10137,8 +10479,8 @@ export class AgentManager {
|
|
|
10137
10479
|
function buildAgentIndex(config) {
|
|
10138
10480
|
const index = new Map();
|
|
10139
10481
|
for (const project of config.project) {
|
|
10140
|
-
for (const
|
|
10141
|
-
for (const agent of
|
|
10482
|
+
for (const team of project.agent) {
|
|
10483
|
+
for (const agent of team) {
|
|
10142
10484
|
index.set(agent.id, { ...agent, projectId: project.id });
|
|
10143
10485
|
}
|
|
10144
10486
|
}
|