baxian 1.2.58 → 1.2.60
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/dist/agent/branch.d.ts.map +1 -1
- package/dist/agent/branch.js +19 -13
- package/dist/agent/branch.js.map +1 -1
- package/dist/agent/dispatch-reconciler.d.ts +45 -0
- package/dist/agent/dispatch-reconciler.d.ts.map +1 -0
- package/dist/agent/dispatch-reconciler.js +513 -0
- package/dist/agent/dispatch-reconciler.js.map +1 -0
- package/dist/agent/manager.d.ts +204 -8
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +1982 -419
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/pane-streamer-manager.d.ts +70 -0
- package/dist/agent/pane-streamer-manager.d.ts.map +1 -1
- package/dist/agent/pane-streamer-manager.js +487 -5
- package/dist/agent/pane-streamer-manager.js.map +1 -1
- package/dist/agent/pane-streamer.d.ts +48 -1
- package/dist/agent/pane-streamer.d.ts.map +1 -1
- package/dist/agent/pane-streamer.js +338 -15
- package/dist/agent/pane-streamer.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +6 -1
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +51 -27
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +3 -1
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +27 -4
- package/dist/agent/phase-signal.js.map +1 -1
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +18 -8
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/repo-store.d.ts +3 -0
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +192 -67
- package/dist/agent/repo-store.js.map +1 -1
- package/dist/agent/review-transport.d.ts.map +1 -1
- package/dist/agent/review-transport.js +4 -2
- package/dist/agent/review-transport.js.map +1 -1
- package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
- package/dist/agent/tmux-probe-poller.js +29 -20
- package/dist/agent/tmux-probe-poller.js.map +1 -1
- package/dist/agent/tmux.d.ts +79 -32
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +435 -225
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +26 -4
- package/dist/api/config.js.map +1 -1
- package/dist/api/platform-guard.d.ts +7 -0
- package/dist/api/platform-guard.d.ts.map +1 -0
- package/dist/api/platform-guard.js +32 -0
- package/dist/api/platform-guard.js.map +1 -0
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +202 -97
- package/dist/api/projects.js.map +1 -1
- package/dist/api/tasks.d.ts.map +1 -1
- package/dist/api/tasks.js +5 -1
- package/dist/api/tasks.js.map +1 -1
- package/dist/app.d.ts +2 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +1 -0
- package/dist/app.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -3
- package/dist/cli.js.map +1 -1
- package/dist/config/hot-reload.d.ts.map +1 -1
- package/dist/config/hot-reload.js +1 -0
- package/dist/config/hot-reload.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +48 -3
- package/dist/config/loader.js.map +1 -1
- package/dist/config/validator.js +3 -0
- package/dist/config/validator.js.map +1 -1
- package/dist/errors.d.ts +2 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -1
- package/dist/errors.js.map +1 -1
- package/dist/event/handlers.d.ts +1 -0
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +641 -82
- package/dist/event/handlers.js.map +1 -1
- package/dist/github/mapper.d.ts +1 -6
- package/dist/github/mapper.d.ts.map +1 -1
- package/dist/github/mapper.js.map +1 -1
- package/dist/github/poller.d.ts +3 -3
- package/dist/github/poller.d.ts.map +1 -1
- package/dist/github/poller.js +2 -11
- package/dist/github/poller.js.map +1 -1
- package/dist/github/pr-conversation-cache.d.ts +41 -0
- package/dist/github/pr-conversation-cache.d.ts.map +1 -0
- package/dist/github/pr-conversation-cache.js +105 -0
- package/dist/github/pr-conversation-cache.js.map +1 -0
- package/dist/github/resolver.js +1 -1
- package/dist/github/resolver.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -2
- package/dist/index.js.map +1 -1
- package/dist/platform/driver-host.d.ts +7 -0
- package/dist/platform/driver-host.d.ts.map +1 -0
- package/dist/platform/driver-host.js +18 -0
- package/dist/platform/driver-host.js.map +1 -0
- package/dist/platform/feedback.d.ts +31 -0
- package/dist/platform/feedback.d.ts.map +1 -0
- package/dist/platform/feedback.js +85 -0
- package/dist/platform/feedback.js.map +1 -0
- package/dist/platform/platform-poller.d.ts +3 -2
- package/dist/platform/platform-poller.d.ts.map +1 -1
- package/dist/platform/platform-poller.js +32 -59
- package/dist/platform/platform-poller.js.map +1 -1
- package/dist/platform/poller-health.d.ts +3 -0
- package/dist/platform/poller-health.d.ts.map +1 -0
- package/dist/platform/poller-health.js +12 -0
- package/dist/platform/poller-health.js.map +1 -0
- package/dist/platform/pr-binding.d.ts +22 -0
- package/dist/platform/pr-binding.d.ts.map +1 -0
- package/dist/platform/pr-binding.js +28 -0
- package/dist/platform/pr-binding.js.map +1 -0
- package/dist/platform/types.d.ts +6 -0
- package/dist/platform/types.d.ts.map +1 -1
- package/dist/platform/types.js.map +1 -1
- package/dist/platform/verdict-engine.d.ts +7 -0
- package/dist/platform/verdict-engine.d.ts.map +1 -1
- package/dist/platform/verdict-engine.js +33 -24
- package/dist/platform/verdict-engine.js.map +1 -1
- package/dist/shared/constants.d.ts +6 -0
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +6 -0
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +39 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/state/agent-store.d.ts +5 -1
- package/dist/state/agent-store.d.ts.map +1 -1
- package/dist/state/agent-store.js +29 -15
- package/dist/state/agent-store.js.map +1 -1
- package/dist/state/lock.d.ts +9 -0
- package/dist/state/lock.d.ts.map +1 -1
- package/dist/state/lock.js +24 -1
- package/dist/state/lock.js.map +1 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +73 -4
- package/dist/state/task-store.js.map +1 -1
- package/dist/terminal/attach.d.ts +10 -1
- package/dist/terminal/attach.d.ts.map +1 -1
- package/dist/terminal/attach.js +40 -20
- package/dist/terminal/attach.js.map +1 -1
- package/dist/terminal/stream-ws.d.ts.map +1 -1
- package/dist/terminal/stream-ws.js +27 -0
- package/dist/terminal/stream-ws.js.map +1 -1
- package/dist/web/assets/{index-DfcbGriX.css → index-CwYBVTyn.css} +1 -1
- package/dist/web/assets/index-DtnDvjOU.js +16 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DskVWe7e.js +0 -13
package/dist/agent/manager.js
CHANGED
|
@@ -4,7 +4,12 @@ import { join, normalize } from 'node:path';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { createSignalToken } from './phase-signal.js';
|
|
6
6
|
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, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoSlug, } from '../shared/index.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, isSpecStagePhase, isGitHubRepo, parseGitRemote, repoIdentityKey, repoSlug, } from '../shared/index.js';
|
|
8
|
+
import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
|
|
9
|
+
import { DriverOpError } from '../platform/git-driver.js';
|
|
10
|
+
import { checkOpenPrBinding } from '../platform/pr-binding.js';
|
|
11
|
+
import { collectPendingFeedback, scanCommentSourcesOnce } from '../platform/feedback.js';
|
|
12
|
+
import { recheckPassProvenance } from '../platform/verdict-engine.js';
|
|
8
13
|
import { AGENT_STORE_NOOP } from '../state/agent-store.js';
|
|
9
14
|
import { PostApproveStore } from '../state/post-approve-store.js';
|
|
10
15
|
import { SkillRegistry } from '../skill/registry.js';
|
|
@@ -12,14 +17,16 @@ import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey,
|
|
|
12
17
|
import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
|
|
13
18
|
import { findForeignTaskTip } from './lineage.js';
|
|
14
19
|
import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
|
|
15
|
-
import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
|
|
16
|
-
import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError } from './branch.js';
|
|
17
|
-
import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded } from './repo-store.js';
|
|
20
|
+
import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, PaneGoneError, } from './tmux.js';
|
|
21
|
+
import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError, isAutoDeletableTaskBranch } from './branch.js';
|
|
22
|
+
import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded, canonicalSelfGuard } from './repo-store.js';
|
|
18
23
|
import { PhaseSignalWatcher } from './phase-signal-watcher.js';
|
|
19
24
|
import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
|
|
20
25
|
import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
|
|
21
26
|
import { ApiError } from '../errors.js';
|
|
22
27
|
import { prepareConfig } from '../config/loader.js';
|
|
28
|
+
import { resolveProjectTool } from '../config/validator.js';
|
|
29
|
+
import { SHA_HEX_SOURCE } from '../platform/types.js';
|
|
23
30
|
export class EnsureSessionError extends Error {
|
|
24
31
|
partial;
|
|
25
32
|
constructor(partial, message) {
|
|
@@ -140,7 +147,28 @@ export function skillSubdirFor(runtime) {
|
|
|
140
147
|
}
|
|
141
148
|
const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
|
|
142
149
|
const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
|
|
150
|
+
// DELETE runtime cleanup runs while holding the deletion tombstone + rotated lock; an unbounded remote
|
|
151
|
+
// probe/kill would block rollback/finally forever, so every tmux round-trip in it carries this deadline.
|
|
152
|
+
const DELETE_CLEANUP_TIMEOUT_MS = 15_000;
|
|
143
153
|
const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
|
|
154
|
+
const PLATFORM_HEAD_SHA_RE = new RegExp(`^${SHA_HEX_SOURCE}$`);
|
|
155
|
+
// 复核类失败(provenance/ack)与平台/瞬态失败分型:前者调用方退回 approved 重入闭环(计划 Task 8)
|
|
156
|
+
export class PlatformCloseError extends Error {
|
|
157
|
+
step;
|
|
158
|
+
constructor(step, cause) {
|
|
159
|
+
super(`platform ${step} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
160
|
+
this.step = step;
|
|
161
|
+
this.name = 'PlatformCloseError';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export class PlatformMergeRecheckError extends Error {
|
|
165
|
+
reason;
|
|
166
|
+
constructor(reason) {
|
|
167
|
+
super(`merge blocked: ${reason}`);
|
|
168
|
+
this.reason = reason;
|
|
169
|
+
this.name = 'PlatformMergeRecheckError';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
144
172
|
const MANUAL_SERVER_REVIEW_STATUSES = ['in_progress', 'review', 'fixing'];
|
|
145
173
|
const IMAGE_DISPATCH_PHASES = new Set(['develop', 'research', 'code', 'fix', 'server-feedback']);
|
|
146
174
|
const RUNTIME_LIVENESS_SAMPLES = 3;
|
|
@@ -204,6 +232,8 @@ export class AgentManager {
|
|
|
204
232
|
repoCache;
|
|
205
233
|
paneStreamerManager;
|
|
206
234
|
postApproveStore;
|
|
235
|
+
pluginRegistry;
|
|
236
|
+
platformDriverExec;
|
|
207
237
|
phaseSignalWatcher;
|
|
208
238
|
errorRecordStore;
|
|
209
239
|
reviewStore;
|
|
@@ -225,6 +255,9 @@ export class AgentManager {
|
|
|
225
255
|
manualCompactWaitMs = 5_000;
|
|
226
256
|
runtimeLivenessProbeMs = 700;
|
|
227
257
|
cleanComposerWaitMs = 5_000;
|
|
258
|
+
readyStableSamples = 3;
|
|
259
|
+
readyStableSpacingMs = 1_000;
|
|
260
|
+
pendingDispatchRetryByTask = new Map();
|
|
228
261
|
cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
|
|
229
262
|
manualReviewInFlight = new Set();
|
|
230
263
|
markCompleteInFlight = new Set();
|
|
@@ -233,6 +266,9 @@ export class AgentManager {
|
|
|
233
266
|
// 引用计数:cancelTask 与 startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
|
|
234
267
|
cancelCleanupInFlight = new Map();
|
|
235
268
|
deletionInFlight = new Set();
|
|
269
|
+
// Diverged ids live in a separate set the DELETE finally never clears: a post-commit replaceConfig throw must fail-stop for the whole process.
|
|
270
|
+
divergedAgents = new Set();
|
|
271
|
+
deletionGeneration = new Map();
|
|
236
272
|
compactInFlight = new Set();
|
|
237
273
|
constructor(deps) {
|
|
238
274
|
const config = prepareConfig(deps.config);
|
|
@@ -246,6 +282,7 @@ export class AgentManager {
|
|
|
246
282
|
this.repoStoreFactory = deps.repoStoreFactory;
|
|
247
283
|
this.paneStreamerManager = deps.paneStreamerManager;
|
|
248
284
|
this.postApproveStore = deps.postApproveStore ?? new PostApproveStore();
|
|
285
|
+
this.pluginRegistry = deps.pluginRegistry;
|
|
249
286
|
this.errorRecordStore = deps.errorRecordStore;
|
|
250
287
|
this.phaseSignalWatcher = deps.phaseSignalWatcher
|
|
251
288
|
?? (deps.paneStreamerManager
|
|
@@ -261,6 +298,7 @@ export class AgentManager {
|
|
|
261
298
|
this.cancelInterruptGuardWaitMs = this.dispatchAckTimeoutMs + 5_000;
|
|
262
299
|
this.agentIndex = buildAgentIndex(config);
|
|
263
300
|
this.platformRunner = deps.platformRunner ?? new LocalRunner();
|
|
301
|
+
this.platformDriverExec = makeDriverExec(this.platformRunner);
|
|
264
302
|
this.imageStagingRoot = deps.imageStagingRoot ?? join(tmpdir(), 'baxian-task-images');
|
|
265
303
|
this.repoCache = createRepoStoreCache();
|
|
266
304
|
this.bootstrapTimeoutsMs = {
|
|
@@ -426,14 +464,62 @@ export class AgentManager {
|
|
|
426
464
|
this.deletionInFlight.add(id);
|
|
427
465
|
return null;
|
|
428
466
|
}
|
|
467
|
+
// Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an active-scan that a concurrent in_progress commit then orphans.
|
|
468
|
+
async scanActiveThenClaimDeletion(targets) {
|
|
469
|
+
return this.withTaskLock(async () => {
|
|
470
|
+
for (const id of targets) {
|
|
471
|
+
const state = await this.agentStore.get(id);
|
|
472
|
+
const task = state?.taskId ? await this.taskStore.get(state.taskId) : null;
|
|
473
|
+
if (task && ACTIVE_TASK_STATUSES.has(task.status)) {
|
|
474
|
+
return { ok: false, code: 'active', agentId: id, taskId: task.id };
|
|
475
|
+
}
|
|
476
|
+
if (state?.status === 'awaiting_human')
|
|
477
|
+
continue;
|
|
478
|
+
if (state?.creationToken)
|
|
479
|
+
return { ok: false, code: 'bootstrapping', agentId: id };
|
|
480
|
+
}
|
|
481
|
+
const targetSet = new Set(targets);
|
|
482
|
+
const referencing = (await this.taskStore.list({})).find(t => ACTIVE_TASK_STATUSES.has(t.status)
|
|
483
|
+
&& ((t.preferredAgentId !== '' && targetSet.has(t.preferredAgentId))
|
|
484
|
+
|| (!!t.agentId && targetSet.has(t.agentId))
|
|
485
|
+
|| (!!t.devAgentId && targetSet.has(t.devAgentId))
|
|
486
|
+
|| (!!t.qaAgentId && targetSet.has(t.qaAgentId))
|
|
487
|
+
|| (!!t.researchAgentId && targetSet.has(t.researchAgentId))));
|
|
488
|
+
if (referencing)
|
|
489
|
+
return { ok: false, code: 'referencing', taskId: referencing.id };
|
|
490
|
+
const conflict = this.tryClaimDeletion(targets);
|
|
491
|
+
if (conflict)
|
|
492
|
+
return { ok: false, code: 'already-deleting', agentId: conflict };
|
|
493
|
+
return { ok: true };
|
|
494
|
+
});
|
|
495
|
+
}
|
|
429
496
|
releaseDeletionClaim(agentIds) {
|
|
430
497
|
for (const id of agentIds)
|
|
431
498
|
this.deletionInFlight.delete(id);
|
|
432
499
|
}
|
|
500
|
+
// A completed DELETE keeps blocking this id past tombstone release; the process dies with any suspended flow, so no persistence is needed.
|
|
501
|
+
bumpDeletionGeneration(agentId) {
|
|
502
|
+
this.deletionGeneration.set(agentId, this.deletionGenerationOf(agentId) + 1);
|
|
503
|
+
}
|
|
504
|
+
deletionGenerationOf(agentId) {
|
|
505
|
+
return this.deletionGeneration.get(agentId) ?? 0;
|
|
506
|
+
}
|
|
507
|
+
// Boolean tombstone alone is ABA-prone (delete→recreate same id); commits must hold both the tombstone-clear and an unchanged generation from entry.
|
|
508
|
+
deletionGateOpen(agentId, genAtEntry) {
|
|
509
|
+
return !this.isDeletionInFlight(agentId) && this.deletionGenerationOf(agentId) === genAtEntry;
|
|
510
|
+
}
|
|
433
511
|
isDeletionInFlight(agentId) {
|
|
434
|
-
return this.deletionInFlight.has(agentId);
|
|
512
|
+
return this.deletionInFlight.has(agentId) || this.divergedAgents.has(agentId);
|
|
513
|
+
}
|
|
514
|
+
// replaceConfig 抛错后的 fail-stop:进入 divergedAgents(不随 DELETE finally 释放),重建/分配持续被拒直到重启。
|
|
515
|
+
retainTombstoneForDivergence(agentId) {
|
|
516
|
+
this.divergedAgents.add(agentId);
|
|
435
517
|
}
|
|
436
|
-
async ensureSession(agentId, mode) {
|
|
518
|
+
async ensureSession(agentId, mode, opts = {}) {
|
|
519
|
+
const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
|
|
520
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
521
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `agent ${agentId} is being deleted (or was deleted and recreated); refusing session work`);
|
|
522
|
+
}
|
|
437
523
|
const agent = this.getAgentConfig(agentId);
|
|
438
524
|
if (!agent) {
|
|
439
525
|
throw new EnsureSessionError({ createdSession: false, agentId }, `Unknown agent: ${agentId}`);
|
|
@@ -444,48 +530,67 @@ export class AgentManager {
|
|
|
444
530
|
}
|
|
445
531
|
const runner = this.createRunnerFor(agent);
|
|
446
532
|
const tmux = new TmuxManager(runner);
|
|
533
|
+
// 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.
|
|
534
|
+
return this.runUnderSessionLifecycle(agentId, () => this.ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry));
|
|
535
|
+
}
|
|
536
|
+
async ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry) {
|
|
537
|
+
const assertGenerationOpen = () => {
|
|
538
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
539
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `agent ${agentId} is being deleted (or was deleted and recreated); refusing session work`);
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
assertGenerationOpen();
|
|
447
543
|
let workdir;
|
|
448
544
|
try {
|
|
449
545
|
const resolved = await this.ensureWorkdir(agent, project, runner);
|
|
450
546
|
workdir = resolved.workdir;
|
|
451
|
-
await this.agentStore.update(agentId, (existing) => {
|
|
452
|
-
if (!existing)
|
|
453
|
-
return AGENT_STORE_NOOP;
|
|
454
|
-
return {
|
|
455
|
-
...existing,
|
|
456
|
-
workdir,
|
|
457
|
-
updatedAt: new Date().toISOString(),
|
|
458
|
-
};
|
|
459
|
-
});
|
|
460
547
|
}
|
|
461
548
|
catch (err) {
|
|
462
549
|
throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
463
550
|
}
|
|
551
|
+
// Gate the Workdir write too: a DELETE→recreate during the slow ensureWorkdir must not persist a stale Workdir onto the new incarnation.
|
|
552
|
+
assertGenerationOpen();
|
|
553
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
554
|
+
if (!existing || !this.deletionGateOpen(agentId, genAtEntry))
|
|
555
|
+
return AGENT_STORE_NOOP;
|
|
556
|
+
return { ...existing, workdir, updatedAt: new Date().toISOString() };
|
|
557
|
+
});
|
|
558
|
+
assertGenerationOpen();
|
|
464
559
|
try {
|
|
465
560
|
await this.provisionRepoSkills(runner, agent, workdir);
|
|
466
561
|
}
|
|
467
562
|
catch (err) {
|
|
468
563
|
throw new EnsureSessionError({ createdSession: false, agentId }, `skill provisioning failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
469
564
|
}
|
|
470
|
-
|
|
565
|
+
assertGenerationOpen();
|
|
566
|
+
let snapshot;
|
|
471
567
|
try {
|
|
472
|
-
|
|
568
|
+
snapshot = await tmux.getSessionSnapshot(agentId);
|
|
473
569
|
}
|
|
474
570
|
catch (err) {
|
|
475
571
|
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux probe failed: ${err instanceof Error ? err.message : String(err)}; ` +
|
|
476
572
|
`if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
|
|
477
573
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
574
|
+
// 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.
|
|
575
|
+
assertGenerationOpen();
|
|
576
|
+
if (!snapshot) {
|
|
577
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
|
|
578
|
+
}
|
|
579
|
+
if (snapshot.claim === null) {
|
|
580
|
+
if (await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
|
|
581
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
|
|
483
582
|
}
|
|
583
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
|
|
584
|
+
`it creates itself — kill it manually or pick a different agent id`);
|
|
484
585
|
}
|
|
485
|
-
if (
|
|
486
|
-
|
|
586
|
+
if (mode === 'create' || snapshot.claim !== agentId) {
|
|
587
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, mode === 'create'
|
|
588
|
+
? `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
|
|
589
|
+
`it creates itself — kill it manually or pick a different agent id`
|
|
590
|
+
: `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
|
|
591
|
+
`(got "${snapshot.claim}"); baxian will not adopt foreign session — operator must intervene`);
|
|
487
592
|
}
|
|
488
|
-
return this.
|
|
593
|
+
return this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, workdir, snapshot);
|
|
489
594
|
}
|
|
490
595
|
async provisionRepoSkills(runner, agent, workdir) {
|
|
491
596
|
if (this.skillRegistry.names().length === 0)
|
|
@@ -554,9 +659,10 @@ export class AgentManager {
|
|
|
554
659
|
return;
|
|
555
660
|
}
|
|
556
661
|
const runner = this.createRunnerFor(cfg);
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
662
|
+
// Rollback may run before or after the claim write, so accept empty-or-ours.
|
|
663
|
+
const outcome = await new TmuxManager(runner).killSessionRef(ref, { kind: 'emptyOr', claim: agentId });
|
|
664
|
+
if (outcome === 'refused') {
|
|
665
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux generation/claim changed — leaving session ${ref.sessionId} untouched`);
|
|
560
666
|
}
|
|
561
667
|
else if (outcome === 'killed') {
|
|
562
668
|
console.warn(`[AgentManager] ${agentId} rollback (${reason}): killed created session ${ref.sessionId}`);
|
|
@@ -603,7 +709,9 @@ export class AgentManager {
|
|
|
603
709
|
async ensureSkillDirSafe(runner, workdir, subdir) {
|
|
604
710
|
const top = subdir.split('/')[0];
|
|
605
711
|
const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
|
|
606
|
-
const inner =
|
|
712
|
+
const inner =
|
|
713
|
+
// canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor, not just a symlinked leaf, so the find/rm can't escape.
|
|
714
|
+
`${canonicalSelfGuard(workdir)} && ` +
|
|
607
715
|
`cd ${shellQuote(workdir)} && ` +
|
|
608
716
|
`for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
|
|
609
717
|
`printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
|
|
@@ -624,14 +732,16 @@ export class AgentManager {
|
|
|
624
732
|
['@baxian-skills-version', this.skillRegistry.contentHash()],
|
|
625
733
|
]);
|
|
626
734
|
}
|
|
627
|
-
async replSkillsStale(tmux, agentId) {
|
|
735
|
+
async replSkillsStale(tmux, agentId, ref) {
|
|
628
736
|
if (this.skillRegistry.names().length === 0)
|
|
629
737
|
return false;
|
|
630
|
-
const tagged = await tmux.
|
|
738
|
+
const tagged = await tmux.getSessionOptionByRef(ref, agentId, '@baxian-skills-version');
|
|
631
739
|
return tagged !== this.skillRegistry.contentHash();
|
|
632
740
|
}
|
|
633
741
|
async excludeInjectedSkills(runner, workdir, subdir) {
|
|
634
|
-
const inner =
|
|
742
|
+
const inner =
|
|
743
|
+
// canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor too, so the info/exclude write can't escape.
|
|
744
|
+
`${canonicalSelfGuard(workdir)} && ` +
|
|
635
745
|
`cd ${shellQuote(workdir)} && ` +
|
|
636
746
|
`if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
|
|
637
747
|
`tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
|
|
@@ -664,12 +774,26 @@ export class AgentManager {
|
|
|
664
774
|
}
|
|
665
775
|
// A name (or even a bare $id) can rebind to a same-name successor mid-flight;
|
|
666
776
|
// every option write goes through the identity-checked by-ref batch.
|
|
667
|
-
async setSessionOptions(tmux, agentId, ref, entries) {
|
|
668
|
-
const outcome = await tmux.setSessionOptionsIfAlive(ref, entries);
|
|
777
|
+
async setSessionOptions(tmux, agentId, ref, entries, expectedClaim = agentId) {
|
|
778
|
+
const outcome = await tmux.setSessionOptionsIfAlive(ref, entries, { expectedClaim });
|
|
669
779
|
if (outcome === 'gone') {
|
|
670
780
|
throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
|
|
671
781
|
}
|
|
672
782
|
}
|
|
783
|
+
// Snapshot's claim check is a filter; the returned PaneRef re-proves generation+claim on every op.
|
|
784
|
+
async resolveClaimedPane(tmux, agentId, expectedPaneId) {
|
|
785
|
+
const snapshot = await tmux.getSessionSnapshot(agentId);
|
|
786
|
+
if (!snapshot)
|
|
787
|
+
throw new PaneGoneError(agentId, 'no tmux session');
|
|
788
|
+
if (snapshot.claim !== agentId) {
|
|
789
|
+
throw new PaneGoneError(agentId, `session claim mismatch (got "${snapshot.claim ?? 'null'}")`);
|
|
790
|
+
}
|
|
791
|
+
const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
|
|
792
|
+
if (expectedPaneId !== undefined && pane.paneId !== expectedPaneId) {
|
|
793
|
+
throw new PaneGoneError(agentId, `pane id changed (state ${expectedPaneId} != live ${pane.paneId})`);
|
|
794
|
+
}
|
|
795
|
+
return pane;
|
|
796
|
+
}
|
|
673
797
|
async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
|
|
674
798
|
return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
|
|
675
799
|
}
|
|
@@ -681,6 +805,7 @@ export class AgentManager {
|
|
|
681
805
|
try {
|
|
682
806
|
createdRef = await tmux.createSession(agentId, sessionDir);
|
|
683
807
|
createdSession = true;
|
|
808
|
+
// First batch writes the claim itself, so it must prove the session is still unclaimed.
|
|
684
809
|
await this.setSessionOptions(tmux, agentId, createdRef, [
|
|
685
810
|
['@baxian-agent-id', agentId],
|
|
686
811
|
['@baxian-runtime', agent.runtime],
|
|
@@ -690,20 +815,21 @@ export class AgentManager {
|
|
|
690
815
|
['window-size', 'latest'],
|
|
691
816
|
...this.runtimePinEntries(),
|
|
692
817
|
['status-right', ''],
|
|
693
|
-
]);
|
|
818
|
+
], '');
|
|
694
819
|
await tmux.setServerOption('extended-keys', 'on');
|
|
695
820
|
await tmux.appendServerOptionIfMissing('terminal-features', 'xterm*:extkeys');
|
|
696
|
-
const
|
|
697
|
-
await tmux.
|
|
698
|
-
await tmux.
|
|
821
|
+
const pane = await tmux.getSinglePaneByRef(createdRef, agentId);
|
|
822
|
+
await tmux.sendKeysLiteral(pane, launchCommandIn(sessionDir, agent));
|
|
823
|
+
await tmux.sendEnter(pane);
|
|
824
|
+
await tmux.handleTrustDialog(pane, runtime, {
|
|
699
825
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
700
826
|
});
|
|
701
|
-
await tmux.waitReplReady(
|
|
827
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
702
828
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
703
829
|
scrollback: 0,
|
|
704
830
|
});
|
|
705
831
|
await this.tagSessionSkillsVersion(tmux, agentId, createdRef);
|
|
706
|
-
return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir, sessionRef: createdRef };
|
|
832
|
+
return { ok: true, createdSession: true, freshRuntime: true, paneId: pane.paneId, pane, workdir, sessionRef: createdRef };
|
|
707
833
|
}
|
|
708
834
|
catch (err) {
|
|
709
835
|
const partial = { createdSession, agentId, genAtCreate };
|
|
@@ -726,7 +852,7 @@ export class AgentManager {
|
|
|
726
852
|
}
|
|
727
853
|
try {
|
|
728
854
|
const result = await this.ensureSession(agentId, 'create');
|
|
729
|
-
if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.
|
|
855
|
+
if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.pane))) {
|
|
730
856
|
const current = await this.agentStore.get(agentId);
|
|
731
857
|
if (current && current.creationToken !== creationToken) {
|
|
732
858
|
console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — leaving the session to its successor`);
|
|
@@ -787,7 +913,7 @@ export class AgentManager {
|
|
|
787
913
|
await this.markBootstrapFailed(agentId, creationToken, message);
|
|
788
914
|
}
|
|
789
915
|
}
|
|
790
|
-
async runGreetingHandshake(agentId, agent,
|
|
916
|
+
async runGreetingHandshake(agentId, agent, pane) {
|
|
791
917
|
const watcher = this.phaseSignalWatcher;
|
|
792
918
|
if (!watcher)
|
|
793
919
|
return true;
|
|
@@ -795,7 +921,7 @@ export class AgentManager {
|
|
|
795
921
|
for (let attempt = 1; attempt <= this.greetingMaxAttempts; attempt++) {
|
|
796
922
|
const token = createSignalToken();
|
|
797
923
|
try {
|
|
798
|
-
await this.injectAndAwaitAckSteps(tmux,
|
|
924
|
+
await this.injectAndAwaitAckSteps(tmux, pane, buildGreetingPrompt(token, agent.runtime), agentId, agent.runtime);
|
|
799
925
|
}
|
|
800
926
|
catch (err) {
|
|
801
927
|
console.warn(`[bootstrap] greeting inject failed for ${agentId} (attempt ${attempt}):`, err);
|
|
@@ -814,7 +940,7 @@ export class AgentManager {
|
|
|
814
940
|
console.warn(`[bootstrap] greeting attempt ${attempt}/${this.greetingMaxAttempts} for ${agentId}: ${outcome}`);
|
|
815
941
|
if (outcome === 'no-agent')
|
|
816
942
|
break;
|
|
817
|
-
if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux,
|
|
943
|
+
if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, pane, agentId))) {
|
|
818
944
|
break;
|
|
819
945
|
}
|
|
820
946
|
}
|
|
@@ -856,21 +982,25 @@ export class AgentManager {
|
|
|
856
982
|
const agent = this.getAgentConfig(agentId);
|
|
857
983
|
if (!agent)
|
|
858
984
|
return false;
|
|
985
|
+
// Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the greeting handshake to the new session.
|
|
986
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
859
987
|
const state = await this.agentStore.get(agentId);
|
|
860
988
|
if (state?.awaitingPhase !== 'greeting_failed')
|
|
861
989
|
return false;
|
|
862
990
|
const guardSince = state.awaitingSince;
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
991
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
992
|
+
return false;
|
|
993
|
+
let pane;
|
|
994
|
+
try {
|
|
995
|
+
pane = await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(agent)), agentId, state.paneId);
|
|
996
|
+
}
|
|
997
|
+
catch (err) {
|
|
998
|
+
console.warn(`[regreet] cannot resolve pane for ${agentId}:`, err);
|
|
999
|
+
return true;
|
|
872
1000
|
}
|
|
873
|
-
if (!
|
|
1001
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
1002
|
+
return false;
|
|
1003
|
+
if (!(await this.runGreetingHandshake(agentId, agent, pane))) {
|
|
874
1004
|
return true;
|
|
875
1005
|
}
|
|
876
1006
|
await this.agentStore.update(agentId, (fresh) => {
|
|
@@ -907,8 +1037,7 @@ export class AgentManager {
|
|
|
907
1037
|
let paneId = existing.paneId;
|
|
908
1038
|
if (cfg && !paneId) {
|
|
909
1039
|
try {
|
|
910
|
-
|
|
911
|
-
paneId = await new TmuxManager(runner).getSinglePaneId(agentId);
|
|
1040
|
+
paneId = (await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), agentId)).paneId;
|
|
912
1041
|
}
|
|
913
1042
|
catch {
|
|
914
1043
|
}
|
|
@@ -985,8 +1114,10 @@ export class AgentManager {
|
|
|
985
1114
|
if (!(err instanceof EnsureSessionError) || !err.partial.dialogPending) {
|
|
986
1115
|
return false;
|
|
987
1116
|
}
|
|
1117
|
+
// 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.
|
|
1118
|
+
const gateOpen = () => opts.expectedGeneration === undefined || this.deletionGateOpen(agentId, opts.expectedGeneration);
|
|
988
1119
|
let state = await this.agentStore.get(agentId);
|
|
989
|
-
if (!state)
|
|
1120
|
+
if (!state || !gateOpen())
|
|
990
1121
|
return false;
|
|
991
1122
|
if (state.paneId === undefined && err.partial.createdSession) {
|
|
992
1123
|
const cfg = this.getAgentConfig(agentId);
|
|
@@ -994,8 +1125,7 @@ export class AgentManager {
|
|
|
994
1125
|
return false;
|
|
995
1126
|
let discoveredPaneId;
|
|
996
1127
|
try {
|
|
997
|
-
|
|
998
|
-
discoveredPaneId = await new TmuxManager(runner).getSinglePaneId(agentId);
|
|
1128
|
+
discoveredPaneId = (await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), agentId)).paneId;
|
|
999
1129
|
}
|
|
1000
1130
|
catch (probeErr) {
|
|
1001
1131
|
console.warn(`[AgentManager] handleDialogPendingFromRuntime: tmux probe paneId failed for ${agentId}:`, probeErr);
|
|
@@ -1005,7 +1135,7 @@ export class AgentManager {
|
|
|
1005
1135
|
return false;
|
|
1006
1136
|
const probeNow = new Date().toISOString();
|
|
1007
1137
|
await this.agentStore.update(agentId, (fresh) => {
|
|
1008
|
-
if (!fresh)
|
|
1138
|
+
if (!fresh || !gateOpen())
|
|
1009
1139
|
return AGENT_STORE_NOOP;
|
|
1010
1140
|
if (fresh.paneId !== undefined)
|
|
1011
1141
|
return AGENT_STORE_NOOP;
|
|
@@ -1019,6 +1149,8 @@ export class AgentManager {
|
|
|
1019
1149
|
console.warn(`[AgentManager] handleDialogPendingFromRuntime: ${agentId} has no paneId/taskId snapshot (no generation guard); refusing — caller should rollback`);
|
|
1020
1150
|
return false;
|
|
1021
1151
|
}
|
|
1152
|
+
if (!gateOpen())
|
|
1153
|
+
return false;
|
|
1022
1154
|
await this.markDialogPending(agentId, undefined, {
|
|
1023
1155
|
runtimePath: true,
|
|
1024
1156
|
...(state.paneId !== undefined ? { expectedPaneId: state.paneId } : {}),
|
|
@@ -1088,15 +1220,16 @@ export class AgentManager {
|
|
|
1088
1220
|
return;
|
|
1089
1221
|
if (generationMismatch(state))
|
|
1090
1222
|
return;
|
|
1091
|
-
let
|
|
1223
|
+
let pane;
|
|
1092
1224
|
try {
|
|
1093
|
-
|
|
1225
|
+
pane = await this.resolveClaimedPane(tmux, agentId);
|
|
1094
1226
|
}
|
|
1095
1227
|
catch {
|
|
1096
1228
|
continue;
|
|
1097
1229
|
}
|
|
1230
|
+
const paneId = pane.paneId;
|
|
1098
1231
|
try {
|
|
1099
|
-
await tmux.waitReplReady(
|
|
1232
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
1100
1233
|
timeoutMs: 1_000,
|
|
1101
1234
|
intervalMs: 200,
|
|
1102
1235
|
scrollback: 0,
|
|
@@ -1114,7 +1247,7 @@ export class AgentManager {
|
|
|
1114
1247
|
let projectIdForEmit = '';
|
|
1115
1248
|
let wrote = false;
|
|
1116
1249
|
const isBootstrapPath = creationToken !== undefined;
|
|
1117
|
-
if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg,
|
|
1250
|
+
if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, pane))) {
|
|
1118
1251
|
await this.markGreetingFailed(agentId, creationToken);
|
|
1119
1252
|
return;
|
|
1120
1253
|
}
|
|
@@ -1209,7 +1342,8 @@ export class AgentManager {
|
|
|
1209
1342
|
continue;
|
|
1210
1343
|
let stripped;
|
|
1211
1344
|
try {
|
|
1212
|
-
|
|
1345
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, state.paneId);
|
|
1346
|
+
stripped = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
1213
1347
|
}
|
|
1214
1348
|
catch {
|
|
1215
1349
|
continue;
|
|
@@ -1250,29 +1384,9 @@ export class AgentManager {
|
|
|
1250
1384
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
1251
1385
|
});
|
|
1252
1386
|
}
|
|
1253
|
-
//
|
|
1254
|
-
async
|
|
1255
|
-
return this.runUnderSessionLifecycle(agentId, () => this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir));
|
|
1256
|
-
}
|
|
1257
|
-
async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir) {
|
|
1258
|
-
let snapshot;
|
|
1259
|
-
try {
|
|
1260
|
-
snapshot = await tmux.getSessionSnapshot(agentId);
|
|
1261
|
-
}
|
|
1262
|
-
catch (err) {
|
|
1263
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1264
|
-
}
|
|
1265
|
-
if (!snapshot) {
|
|
1266
|
-
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1267
|
-
}
|
|
1387
|
+
// Runs inside the caller's lifecycle section (ensureSessionLocked routed the snapshot here).
|
|
1388
|
+
async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir, snapshot) {
|
|
1268
1389
|
const sessionRef = snapshot.ref;
|
|
1269
|
-
if (snapshot.claim !== agentId) {
|
|
1270
|
-
if (snapshot.claim === null && await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
|
|
1271
|
-
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1272
|
-
}
|
|
1273
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
|
|
1274
|
-
`(got "${snapshot.claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
|
|
1275
|
-
}
|
|
1276
1390
|
this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
|
|
1277
1391
|
try {
|
|
1278
1392
|
await this.setSessionOptions(tmux, agentId, sessionRef, this.runtimePinEntries());
|
|
@@ -1280,24 +1394,25 @@ export class AgentManager {
|
|
|
1280
1394
|
catch (err) {
|
|
1281
1395
|
throw new EnsureSessionError({ createdSession: false, agentId }, `pinning runtime session options failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1282
1396
|
}
|
|
1283
|
-
let
|
|
1397
|
+
let pane;
|
|
1284
1398
|
try {
|
|
1285
|
-
|
|
1399
|
+
pane = await tmux.getSinglePaneByRef(sessionRef, agentId);
|
|
1286
1400
|
}
|
|
1287
1401
|
catch (err) {
|
|
1288
1402
|
throw new EnsureSessionError({ createdSession: false, agentId }, `getSinglePaneId failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1289
1403
|
}
|
|
1404
|
+
const paneId = pane.paneId;
|
|
1290
1405
|
const runtime = agentRuntimeKindFor(agent);
|
|
1291
1406
|
let state;
|
|
1292
1407
|
try {
|
|
1293
|
-
state = await tmux.classifyPaneForAdopt(
|
|
1408
|
+
state = await tmux.classifyPaneForAdopt(pane, runtime);
|
|
1294
1409
|
}
|
|
1295
1410
|
catch (err) {
|
|
1296
1411
|
throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1297
1412
|
}
|
|
1298
1413
|
let currentPath;
|
|
1299
1414
|
try {
|
|
1300
|
-
currentPath = await tmux.getPaneCurrentPath(
|
|
1415
|
+
currentPath = await tmux.getPaneCurrentPath(pane);
|
|
1301
1416
|
}
|
|
1302
1417
|
catch (err) {
|
|
1303
1418
|
throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1312,7 +1427,7 @@ export class AgentManager {
|
|
|
1312
1427
|
if (!pathMatches) {
|
|
1313
1428
|
if (state.kind === 'live-runtime') {
|
|
1314
1429
|
try {
|
|
1315
|
-
await this.waitForReplPromptReady(tmux,
|
|
1430
|
+
await this.waitForReplPromptReady(tmux, pane, agent.runtime, this.cleanComposerWaitMs);
|
|
1316
1431
|
}
|
|
1317
1432
|
catch (err) {
|
|
1318
1433
|
throw new EnsureSessionError({ createdSession: false, agentId }, `Workdir change requires an idle runtime: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1329,7 +1444,7 @@ export class AgentManager {
|
|
|
1329
1444
|
case 'live-runtime': {
|
|
1330
1445
|
let stale;
|
|
1331
1446
|
try {
|
|
1332
|
-
stale = await this.replSkillsStale(tmux, agentId);
|
|
1447
|
+
stale = await this.replSkillsStale(tmux, agentId, sessionRef);
|
|
1333
1448
|
}
|
|
1334
1449
|
catch (err) {
|
|
1335
1450
|
throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1341,11 +1456,12 @@ export class AgentManager {
|
|
|
1341
1456
|
freshRuntime: false,
|
|
1342
1457
|
skillsStale: true,
|
|
1343
1458
|
paneId,
|
|
1459
|
+
pane,
|
|
1344
1460
|
workdir,
|
|
1345
1461
|
sessionRef,
|
|
1346
1462
|
};
|
|
1347
1463
|
}
|
|
1348
|
-
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir, sessionRef };
|
|
1464
|
+
return { ok: true, createdSession: false, freshRuntime: false, paneId, pane, workdir, sessionRef };
|
|
1349
1465
|
}
|
|
1350
1466
|
case 'startup-dialog':
|
|
1351
1467
|
throw new EnsureSessionError({
|
|
@@ -1360,15 +1476,15 @@ export class AgentManager {
|
|
|
1360
1476
|
`must reset the pane manually`);
|
|
1361
1477
|
case 'trust-dialog':
|
|
1362
1478
|
try {
|
|
1363
|
-
await tmux.handleTrustDialog(
|
|
1479
|
+
await tmux.handleTrustDialog(pane, runtime, {
|
|
1364
1480
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
1365
1481
|
});
|
|
1366
|
-
await tmux.waitReplReady(
|
|
1482
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
1367
1483
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1368
1484
|
scrollback: 0,
|
|
1369
1485
|
});
|
|
1370
1486
|
await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
|
|
1371
|
-
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
|
|
1487
|
+
return { ok: true, createdSession: false, freshRuntime: true, paneId, pane, workdir, sessionRef };
|
|
1372
1488
|
}
|
|
1373
1489
|
catch (trustErr) {
|
|
1374
1490
|
if (trustErr instanceof ReplNotReadyError && detectStartupDialog(trustErr.lastScreen, runtime)) {
|
|
@@ -1386,16 +1502,17 @@ export class AgentManager {
|
|
|
1386
1502
|
break;
|
|
1387
1503
|
}
|
|
1388
1504
|
try {
|
|
1389
|
-
await tmux.
|
|
1390
|
-
await tmux.
|
|
1505
|
+
await tmux.sendKeysLiteral(pane, launchCommandIn(sessionDir, agent));
|
|
1506
|
+
await tmux.sendEnter(pane);
|
|
1507
|
+
await tmux.handleTrustDialog(pane, runtime, {
|
|
1391
1508
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
1392
1509
|
});
|
|
1393
|
-
await tmux.waitReplReady(
|
|
1510
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
1394
1511
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1395
1512
|
scrollback: 0,
|
|
1396
1513
|
});
|
|
1397
1514
|
await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
|
|
1398
|
-
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
|
|
1515
|
+
return { ok: true, createdSession: false, freshRuntime: true, paneId, pane, workdir, sessionRef };
|
|
1399
1516
|
}
|
|
1400
1517
|
catch (relErr) {
|
|
1401
1518
|
if (relErr instanceof ReplNotReadyError && detectStartupDialog(relErr.lastScreen, runtime)) {
|
|
@@ -1435,12 +1552,12 @@ export class AgentManager {
|
|
|
1435
1552
|
let outcome;
|
|
1436
1553
|
try {
|
|
1437
1554
|
// claim-still-empty rides the kill itself — a claim racing the probes makes the server refuse.
|
|
1438
|
-
outcome = await tmux.killSessionRef(snapshot.ref, {
|
|
1555
|
+
outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' });
|
|
1439
1556
|
}
|
|
1440
1557
|
catch (err) {
|
|
1441
1558
|
throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (half-created leftover) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1442
1559
|
}
|
|
1443
|
-
if (outcome === 'refused'
|
|
1560
|
+
if (outcome === 'refused') {
|
|
1444
1561
|
throw new EnsureSessionError({ createdSession: false, agentId }, `session ${snapshot.ref.sessionId} was claimed (or its server changed) while reclaiming the half-created leftover — retry to observe the new state`);
|
|
1445
1562
|
}
|
|
1446
1563
|
if (outcome === 'killed') {
|
|
@@ -1454,13 +1571,13 @@ export class AgentManager {
|
|
|
1454
1571
|
async killAdoptTarget(tmux, agentId, sessionRef, reason) {
|
|
1455
1572
|
let outcome;
|
|
1456
1573
|
try {
|
|
1457
|
-
outcome = await tmux.killSessionRef(sessionRef);
|
|
1574
|
+
outcome = await tmux.killSessionRef(sessionRef, { kind: 'equals', claim: agentId });
|
|
1458
1575
|
}
|
|
1459
1576
|
catch (err) {
|
|
1460
1577
|
throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1461
1578
|
}
|
|
1462
|
-
if (outcome === '
|
|
1463
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux
|
|
1579
|
+
if (outcome === 'refused') {
|
|
1580
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux generation/claim changed while replacing session (${reason}); retry to observe the new state`);
|
|
1464
1581
|
}
|
|
1465
1582
|
if (outcome === 'killed') {
|
|
1466
1583
|
console.warn(`[AgentManager] ${agentId}: killed session ${sessionRef.sessionId} (${reason})`);
|
|
@@ -1469,7 +1586,17 @@ export class AgentManager {
|
|
|
1469
1586
|
console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
|
|
1470
1587
|
}
|
|
1471
1588
|
}
|
|
1472
|
-
|
|
1589
|
+
// 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.
|
|
1590
|
+
bindingInitPredicate(agentId, genAtEntry) {
|
|
1591
|
+
return this.deletionGateOpen(agentId, genAtEntry) && this.getAgentConfig(agentId) !== undefined;
|
|
1592
|
+
}
|
|
1593
|
+
async acquireAgentForTask(agentId, taskId, phase,
|
|
1594
|
+
// 锁代必须由 acquire 原子交出:事后重读 claim/binding 的窗口里并发派发方可以
|
|
1595
|
+
// release + re-acquire 同一 agent/同一 task,读到的会是 successor 的代
|
|
1596
|
+
opts = {}) {
|
|
1597
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
1598
|
+
if (this.isDeletionInFlight(agentId))
|
|
1599
|
+
return false;
|
|
1473
1600
|
const cfg = this.getAgentConfig(agentId);
|
|
1474
1601
|
if (!cfg)
|
|
1475
1602
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
@@ -1493,16 +1620,45 @@ export class AgentManager {
|
|
|
1493
1620
|
const token = reuseLock ? existingToken : await this.lockManager.acquire(agentId, taskId);
|
|
1494
1621
|
if (!token)
|
|
1495
1622
|
return false;
|
|
1623
|
+
const releaseAcquired = async (originalErr) => {
|
|
1624
|
+
if (reuseLock)
|
|
1625
|
+
return;
|
|
1626
|
+
try {
|
|
1627
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, token);
|
|
1628
|
+
}
|
|
1629
|
+
catch (releaseErr) {
|
|
1630
|
+
console.warn(`[AgentManager] acquireAgentForTask(${agentId}, ${taskId}): lock release after ` +
|
|
1631
|
+
`${originalErr === undefined ? 'refused commit' : 'commit failure'} itself failed:`, releaseErr);
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1496
1634
|
const now = new Date().toISOString();
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1635
|
+
let commit;
|
|
1636
|
+
try {
|
|
1637
|
+
commit = await this.agentStore.update(agentId, (existing) => {
|
|
1638
|
+
if (existing === null && !this.bindingInitPredicate(agentId, genAtEntry))
|
|
1639
|
+
return AGENT_STORE_NOOP;
|
|
1640
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
1641
|
+
return AGENT_STORE_NOOP;
|
|
1642
|
+
return {
|
|
1643
|
+
...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
|
|
1644
|
+
id: agentId,
|
|
1645
|
+
projectId: cfg.projectId,
|
|
1646
|
+
taskId,
|
|
1647
|
+
lockToken: token,
|
|
1648
|
+
...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
|
|
1649
|
+
updatedAt: now,
|
|
1650
|
+
};
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
catch (err) {
|
|
1654
|
+
await releaseAcquired(err);
|
|
1655
|
+
throw err;
|
|
1656
|
+
}
|
|
1657
|
+
if (commit !== 'committed') {
|
|
1658
|
+
await releaseAcquired();
|
|
1659
|
+
return false;
|
|
1660
|
+
}
|
|
1661
|
+
opts.onAcquired?.(token);
|
|
1506
1662
|
return true;
|
|
1507
1663
|
}
|
|
1508
1664
|
async releaseAgentForTask(agentId, expectedTaskId, mode, opts = {}) {
|
|
@@ -1662,7 +1818,7 @@ export class AgentManager {
|
|
|
1662
1818
|
return hold(runtime.reason);
|
|
1663
1819
|
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1664
1820
|
if (runtime.kind === 'pane') {
|
|
1665
|
-
if (state.paneId !== runtime.paneId) {
|
|
1821
|
+
if (state.paneId !== runtime.pane.paneId) {
|
|
1666
1822
|
await this.agentStore.update(agentId, (latest) => {
|
|
1667
1823
|
if (!latest
|
|
1668
1824
|
|| latest.taskId !== expectedTaskId
|
|
@@ -1670,14 +1826,14 @@ export class AgentManager {
|
|
|
1670
1826
|
|| latest.workdir !== releaseWorkdir) {
|
|
1671
1827
|
return AGENT_STORE_NOOP;
|
|
1672
1828
|
}
|
|
1673
|
-
return { ...latest, paneId: runtime.paneId, updatedAt: new Date().toISOString() };
|
|
1829
|
+
return { ...latest, paneId: runtime.pane.paneId, updatedAt: new Date().toISOString() };
|
|
1674
1830
|
});
|
|
1675
1831
|
const refreshed = await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1676
|
-
if (refreshed.paneId !== runtime.paneId) {
|
|
1832
|
+
if (refreshed.paneId !== runtime.pane.paneId) {
|
|
1677
1833
|
throw new Error(`Agent ${agentId} runtime pane changed during release; operation aborted`);
|
|
1678
1834
|
}
|
|
1679
1835
|
}
|
|
1680
|
-
await this.waitForReplPromptReady(tmux, runtime.
|
|
1836
|
+
await this.waitForReplPromptReady(tmux, runtime.pane, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
|
|
1681
1837
|
}
|
|
1682
1838
|
else {
|
|
1683
1839
|
sessionConfirmedAbsent = true;
|
|
@@ -1755,6 +1911,14 @@ export class AgentManager {
|
|
|
1755
1911
|
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1756
1912
|
}
|
|
1757
1913
|
catch (err) {
|
|
1914
|
+
// REPL 忙碌不是清理失败:pane 完好、绑定未动、非 dev 无分支凭据可留。落
|
|
1915
|
+
// branch-cleanup-pending(不在可恢复白名单)会把常态忙碌变成永久 hold(#558 验收 1),
|
|
1916
|
+
// 故拒绝释放而不落 hold,由调用方按「仍绑定且未 hold」判定重新排队本 pass
|
|
1917
|
+
if (opts.deferWhenBusy && err instanceof ReplNotReadyError && cfg.role !== 'dev') {
|
|
1918
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} REPL is busy; refusing release without a hold ` +
|
|
1919
|
+
`(the pass can be re-queued): ${err.message}`);
|
|
1920
|
+
return false;
|
|
1921
|
+
}
|
|
1758
1922
|
const reason = err instanceof DirtyWorkdirError
|
|
1759
1923
|
? err.message
|
|
1760
1924
|
: `checkout cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -1880,6 +2044,8 @@ export class AgentManager {
|
|
|
1880
2044
|
if (candidateTasks.length === 0)
|
|
1881
2045
|
continue;
|
|
1882
2046
|
const candidateByBranch = new Map(candidateTasks.map(task => [task.branch, task]));
|
|
2047
|
+
// 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.
|
|
2048
|
+
const genAtEntry = this.deletionGenerationOf(cfg.id);
|
|
1883
2049
|
const state = await this.agentStore.get(cfg.id);
|
|
1884
2050
|
if (!state || !canDispatchWithBinding(state))
|
|
1885
2051
|
continue;
|
|
@@ -1899,11 +2065,15 @@ export class AgentManager {
|
|
|
1899
2065
|
}
|
|
1900
2066
|
if (!refs.some(ref => candidateByBranch.has(ref.slice('refs/heads/'.length))))
|
|
1901
2067
|
continue;
|
|
2068
|
+
if (this.isDeletionInFlight(cfg.id))
|
|
2069
|
+
continue;
|
|
1902
2070
|
const token = await this.lockManager.acquire(cfg.id, owner);
|
|
1903
2071
|
if (!token)
|
|
1904
2072
|
continue;
|
|
1905
2073
|
const cleanupResults = [];
|
|
1906
2074
|
try {
|
|
2075
|
+
if (!this.deletionGateOpen(cfg.id, genAtEntry))
|
|
2076
|
+
continue;
|
|
1907
2077
|
const lockedState = await this.agentStore.get(cfg.id);
|
|
1908
2078
|
if (!lockedState || !canDispatchWithBinding(lockedState))
|
|
1909
2079
|
continue;
|
|
@@ -1915,12 +2085,8 @@ export class AgentManager {
|
|
|
1915
2085
|
continue;
|
|
1916
2086
|
const tmux = new TmuxManager(runner);
|
|
1917
2087
|
if (await tmux.hasSession(cfg.id)) {
|
|
1918
|
-
const
|
|
1919
|
-
|
|
1920
|
-
throw new Error(`tmux session claim mismatch for ${cfg.id}: ${claim ?? '<missing>'}`);
|
|
1921
|
-
}
|
|
1922
|
-
const paneId = lockedState.paneId ?? await tmux.getSinglePaneId(cfg.id);
|
|
1923
|
-
await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
|
|
2088
|
+
const pane = await this.resolveClaimedPane(tmux, cfg.id, lockedState.paneId ?? undefined);
|
|
2089
|
+
await this.waitForReplPromptReady(tmux, pane, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
|
|
1924
2090
|
}
|
|
1925
2091
|
if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
|
|
1926
2092
|
continue;
|
|
@@ -2369,10 +2535,18 @@ export class AgentManager {
|
|
|
2369
2535
|
}
|
|
2370
2536
|
if (result.redispatchReviewTaskId) {
|
|
2371
2537
|
try {
|
|
2538
|
+
const reviewTask = await this.taskStore.get(result.redispatchReviewTaskId);
|
|
2539
|
+
const resumeEntry = this.getPendingDispatchRetry(result.redispatchReviewTaskId);
|
|
2540
|
+
const resumeQaPhase = resumeEntry?.kind === 'qa-recheck'
|
|
2541
|
+
&& resumeEntry.signalToken === reviewTask?.signalToken
|
|
2542
|
+
&& resumeEntry.qaPhase !== undefined
|
|
2543
|
+
? resumeEntry.qaPhase
|
|
2544
|
+
: (reviewTask?.reviewRound === 0 ? 'review' : undefined);
|
|
2372
2545
|
await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
|
|
2373
|
-
bumpRound:
|
|
2546
|
+
bumpRound: reviewTask?.reviewRoundPending === true,
|
|
2374
2547
|
fromStatus: ['review'],
|
|
2375
2548
|
...(result.reviewPassToken !== undefined ? { expectSignalToken: result.reviewPassToken } : {}),
|
|
2549
|
+
...(resumeQaPhase !== undefined ? { qaPhase: resumeQaPhase } : {}),
|
|
2376
2550
|
});
|
|
2377
2551
|
}
|
|
2378
2552
|
catch (err) {
|
|
@@ -2467,14 +2641,12 @@ export class AgentManager {
|
|
|
2467
2641
|
...(result.reason ? { reason: result.reason } : {}),
|
|
2468
2642
|
};
|
|
2469
2643
|
}
|
|
2470
|
-
async
|
|
2471
|
-
if (state.paneId)
|
|
2472
|
-
return state.paneId;
|
|
2644
|
+
async resolveClaimedPaneOrNull(state, cfg) {
|
|
2473
2645
|
try {
|
|
2474
|
-
return await new TmuxManager(this.createRunnerFor(cfg))
|
|
2646
|
+
return await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), cfg.id, state.paneId);
|
|
2475
2647
|
}
|
|
2476
2648
|
catch (err) {
|
|
2477
|
-
console.warn(`[AgentManager]
|
|
2649
|
+
console.warn(`[AgentManager] resolveClaimedPane failed for ${cfg.id}:`, err);
|
|
2478
2650
|
return null;
|
|
2479
2651
|
}
|
|
2480
2652
|
}
|
|
@@ -2492,27 +2664,16 @@ export class AgentManager {
|
|
|
2492
2664
|
}
|
|
2493
2665
|
if (!sessionAlive)
|
|
2494
2666
|
return { kind: 'absent' };
|
|
2495
|
-
let claim;
|
|
2496
|
-
try {
|
|
2497
|
-
claim = await tmux.getOption(agentId, '@baxian-agent-id');
|
|
2498
|
-
}
|
|
2499
|
-
catch (err) {
|
|
2500
|
-
return {
|
|
2501
|
-
kind: 'hold',
|
|
2502
|
-
reason: `Runtime session claim probe failed; refusing checkout cleanup: ` +
|
|
2503
|
-
`${err instanceof Error ? err.message : String(err)}`,
|
|
2504
|
-
};
|
|
2505
|
-
}
|
|
2506
|
-
if (claim !== agentId) {
|
|
2507
|
-
return {
|
|
2508
|
-
kind: 'hold',
|
|
2509
|
-
reason: `Runtime session claim mismatch (got "${claim ?? 'null'}"); refusing checkout cleanup`,
|
|
2510
|
-
};
|
|
2511
|
-
}
|
|
2512
2667
|
try {
|
|
2513
|
-
return { kind: 'pane',
|
|
2668
|
+
return { kind: 'pane', pane: await this.resolveClaimedPane(tmux, agentId) };
|
|
2514
2669
|
}
|
|
2515
2670
|
catch (err) {
|
|
2671
|
+
if (err instanceof PaneGoneError) {
|
|
2672
|
+
return {
|
|
2673
|
+
kind: 'hold',
|
|
2674
|
+
reason: `Runtime session claim probe/mismatch; refusing checkout cleanup: ${err.message}`,
|
|
2675
|
+
};
|
|
2676
|
+
}
|
|
2516
2677
|
return {
|
|
2517
2678
|
kind: 'hold',
|
|
2518
2679
|
reason: `Runtime pane probe failed; refusing checkout cleanup: ` +
|
|
@@ -2521,9 +2682,10 @@ export class AgentManager {
|
|
|
2521
2682
|
}
|
|
2522
2683
|
}
|
|
2523
2684
|
async interruptPaneAndWaitReady(state, cfg) {
|
|
2524
|
-
const
|
|
2525
|
-
if (!
|
|
2685
|
+
const pane = await this.resolveClaimedPaneOrNull(state, cfg);
|
|
2686
|
+
if (!pane)
|
|
2526
2687
|
return false;
|
|
2688
|
+
const paneId = pane.paneId;
|
|
2527
2689
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
2528
2690
|
const runtime = agentRuntimeKindFor(cfg);
|
|
2529
2691
|
if (!(await this.acquireCompactGuardWithin(cfg.id, this.cancelInterruptGuardWaitMs))) {
|
|
@@ -2534,43 +2696,44 @@ export class AgentManager {
|
|
|
2534
2696
|
}
|
|
2535
2697
|
try {
|
|
2536
2698
|
try {
|
|
2537
|
-
await tmux.sendKeysToPane(
|
|
2699
|
+
await tmux.sendKeysToPane(pane, 'Escape');
|
|
2538
2700
|
await new Promise(r => setTimeout(r, 200));
|
|
2539
2701
|
}
|
|
2540
2702
|
catch (err) {
|
|
2541
2703
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: send Escape failed for pane ${paneId}:`, err);
|
|
2542
2704
|
return false;
|
|
2543
2705
|
}
|
|
2544
|
-
if (await this.paneReachedReplReady(tmux,
|
|
2706
|
+
if (await this.paneReachedReplReady(tmux, pane, runtime, 10_000))
|
|
2545
2707
|
return true;
|
|
2546
|
-
if (!(await this.paneRunsRuntime(tmux,
|
|
2708
|
+
if (!(await this.paneRunsRuntime(tmux, pane, runtime)))
|
|
2547
2709
|
return false;
|
|
2548
|
-
if (await this.paneHasLiveTurn(tmux,
|
|
2710
|
+
if (await this.paneHasLiveTurn(tmux, pane, runtime)) {
|
|
2549
2711
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: pane ${paneId} still running a turn after ESC; holding`);
|
|
2550
2712
|
return false;
|
|
2551
2713
|
}
|
|
2552
|
-
if (!(await this.paneRunsRuntime(tmux,
|
|
2714
|
+
if (!(await this.paneRunsRuntime(tmux, pane, runtime)))
|
|
2553
2715
|
return false;
|
|
2554
2716
|
try {
|
|
2555
|
-
await tmux.sendKeysToPane(
|
|
2717
|
+
await tmux.sendKeysToPane(pane, 'C-c');
|
|
2556
2718
|
await new Promise(r => setTimeout(r, 200));
|
|
2557
2719
|
}
|
|
2558
2720
|
catch (err) {
|
|
2559
2721
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: send C-c (composer clear) failed for pane ${paneId}:`, err);
|
|
2560
2722
|
return false;
|
|
2561
2723
|
}
|
|
2562
|
-
return this.paneReachedReplReady(tmux,
|
|
2724
|
+
return this.paneReachedReplReady(tmux, pane, runtime, this.cleanComposerWaitMs);
|
|
2563
2725
|
}
|
|
2564
2726
|
finally {
|
|
2565
2727
|
this.compactInFlight.delete(cfg.id);
|
|
2566
2728
|
}
|
|
2567
2729
|
}
|
|
2568
|
-
async paneHasLiveTurn(tmux,
|
|
2730
|
+
async paneHasLiveTurn(tmux, pane, runtime) {
|
|
2731
|
+
const paneId = pane.paneId;
|
|
2569
2732
|
let first;
|
|
2570
2733
|
let firstTitle;
|
|
2571
2734
|
try {
|
|
2572
|
-
first = await tmux.capturePaneById(
|
|
2573
|
-
firstTitle = await tmux.readPaneTitle(
|
|
2735
|
+
first = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
2736
|
+
firstTitle = await tmux.readPaneTitle(pane);
|
|
2574
2737
|
}
|
|
2575
2738
|
catch (err) {
|
|
2576
2739
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: liveness capture failed for pane ${paneId}:`, err);
|
|
@@ -2581,8 +2744,8 @@ export class AgentManager {
|
|
|
2581
2744
|
let frame;
|
|
2582
2745
|
let title;
|
|
2583
2746
|
try {
|
|
2584
|
-
frame = await tmux.capturePaneById(
|
|
2585
|
-
title = await tmux.readPaneTitle(
|
|
2747
|
+
frame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
2748
|
+
title = await tmux.readPaneTitle(pane);
|
|
2586
2749
|
}
|
|
2587
2750
|
catch (err) {
|
|
2588
2751
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: liveness re-capture failed for pane ${paneId}:`, err);
|
|
@@ -2597,10 +2760,11 @@ export class AgentManager {
|
|
|
2597
2760
|
}
|
|
2598
2761
|
return false;
|
|
2599
2762
|
}
|
|
2600
|
-
async paneRunsRuntime(tmux,
|
|
2763
|
+
async paneRunsRuntime(tmux, pane, runtime) {
|
|
2764
|
+
const paneId = pane.paneId;
|
|
2601
2765
|
let proc;
|
|
2602
2766
|
try {
|
|
2603
|
-
proc = await tmux.displayMessage(
|
|
2767
|
+
proc = await tmux.displayMessage(pane, '#{pane_current_command}');
|
|
2604
2768
|
}
|
|
2605
2769
|
catch (err) {
|
|
2606
2770
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: proc-title read failed for pane ${paneId}:`, err);
|
|
@@ -2612,9 +2776,10 @@ export class AgentManager {
|
|
|
2612
2776
|
}
|
|
2613
2777
|
return true;
|
|
2614
2778
|
}
|
|
2615
|
-
async paneReachedReplReady(tmux,
|
|
2779
|
+
async paneReachedReplReady(tmux, pane, runtime, timeoutMs) {
|
|
2780
|
+
const paneId = pane.paneId;
|
|
2616
2781
|
try {
|
|
2617
|
-
await tmux.waitReplReady(
|
|
2782
|
+
await tmux.waitReplReady(pane, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true });
|
|
2618
2783
|
return true;
|
|
2619
2784
|
}
|
|
2620
2785
|
catch (err) {
|
|
@@ -2745,20 +2910,25 @@ export class AgentManager {
|
|
|
2745
2910
|
}
|
|
2746
2911
|
}
|
|
2747
2912
|
}
|
|
2748
|
-
async pollPaneCommandStable(tmux,
|
|
2913
|
+
async pollPaneCommandStable(tmux, pane, opts) {
|
|
2749
2914
|
const deadline = Date.now() + opts.timeoutMs;
|
|
2750
2915
|
const SHELL = /^(?:zsh|bash|sh|fish)$/;
|
|
2751
2916
|
let last = '';
|
|
2752
2917
|
while (Date.now() < deadline) {
|
|
2753
2918
|
await new Promise(r => setTimeout(r, 100));
|
|
2754
|
-
const raw = await tmux.displayMessage(
|
|
2919
|
+
const raw = await tmux.displayMessage(pane, '#{pane_current_command}');
|
|
2755
2920
|
last = raw.trim();
|
|
2756
2921
|
if (opts.expectShell ? SHELL.test(last) : last !== '')
|
|
2757
2922
|
return last;
|
|
2758
2923
|
}
|
|
2759
2924
|
return last;
|
|
2760
2925
|
}
|
|
2761
|
-
async restartReplOnly(agentId) {
|
|
2926
|
+
async restartReplOnly(agentId, opts = {}) {
|
|
2927
|
+
// 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).
|
|
2928
|
+
const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
|
|
2929
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
2930
|
+
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
2931
|
+
}
|
|
2762
2932
|
const cfg = this.getAgentConfig(agentId);
|
|
2763
2933
|
if (!cfg)
|
|
2764
2934
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
@@ -2771,14 +2941,17 @@ export class AgentManager {
|
|
|
2771
2941
|
if (snapshot.claim !== agentId) {
|
|
2772
2942
|
throw new Error(`restart-repl: session claim mismatch (got "${snapshot.claim ?? 'null'}"); refusing to touch foreign session`);
|
|
2773
2943
|
}
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2944
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
2945
|
+
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
2946
|
+
}
|
|
2947
|
+
const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
|
|
2948
|
+
await tmux.sendKeysToPane(pane, 'C-c');
|
|
2949
|
+
const cmd = await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000 });
|
|
2777
2950
|
const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
|
|
2778
2951
|
const SHELL = /^(?:zsh|bash|sh|fish)$/;
|
|
2779
2952
|
if (RUNTIME.test(cmd)) {
|
|
2780
|
-
await tmux.sendKeysToPane(
|
|
2781
|
-
await this.pollPaneCommandStable(tmux,
|
|
2953
|
+
await tmux.sendKeysToPane(pane, REPL_EXIT_COMMAND[cfg.runtime], 'Enter');
|
|
2954
|
+
await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000, expectShell: true });
|
|
2782
2955
|
}
|
|
2783
2956
|
else if (!SHELL.test(cmd)) {
|
|
2784
2957
|
throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
|
|
@@ -2786,33 +2959,42 @@ export class AgentManager {
|
|
|
2786
2959
|
const project = this.getProjectConfig(cfg.projectId);
|
|
2787
2960
|
if (!project)
|
|
2788
2961
|
throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
|
|
2962
|
+
// 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.
|
|
2963
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
2964
|
+
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
2965
|
+
}
|
|
2789
2966
|
const workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
|
|
2790
|
-
const paneWorkdir = await tmux.getPaneCurrentPath(
|
|
2967
|
+
const paneWorkdir = await tmux.getPaneCurrentPath(pane);
|
|
2791
2968
|
if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
|
|
2792
2969
|
throw new Error(`restart-repl: pane Workdir ${paneWorkdir} does not match agent Workdir ${workdir}; ` +
|
|
2793
2970
|
'use retry to rebuild the session safely');
|
|
2794
2971
|
}
|
|
2972
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
2973
|
+
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
2974
|
+
}
|
|
2795
2975
|
await this.provisionRepoSkills(runner, cfg, workdir);
|
|
2796
2976
|
await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
|
|
2797
2977
|
const runtime = agentRuntimeKindFor(cfg);
|
|
2798
2978
|
const relaunch = async () => {
|
|
2799
|
-
await tmux.
|
|
2800
|
-
await tmux.
|
|
2979
|
+
await tmux.sendKeysLiteral(pane, launchCommandIn(workdir, cfg));
|
|
2980
|
+
await tmux.sendEnter(pane);
|
|
2981
|
+
await tmux.handleTrustDialog(pane, runtime, {
|
|
2801
2982
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
2802
2983
|
});
|
|
2803
|
-
await tmux.waitReplReady(
|
|
2984
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
2804
2985
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
2805
2986
|
scrollback: 0,
|
|
2806
2987
|
});
|
|
2807
2988
|
await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
|
|
2808
2989
|
};
|
|
2809
2990
|
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
2991
|
+
// Final writeback is generation-gated too, so a DELETE→recreate reusing %0 can't persist the old incarnation's paneId onto the new agent.
|
|
2810
2992
|
await this.agentStore.update(agentId, (state) => {
|
|
2811
|
-
if (!state)
|
|
2993
|
+
if (!state || !this.deletionGateOpen(agentId, genAtEntry))
|
|
2812
2994
|
return AGENT_STORE_NOOP;
|
|
2813
2995
|
return {
|
|
2814
2996
|
...state,
|
|
2815
|
-
paneId,
|
|
2997
|
+
paneId: pane.paneId,
|
|
2816
2998
|
updatedAt: new Date().toISOString(),
|
|
2817
2999
|
};
|
|
2818
3000
|
});
|
|
@@ -2891,6 +3073,9 @@ export class AgentManager {
|
|
|
2891
3073
|
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
|
|
2892
3074
|
skipSnapshot: true,
|
|
2893
3075
|
onlyReplaceOwnToken: true,
|
|
3076
|
+
// git 双 watcher 下 fence 只应比较自己的 (taskId, agentId) 条目:task 域会拿
|
|
3077
|
+
// sibling(reconciliation)的不同 token 否决本应合法的重布防
|
|
3078
|
+
...(task.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
|
|
2894
3079
|
});
|
|
2895
3080
|
if (!armed) {
|
|
2896
3081
|
if (!(await stillCurrent()))
|
|
@@ -2907,6 +3092,8 @@ export class AgentManager {
|
|
|
2907
3092
|
if (task.status === 'approved' && agent.role === 'dev') {
|
|
2908
3093
|
return await this.replayApprovedHolderAfterReplRestart(agentId, task, taskStillCurrent, fencedReplay, { clearEntryHold, holdReplayFailure });
|
|
2909
3094
|
}
|
|
3095
|
+
// reconciliation 条目不是 replay 目标:它没有对应的可重放 prompt,误选会以 QA 轮换 token
|
|
3096
|
+
// 重注 dev 的 publish 提示(spec §5.3 ④ 只允许一次 actor 归位)
|
|
2910
3097
|
const mapped = this.mapTaskStateToExpectedWatcher(task);
|
|
2911
3098
|
if (!mapped || mapped.agentId !== agentId)
|
|
2912
3099
|
return false;
|
|
@@ -3106,6 +3293,9 @@ export class AgentManager {
|
|
|
3106
3293
|
await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
|
|
3107
3294
|
throw err;
|
|
3108
3295
|
}
|
|
3296
|
+
if (resumed && task.reviewMode === 'git') {
|
|
3297
|
+
await this.confirmPostApprovePromptDelivered(taskId, completion.token);
|
|
3298
|
+
}
|
|
3109
3299
|
if (!resumed && completion.rebuilt) {
|
|
3110
3300
|
await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
|
|
3111
3301
|
const fresh = await this.taskStore.get(taskId);
|
|
@@ -3142,6 +3332,19 @@ export class AgentManager {
|
|
|
3142
3332
|
|| fresh.postApproveHeadSha !== approvedHeadSha) {
|
|
3143
3333
|
return false;
|
|
3144
3334
|
}
|
|
3335
|
+
if (fresh.reviewMode === 'git') {
|
|
3336
|
+
if (fresh.postApproveToken !== undefined)
|
|
3337
|
+
return false;
|
|
3338
|
+
// rebuild 与常规安装同一 installed→delivered 协议:写后崩溃由 sweep 按 pending 补派
|
|
3339
|
+
await this.taskStore.set({
|
|
3340
|
+
...fresh,
|
|
3341
|
+
postApproveToken: token,
|
|
3342
|
+
postApprovePhase: 'installed',
|
|
3343
|
+
pendingRedispatch: true,
|
|
3344
|
+
updatedAt: new Date().toISOString(),
|
|
3345
|
+
});
|
|
3346
|
+
return true;
|
|
3347
|
+
}
|
|
3145
3348
|
if (await this.postApproveStore.get(task.id))
|
|
3146
3349
|
return false;
|
|
3147
3350
|
await this.postApproveStore.set(task.id, { token, approvedHeadSha });
|
|
@@ -3171,21 +3374,7 @@ export class AgentManager {
|
|
|
3171
3374
|
continue;
|
|
3172
3375
|
const runner = this.createRunnerFor(cfg);
|
|
3173
3376
|
const tmux = new TmuxManager(runner);
|
|
3174
|
-
|
|
3175
|
-
if (boundState?.taskId) {
|
|
3176
|
-
try {
|
|
3177
|
-
const released = await this.releaseAgentForTask(id, boundState.taskId, 'idle', {
|
|
3178
|
-
allowAwaitingHuman: true,
|
|
3179
|
-
});
|
|
3180
|
-
if (!released) {
|
|
3181
|
-
throw new Error(`could not safely release task ${boundState.taskId}`);
|
|
3182
|
-
}
|
|
3183
|
-
}
|
|
3184
|
-
catch (err) {
|
|
3185
|
-
failures.push({ agentId: id, step: 'binding.release', error: err });
|
|
3186
|
-
continue;
|
|
3187
|
-
}
|
|
3188
|
-
}
|
|
3377
|
+
// 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.
|
|
3189
3378
|
this.stopRuntimeMenuWatch(id);
|
|
3190
3379
|
if (this.paneStreamerManager) {
|
|
3191
3380
|
try {
|
|
@@ -3197,21 +3386,45 @@ export class AgentManager {
|
|
|
3197
3386
|
}
|
|
3198
3387
|
try {
|
|
3199
3388
|
await this.runUnderSessionLifecycle(id, async () => {
|
|
3200
|
-
const
|
|
3389
|
+
const cleanupOpts = { timeout: DELETE_CLEANUP_TIMEOUT_MS };
|
|
3390
|
+
const snapshot = await tmux.getSessionSnapshot(id, cleanupOpts);
|
|
3201
3391
|
if (!snapshot)
|
|
3202
3392
|
return;
|
|
3203
|
-
if (snapshot.claim
|
|
3204
|
-
|
|
3205
|
-
|
|
3393
|
+
if (snapshot.claim === id) {
|
|
3394
|
+
const outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'equals', claim: id }, cleanupOpts);
|
|
3395
|
+
if (outcome === 'refused') {
|
|
3396
|
+
throw new Error(`tmux generation/claim changed for ${id} before kill; retry DELETE to observe new state`);
|
|
3397
|
+
}
|
|
3398
|
+
if (outcome === 'killed') {
|
|
3399
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
|
|
3400
|
+
}
|
|
3206
3401
|
return;
|
|
3207
3402
|
}
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3403
|
+
if (snapshot.claim === null) {
|
|
3404
|
+
// A nonce-owned unclaimed session is this project's own half-created leftover — reclaim it.
|
|
3405
|
+
let nonceOwned;
|
|
3406
|
+
try {
|
|
3407
|
+
nonceOwned = await tmux.hasCreationNonce(id, cleanupOpts);
|
|
3408
|
+
}
|
|
3409
|
+
catch (err) {
|
|
3410
|
+
throw new Error(`creation-nonce probe failed for ${id}; retry DELETE: ${err instanceof Error ? err.message : String(err)}`);
|
|
3411
|
+
}
|
|
3412
|
+
if (!nonceOwned) {
|
|
3413
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping ${id} ` +
|
|
3414
|
+
`(unclaimed, no baxian creation nonce; foreign session)`);
|
|
3415
|
+
return;
|
|
3416
|
+
}
|
|
3417
|
+
const outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' }, cleanupOpts);
|
|
3418
|
+
if (outcome === 'refused') {
|
|
3419
|
+
throw new Error(`half-created session for ${id} was claimed or its server changed before kill; retry DELETE`);
|
|
3420
|
+
}
|
|
3421
|
+
if (outcome === 'killed') {
|
|
3422
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed half-created leftover ${snapshot.ref.sessionId} for removed agent ${id}`);
|
|
3423
|
+
}
|
|
3424
|
+
return;
|
|
3214
3425
|
}
|
|
3426
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
|
|
3427
|
+
`(claim=${snapshot.claim}; not baxian-managed)`);
|
|
3215
3428
|
});
|
|
3216
3429
|
}
|
|
3217
3430
|
catch (err) {
|
|
@@ -3292,6 +3505,17 @@ export class AgentManager {
|
|
|
3292
3505
|
getProjectByRepo(repo) {
|
|
3293
3506
|
return this.config.project.find(p => repoSlug(p.repo) === repo);
|
|
3294
3507
|
}
|
|
3508
|
+
getProjectByRepoIdentity(repoKey) {
|
|
3509
|
+
return this.config.project.find(p => repoIdentityKey(p.repo) === repoKey);
|
|
3510
|
+
}
|
|
3511
|
+
platformDriverFor(projectId) {
|
|
3512
|
+
if (!this.pluginRegistry)
|
|
3513
|
+
return undefined;
|
|
3514
|
+
const project = this.getProjectConfig(projectId);
|
|
3515
|
+
if (!project)
|
|
3516
|
+
return undefined;
|
|
3517
|
+
return buildProjectDriver(project, this.pluginRegistry, this.platformDriverExec);
|
|
3518
|
+
}
|
|
3295
3519
|
findAgentGroup(anchorAgentId) {
|
|
3296
3520
|
for (const project of this.config.project) {
|
|
3297
3521
|
for (const group of project.agent) {
|
|
@@ -3327,8 +3551,28 @@ export class AgentManager {
|
|
|
3327
3551
|
return this.agentStore.get(agentId);
|
|
3328
3552
|
}
|
|
3329
3553
|
async getPostApproveCompletion(taskId) {
|
|
3554
|
+
const task = await this.taskStore.get(taskId);
|
|
3555
|
+
if (task?.reviewMode === 'git')
|
|
3556
|
+
return this.gitCompletionOf(task);
|
|
3330
3557
|
return this.postApproveStore.get(taskId);
|
|
3331
3558
|
}
|
|
3559
|
+
// 'git' 的 completion 与效果字段同居 TaskState:consumedFeedback/pendingRedispatch 与
|
|
3560
|
+
// token CAS 必须同一次持久化(spec §6 双写协议);legacy github 沿 PostApproveStore 至 M3c。
|
|
3561
|
+
gitCompletionOf(task) {
|
|
3562
|
+
if (task.postApproveToken === undefined || task.postApproveHeadSha === undefined)
|
|
3563
|
+
return null;
|
|
3564
|
+
return {
|
|
3565
|
+
token: task.postApproveToken,
|
|
3566
|
+
approvedHeadSha: task.postApproveHeadSha,
|
|
3567
|
+
updatedAt: task.updatedAt,
|
|
3568
|
+
...(task.redispatchCount !== undefined ? { redispatchCount: task.redispatchCount } : {}),
|
|
3569
|
+
...(task.pendingRedispatch !== undefined ? { pendingRedispatch: task.pendingRedispatch } : {}),
|
|
3570
|
+
};
|
|
3571
|
+
}
|
|
3572
|
+
stripGitCompletionFields(task) {
|
|
3573
|
+
const { postApproveToken: _t, pendingRedispatch: _p, redispatchCount: _r, postApprovePhase: _ph, ...rest } = task;
|
|
3574
|
+
return rest;
|
|
3575
|
+
}
|
|
3332
3576
|
// Install CASes on the current approved episode; only a fresh human verdict (clearRevocation) unlocks a standing marker.
|
|
3333
3577
|
async setPostApproveCompletion(taskId, value, opts = {}) {
|
|
3334
3578
|
return this.withTaskLock(async () => {
|
|
@@ -3341,37 +3585,111 @@ export class AgentManager {
|
|
|
3341
3585
|
if (task.postApproveHeadSha !== undefined && task.postApproveHeadSha !== value.approvedHeadSha)
|
|
3342
3586
|
return false;
|
|
3343
3587
|
}
|
|
3344
|
-
|
|
3588
|
+
if (task?.reviewMode === 'git') {
|
|
3589
|
+
const { postApproveRevoked: _revoked, ...rest } = task;
|
|
3590
|
+
// 全量替换语义与 PostApproveStore.set 一致:未携带的 pending/redispatch 字段清空
|
|
3591
|
+
await this.taskStore.set({
|
|
3592
|
+
...this.stripGitCompletionFields(opts.clearRevocation ? rest : task),
|
|
3593
|
+
postApproveToken: value.token,
|
|
3594
|
+
postApprovePhase: 'installed',
|
|
3595
|
+
...(value.redispatchCount !== undefined ? { redispatchCount: value.redispatchCount } : {}),
|
|
3596
|
+
...(value.pendingRedispatch !== undefined ? { pendingRedispatch: value.pendingRedispatch } : {}),
|
|
3597
|
+
updatedAt: new Date().toISOString(),
|
|
3598
|
+
});
|
|
3599
|
+
}
|
|
3600
|
+
else {
|
|
3601
|
+
await this.postApproveStore.set(taskId, value);
|
|
3602
|
+
}
|
|
3345
3603
|
if (!task)
|
|
3346
3604
|
return true;
|
|
3347
|
-
if (task.postApproveRevoked) {
|
|
3605
|
+
if (task.postApproveRevoked && task.reviewMode !== 'git') {
|
|
3348
3606
|
const { postApproveRevoked: _revoked, ...rest } = task;
|
|
3349
3607
|
await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
|
|
3350
3608
|
}
|
|
3351
3609
|
if (!this.phaseSignalWatcher)
|
|
3352
3610
|
return true;
|
|
3353
|
-
await this.startPhaseSignalWatch({
|
|
3611
|
+
const armed = await this.startPhaseSignalWatch({
|
|
3354
3612
|
taskId,
|
|
3355
3613
|
projectId: task.projectId,
|
|
3356
3614
|
agentId: task.agentId,
|
|
3357
3615
|
expectedKinds: 'pr-merge-ready',
|
|
3358
3616
|
token: value.token,
|
|
3359
3617
|
});
|
|
3618
|
+
if (!armed) {
|
|
3619
|
+
// completion 没有 consumer 就不算安装成功:撤销本次写,durable pending(若有)随原状保留
|
|
3620
|
+
if (task.reviewMode === 'git') {
|
|
3621
|
+
await this.taskStore.set({ ...task, updatedAt: new Date().toISOString() });
|
|
3622
|
+
}
|
|
3623
|
+
else {
|
|
3624
|
+
await this.postApproveStore.clear(taskId);
|
|
3625
|
+
}
|
|
3626
|
+
return false;
|
|
3627
|
+
}
|
|
3360
3628
|
return true;
|
|
3361
3629
|
});
|
|
3362
3630
|
}
|
|
3631
|
+
// post-approve prompt 的至少一次投递(spec §6):pendingRedispatch 随 completion 安装保持 true,
|
|
3632
|
+
// 仅在 continueSession 确认送达后清除——安装与投递之间的崩溃由恢复扫描按 pending 补派。
|
|
3633
|
+
async confirmPostApprovePromptDelivered(taskId, token) {
|
|
3634
|
+
await this.withTaskLock(async () => {
|
|
3635
|
+
const task = await this.taskStore.get(taskId);
|
|
3636
|
+
if (!task || task.reviewMode !== 'git' || task.postApproveToken !== token)
|
|
3637
|
+
return;
|
|
3638
|
+
await this.taskStore.set({
|
|
3639
|
+
...task,
|
|
3640
|
+
pendingRedispatch: false,
|
|
3641
|
+
postApprovePhase: 'delivered',
|
|
3642
|
+
updatedAt: new Date().toISOString(),
|
|
3643
|
+
});
|
|
3644
|
+
});
|
|
3645
|
+
}
|
|
3646
|
+
async markPostApproveSignalReceived(taskId, token) {
|
|
3647
|
+
await this.withTaskLock(async () => {
|
|
3648
|
+
const task = await this.taskStore.get(taskId);
|
|
3649
|
+
if (!task || task.reviewMode !== 'git' || task.postApproveToken !== token)
|
|
3650
|
+
return;
|
|
3651
|
+
if (task.postApprovePhase === 'signaled')
|
|
3652
|
+
return;
|
|
3653
|
+
await this.taskStore.set({ ...task, postApprovePhase: 'signaled', updatedAt: new Date().toISOString() });
|
|
3654
|
+
});
|
|
3655
|
+
}
|
|
3363
3656
|
async clearPostApproveCompletion(taskId) {
|
|
3364
3657
|
await this.withTaskLock(async () => {
|
|
3365
|
-
await this.
|
|
3658
|
+
const task = await this.taskStore.get(taskId);
|
|
3659
|
+
if (task?.reviewMode === 'git') {
|
|
3660
|
+
if (task.postApproveToken !== undefined || task.pendingRedispatch !== undefined
|
|
3661
|
+
|| task.redispatchCount !== undefined) {
|
|
3662
|
+
await this.taskStore.set({
|
|
3663
|
+
...this.stripGitCompletionFields(task),
|
|
3664
|
+
updatedAt: new Date().toISOString(),
|
|
3665
|
+
});
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
else {
|
|
3669
|
+
await this.postApproveStore.clear(taskId);
|
|
3670
|
+
}
|
|
3366
3671
|
this.phaseSignalWatcher?.stop(taskId);
|
|
3367
3672
|
});
|
|
3368
3673
|
}
|
|
3369
3674
|
// CAS for an existing pass: never creates a record nor touches the marker, so it cannot resurrect a revoked completion.
|
|
3370
|
-
async updatePostApproveCompletionIfToken(taskId, expectedToken, patch) {
|
|
3675
|
+
async updatePostApproveCompletionIfToken(taskId, expectedToken, patch, opts = {}) {
|
|
3371
3676
|
return this.withTaskLock(async () => {
|
|
3372
3677
|
const task = await this.taskStore.get(taskId);
|
|
3373
3678
|
if (task?.postApproveRevoked)
|
|
3374
3679
|
return false;
|
|
3680
|
+
if (task?.reviewMode === 'git') {
|
|
3681
|
+
if (task.postApproveToken !== expectedToken)
|
|
3682
|
+
return false;
|
|
3683
|
+
await this.taskStore.set({
|
|
3684
|
+
...task,
|
|
3685
|
+
...patch,
|
|
3686
|
+
...(opts.consumeRevision
|
|
3687
|
+
? { consumedFeedback: { ...task.consumedFeedback, [opts.consumeRevision.key]: opts.consumeRevision.versionTime } }
|
|
3688
|
+
: {}),
|
|
3689
|
+
updatedAt: new Date().toISOString(),
|
|
3690
|
+
});
|
|
3691
|
+
return true;
|
|
3692
|
+
}
|
|
3375
3693
|
const current = await this.postApproveStore.get(taskId);
|
|
3376
3694
|
if (!current || current.token !== expectedToken)
|
|
3377
3695
|
return false;
|
|
@@ -3391,9 +3709,24 @@ export class AgentManager {
|
|
|
3391
3709
|
&& task.postApproveHeadSha !== opts.expectedHeadSha)
|
|
3392
3710
|
return false;
|
|
3393
3711
|
if (opts.expectedToken !== undefined) {
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3712
|
+
if (task.reviewMode === 'git') {
|
|
3713
|
+
if (task.postApproveToken !== opts.expectedToken)
|
|
3714
|
+
return false;
|
|
3715
|
+
}
|
|
3716
|
+
else {
|
|
3717
|
+
const current = await this.postApproveStore.get(taskId);
|
|
3718
|
+
if (current?.token !== opts.expectedToken)
|
|
3719
|
+
return false;
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
if (task.reviewMode === 'git') {
|
|
3723
|
+
await this.taskStore.set({
|
|
3724
|
+
...this.stripGitCompletionFields(task),
|
|
3725
|
+
postApproveRevoked: { reason, at: new Date().toISOString() },
|
|
3726
|
+
updatedAt: new Date().toISOString(),
|
|
3727
|
+
});
|
|
3728
|
+
this.phaseSignalWatcher?.stop(taskId);
|
|
3729
|
+
return true;
|
|
3397
3730
|
}
|
|
3398
3731
|
// Marker lands first: a crash before the clear leaves marker+completion, which every consumer fences on.
|
|
3399
3732
|
await this.taskStore.set({
|
|
@@ -3408,6 +3741,17 @@ export class AgentManager {
|
|
|
3408
3741
|
}
|
|
3409
3742
|
async clearPostApproveCompletionIfMatches(taskId, token) {
|
|
3410
3743
|
return this.withTaskLock(async () => {
|
|
3744
|
+
const task = await this.taskStore.get(taskId);
|
|
3745
|
+
if (task?.reviewMode === 'git') {
|
|
3746
|
+
if (task.postApproveToken !== token)
|
|
3747
|
+
return false;
|
|
3748
|
+
await this.taskStore.set({
|
|
3749
|
+
...this.stripGitCompletionFields(task),
|
|
3750
|
+
updatedAt: new Date().toISOString(),
|
|
3751
|
+
});
|
|
3752
|
+
this.phaseSignalWatcher?.stop(taskId);
|
|
3753
|
+
return true;
|
|
3754
|
+
}
|
|
3411
3755
|
const cleared = await this.postApproveStore.clearIfMatches(taskId, token);
|
|
3412
3756
|
if (cleared)
|
|
3413
3757
|
this.phaseSignalWatcher?.stop(taskId);
|
|
@@ -3421,7 +3765,9 @@ export class AgentManager {
|
|
|
3421
3765
|
if (!task || task.status !== 'approved' || task.postApproveRevoked) {
|
|
3422
3766
|
return { refused: 'stale' };
|
|
3423
3767
|
}
|
|
3424
|
-
const completion =
|
|
3768
|
+
const completion = task.reviewMode === 'git'
|
|
3769
|
+
? this.gitCompletionOf(task)
|
|
3770
|
+
: await this.postApproveStore.get(taskId);
|
|
3425
3771
|
if (!completion
|
|
3426
3772
|
|| completion.token !== expectedToken
|
|
3427
3773
|
|| task.postApproveHeadSha === undefined
|
|
@@ -3430,16 +3776,339 @@ export class AgentManager {
|
|
|
3430
3776
|
}
|
|
3431
3777
|
if (completion.pendingRedispatch)
|
|
3432
3778
|
return { refused: 'pending' };
|
|
3433
|
-
|
|
3434
|
-
|
|
3779
|
+
const next = task.reviewMode === 'git' ? this.stripGitCompletionFields(task) : task;
|
|
3780
|
+
delete next.postApproveHeadSha;
|
|
3781
|
+
Object.assign(next, {
|
|
3435
3782
|
status: 'merge-ready',
|
|
3436
3783
|
latestHeadSha: completion.approvedHeadSha,
|
|
3437
3784
|
updatedAt: new Date().toISOString(),
|
|
3438
3785
|
});
|
|
3439
|
-
await this.taskStore.set(
|
|
3440
|
-
|
|
3786
|
+
await this.taskStore.set(next);
|
|
3787
|
+
if (task.reviewMode !== 'git')
|
|
3788
|
+
await this.postApproveStore.clear(taskId);
|
|
3441
3789
|
this.phaseSignalWatcher?.stop(taskId);
|
|
3442
|
-
return { task };
|
|
3790
|
+
return { task: next };
|
|
3791
|
+
});
|
|
3792
|
+
}
|
|
3793
|
+
// 修剪顺序单向(spec §6):仅在对应源 cursor durable 落盘后调用;水位停滞的源不修剪,
|
|
3794
|
+
// 有界性由水位前进保证,禁止按容量淘汰未覆盖键。
|
|
3795
|
+
async pruneConsumedFeedback(taskId, sourceKey, watermarkTime) {
|
|
3796
|
+
await this.withTaskLock(async () => {
|
|
3797
|
+
const task = await this.taskStore.get(taskId);
|
|
3798
|
+
if (!task?.consumedFeedback)
|
|
3799
|
+
return;
|
|
3800
|
+
const prefix = `${sourceKey}:`;
|
|
3801
|
+
const kept = Object.entries(task.consumedFeedback)
|
|
3802
|
+
.filter(([key, time]) => !key.startsWith(prefix) || time >= watermarkTime);
|
|
3803
|
+
if (kept.length === Object.keys(task.consumedFeedback).length)
|
|
3804
|
+
return;
|
|
3805
|
+
await this.taskStore.set({
|
|
3806
|
+
...task,
|
|
3807
|
+
consumedFeedback: Object.fromEntries(kept),
|
|
3808
|
+
updatedAt: new Date().toISOString(),
|
|
3809
|
+
});
|
|
3810
|
+
});
|
|
3811
|
+
}
|
|
3812
|
+
// TaskState 效果+outbox 先 durable、投递成功再清(spec §6 双写协议):崩溃于任一侧,
|
|
3813
|
+
// 重启从未清 outbox 重投,事件携确定性 key 供展示端去重——效果恰一次、通知至少一次。
|
|
3814
|
+
async recordClosedUnmergedAnchor(taskId, prNumber) {
|
|
3815
|
+
return this.withTaskLock(async () => {
|
|
3816
|
+
const task = await this.taskStore.get(taskId);
|
|
3817
|
+
if (!task || task.reviewMode !== 'git' || TERMINAL_STATUSES.includes(task.status))
|
|
3818
|
+
return false;
|
|
3819
|
+
const anchor = task.closedUnmergedAnchor;
|
|
3820
|
+
if (anchor?.prNumber === prNumber && anchor.cleared !== true)
|
|
3821
|
+
return false;
|
|
3822
|
+
const generation = (anchor?.generation ?? 0) + 1;
|
|
3823
|
+
const eventKey = `${taskId}:${prNumber}:mr-closed-unmerged:${generation}`;
|
|
3824
|
+
await this.taskStore.set({
|
|
3825
|
+
...task,
|
|
3826
|
+
closedUnmergedAnchor: { prNumber, generation },
|
|
3827
|
+
outbox: [...(task.outbox ?? []), {
|
|
3828
|
+
key: eventKey,
|
|
3829
|
+
type: 'human.intervention',
|
|
3830
|
+
data: { phase: 'mr-closed-unmerged', prNumber, eventKey },
|
|
3831
|
+
}],
|
|
3832
|
+
updatedAt: new Date().toISOString(),
|
|
3833
|
+
});
|
|
3834
|
+
return true;
|
|
3835
|
+
});
|
|
3836
|
+
}
|
|
3837
|
+
async clearClosedUnmergedAnchor(taskId, prNumber) {
|
|
3838
|
+
return this.withTaskLock(async () => {
|
|
3839
|
+
const task = await this.taskStore.get(taskId);
|
|
3840
|
+
const anchor = task?.closedUnmergedAnchor;
|
|
3841
|
+
if (!task || anchor?.prNumber !== prNumber || anchor.cleared === true)
|
|
3842
|
+
return false;
|
|
3843
|
+
const generation = anchor.generation;
|
|
3844
|
+
const eventKey = `${taskId}:${prNumber}:mr-reopened:${generation}`;
|
|
3845
|
+
await this.taskStore.set({
|
|
3846
|
+
...task,
|
|
3847
|
+
closedUnmergedAnchor: { ...anchor, cleared: true },
|
|
3848
|
+
outbox: [...(task.outbox ?? []), {
|
|
3849
|
+
key: eventKey,
|
|
3850
|
+
type: 'human.intervention',
|
|
3851
|
+
data: { phase: 'mr-reopened', prNumber, eventKey },
|
|
3852
|
+
}],
|
|
3853
|
+
updatedAt: new Date().toISOString(),
|
|
3854
|
+
});
|
|
3855
|
+
return true;
|
|
3856
|
+
});
|
|
3857
|
+
}
|
|
3858
|
+
async deliverTaskOutbox(taskId) {
|
|
3859
|
+
const task = await this.taskStore.get(taskId);
|
|
3860
|
+
if (!task?.outbox?.length)
|
|
3861
|
+
return;
|
|
3862
|
+
for (const entry of task.outbox) {
|
|
3863
|
+
try {
|
|
3864
|
+
await this.eventBus.emit({
|
|
3865
|
+
// 确定性 id(spec §6 outbox):重投/并发 flush 的重复条目同 id,消费/展示端可去重
|
|
3866
|
+
id: `outbox:${entry.key}`,
|
|
3867
|
+
type: entry.type,
|
|
3868
|
+
timestamp: new Date().toISOString(),
|
|
3869
|
+
projectId: task.projectId,
|
|
3870
|
+
agentId: task.agentId,
|
|
3871
|
+
taskId: task.id,
|
|
3872
|
+
data: entry.data,
|
|
3873
|
+
});
|
|
3874
|
+
}
|
|
3875
|
+
catch (err) {
|
|
3876
|
+
console.warn(`[AgentManager] outbox delivery failed for task=${taskId} key=${entry.key}:`, err);
|
|
3877
|
+
continue;
|
|
3878
|
+
}
|
|
3879
|
+
await this.withTaskLock(async () => {
|
|
3880
|
+
const fresh = await this.taskStore.get(taskId);
|
|
3881
|
+
if (!fresh?.outbox?.some(e => e.key === entry.key))
|
|
3882
|
+
return;
|
|
3883
|
+
const remaining = fresh.outbox.filter(e => e.key !== entry.key);
|
|
3884
|
+
const { outbox: _outbox, ...rest } = fresh;
|
|
3885
|
+
await this.taskStore.set({
|
|
3886
|
+
...rest,
|
|
3887
|
+
...(remaining.length > 0 ? { outbox: remaining } : {}),
|
|
3888
|
+
updatedAt: new Date().toISOString(),
|
|
3889
|
+
});
|
|
3890
|
+
});
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
// merge-ready 信号是单发消费:git 分支在消费点引入网络复核,失败时必须重 arm(skipSnapshot
|
|
3894
|
+
// false 让仍在面板上的信号立即重放),否则一次瞬态失败吞掉整轮完成信号。
|
|
3895
|
+
async rearmPostApproveSignal(taskId, opts = {}) {
|
|
3896
|
+
const task = await this.taskStore.get(taskId);
|
|
3897
|
+
if (!task || task.status !== 'approved')
|
|
3898
|
+
return false;
|
|
3899
|
+
const completion = task.reviewMode === 'git'
|
|
3900
|
+
? this.gitCompletionOf(task)
|
|
3901
|
+
: await this.postApproveStore.get(taskId);
|
|
3902
|
+
if (!completion)
|
|
3903
|
+
return false;
|
|
3904
|
+
if (!this.phaseSignalWatcher)
|
|
3905
|
+
return true;
|
|
3906
|
+
return this.startPhaseSignalWatch({
|
|
3907
|
+
taskId,
|
|
3908
|
+
projectId: task.projectId,
|
|
3909
|
+
agentId: task.agentId,
|
|
3910
|
+
expectedKinds: 'pr-merge-ready',
|
|
3911
|
+
token: completion.token,
|
|
3912
|
+
// 刚消费的信号仍在滚动区:扫描失败路径必须 live-only,否则与持续失败叠加成无退避热循环
|
|
3913
|
+
...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
|
|
3914
|
+
});
|
|
3915
|
+
}
|
|
3916
|
+
// 配置身份切换与任务创建共栅栏(TOCTOU):blocker 扫描与提交在 withTaskLock 内执行,
|
|
3917
|
+
// createTask 的「读配置快照+落任务」同经该锁,扫描时不可见的并发新任务不存在。
|
|
3918
|
+
async guardGitConfigCommit(current, next, scan, commit) {
|
|
3919
|
+
return this.withTaskLock(async () => {
|
|
3920
|
+
const blockers = await scan(this, current, next);
|
|
3921
|
+
if (blockers.length > 0)
|
|
3922
|
+
return { ok: false, blockers };
|
|
3923
|
+
await commit();
|
|
3924
|
+
return { ok: true };
|
|
3925
|
+
});
|
|
3926
|
+
}
|
|
3927
|
+
async flushGitOutbox() {
|
|
3928
|
+
const tasks = await this.taskStore.list();
|
|
3929
|
+
for (const task of tasks) {
|
|
3930
|
+
if (task.reviewMode !== 'git' || !task.outbox?.length)
|
|
3931
|
+
continue;
|
|
3932
|
+
try {
|
|
3933
|
+
await this.deliverTaskOutbox(task.id);
|
|
3934
|
+
}
|
|
3935
|
+
catch (err) {
|
|
3936
|
+
// 单任务失败不得饿死其后的 durable outbox(至少一次的重启保证)
|
|
3937
|
+
console.warn(`[AgentManager] flushGitOutbox: task=${task.id} failed, continuing:`, err);
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
// cancel 清理的部分成功可重试(spec §6 close 条 + 破坏性清理不静默失败):close 已成而
|
|
3942
|
+
// deleteBranch 瞬态失败时任务已终态,重启在守卫成立的前提下幂等补删(REF_NOT_FOUND 即成功)。
|
|
3943
|
+
// 只清本代 pending:A 的 start 晚于 B 的失败返回时,无条件清除会吞掉 B 留下的重试锚
|
|
3944
|
+
async clearReviewDispatchPending(taskId, expectedToken) {
|
|
3945
|
+
await this.withTaskLock(async () => {
|
|
3946
|
+
const task = await this.taskStore.get(taskId);
|
|
3947
|
+
if (!task || task.reviewDispatchPending !== true)
|
|
3948
|
+
return;
|
|
3949
|
+
if (task.status !== 'review' || task.signalToken !== expectedToken)
|
|
3950
|
+
return;
|
|
3951
|
+
const { reviewDispatchPending: _p, ...rest } = task;
|
|
3952
|
+
await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
|
|
3953
|
+
});
|
|
3954
|
+
}
|
|
3955
|
+
// 评审派发的至少一次(sweep 消费):transition 落 pending、QA session 启动成功才清——
|
|
3956
|
+
// QA 缺失/忙/arm/start 失败的每条早退路径都由此在 60s 内自愈,无需依赖事件重放。
|
|
3957
|
+
async retryPendingGitReviewDispatches() {
|
|
3958
|
+
const tasks = await this.listActiveGitTasks();
|
|
3959
|
+
for (const task of tasks) {
|
|
3960
|
+
if (task.reviewDispatchPending !== true)
|
|
3961
|
+
continue;
|
|
3962
|
+
// arm/start 失败回滚会退到 in_progress/fixing 且无第二触发源(poller 已记 pushSha、
|
|
3963
|
+
// watcher 已消费):pending 在场即重新拉入 review
|
|
3964
|
+
if (!['review', 'in_progress', 'fixing'].includes(task.status))
|
|
3965
|
+
continue;
|
|
3966
|
+
try {
|
|
3967
|
+
// 列表是旧快照:消费谓词按实时值复核(pending 仍在 + 状态允许),并把代际
|
|
3968
|
+
// CAS(expectSignalToken + expectReviewDispatchPending)压进 dispatch 锁内
|
|
3969
|
+
const live = await this.taskStore.get(task.id);
|
|
3970
|
+
if (!live || live.reviewDispatchPending !== true)
|
|
3971
|
+
continue;
|
|
3972
|
+
if (!['review', 'in_progress', 'fixing'].includes(live.status))
|
|
3973
|
+
continue;
|
|
3974
|
+
const entry = this.getPendingDispatchRetry(live.id);
|
|
3975
|
+
if (entry?.kind === 'qa-recheck' && entry.signalToken === live.signalToken) {
|
|
3976
|
+
// 同代 busy pending 在场:QA 已知忙碌,重派只会空转 release/acquire 并重置预算;
|
|
3977
|
+
// 交给对账的观测门在空闲后消费
|
|
3978
|
+
continue;
|
|
3979
|
+
}
|
|
3980
|
+
const qaPhase = live.reviewRound === 0 ? 'review' : undefined;
|
|
3981
|
+
await this.dispatchReviewToQa(live.id, {
|
|
3982
|
+
bumpRound: live.reviewRoundPending === true,
|
|
3983
|
+
...(qaPhase !== undefined ? { qaPhase } : {}),
|
|
3984
|
+
...(live.signalToken !== undefined ? { expectSignalToken: live.signalToken } : {}),
|
|
3985
|
+
expectReviewDispatchPending: true,
|
|
3986
|
+
fromStatus: ['review', 'in_progress', 'fixing'],
|
|
3987
|
+
});
|
|
3988
|
+
}
|
|
3989
|
+
catch (err) {
|
|
3990
|
+
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} still failing:`, err);
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
async retryGitRemoteBranchCleanup() {
|
|
3995
|
+
const tasks = await this.taskStore.list({ status: 'cancelled' });
|
|
3996
|
+
for (const task of tasks) {
|
|
3997
|
+
const reason = task.branchCleanupPending?.reason;
|
|
3998
|
+
if (task.reviewMode !== 'git'
|
|
3999
|
+
|| (reason !== 'remote-delete-failed' && reason !== 'remote-close-failed'))
|
|
4000
|
+
continue;
|
|
4001
|
+
try {
|
|
4002
|
+
const driver = this.platformDriverFor(task.projectId);
|
|
4003
|
+
const branch = task.branch;
|
|
4004
|
+
if (!driver || branch === undefined)
|
|
4005
|
+
continue;
|
|
4006
|
+
// close 先补(幂等:closed PR 再 PATCH state=closed 为 no-op),再按守卫补删分支
|
|
4007
|
+
if (reason === 'remote-close-failed' && task.prNumber !== undefined) {
|
|
4008
|
+
await driver.runOp('close', { prNumber: task.prNumber });
|
|
4009
|
+
}
|
|
4010
|
+
if (!isAutoDeletableTaskBranch({
|
|
4011
|
+
taskId: task.id,
|
|
4012
|
+
taskBranch: branch,
|
|
4013
|
+
branchCreatedByBaxian: task.branchCreatedByBaxian,
|
|
4014
|
+
actualRef: `refs/heads/${branch}`,
|
|
4015
|
+
})) {
|
|
4016
|
+
if (reason !== 'remote-close-failed')
|
|
4017
|
+
continue;
|
|
4018
|
+
}
|
|
4019
|
+
else {
|
|
4020
|
+
await driver.runOp('deleteBranch', { branch });
|
|
4021
|
+
}
|
|
4022
|
+
await this.withTaskLock(async () => {
|
|
4023
|
+
const fresh = await this.taskStore.get(task.id);
|
|
4024
|
+
const freshReason = fresh?.branchCleanupPending?.reason;
|
|
4025
|
+
if (freshReason !== 'remote-delete-failed' && freshReason !== 'remote-close-failed')
|
|
4026
|
+
return;
|
|
4027
|
+
const { branchCleanupPending: _pending, ...rest } = fresh;
|
|
4028
|
+
await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
|
|
4029
|
+
});
|
|
4030
|
+
}
|
|
4031
|
+
catch (err) {
|
|
4032
|
+
console.warn(`[AgentManager] retryGitRemoteBranchCleanup: task=${task.id} still failing:`, err);
|
|
4033
|
+
}
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
// 反馈消费的唯一状态机(spec §6 双写协议 + merge 条):锁内按实时状态决定效果,效果字段
|
|
4037
|
+
// 与消费键同一次持久化——入口快照与提交之间的并发状态迁移不再能让 revision 落空。
|
|
4038
|
+
async consumeGitFeedbackRevision(taskId, consume, opts = {}) {
|
|
4039
|
+
return this.withTaskLock(async () => {
|
|
4040
|
+
const task = await this.taskStore.get(taskId);
|
|
4041
|
+
if (!task || task.reviewMode !== 'git')
|
|
4042
|
+
return { kind: 'gone' };
|
|
4043
|
+
if (task.consumedFeedback?.[consume.key] !== undefined)
|
|
4044
|
+
return { kind: 'duplicate' };
|
|
4045
|
+
const consumed = { ...task.consumedFeedback, [consume.key]: consume.versionTime };
|
|
4046
|
+
const stamp = { updatedAt: new Date().toISOString() };
|
|
4047
|
+
if (TERMINAL_STATUSES.includes(task.status)) {
|
|
4048
|
+
await this.taskStore.set({ ...task, consumedFeedback: consumed, ...stamp });
|
|
4049
|
+
return { kind: 'consumed' };
|
|
4050
|
+
}
|
|
4051
|
+
if (task.status === 'merge-ready') {
|
|
4052
|
+
const approvedHead = typeof task.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha)
|
|
4053
|
+
? task.latestHeadSha
|
|
4054
|
+
: undefined;
|
|
4055
|
+
if (approvedHead !== undefined) {
|
|
4056
|
+
const next = {
|
|
4057
|
+
...task,
|
|
4058
|
+
status: 'approved',
|
|
4059
|
+
postApproveHeadSha: approvedHead,
|
|
4060
|
+
pendingRedispatch: true,
|
|
4061
|
+
consumedFeedback: consumed,
|
|
4062
|
+
...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
|
|
4063
|
+
...stamp,
|
|
4064
|
+
};
|
|
4065
|
+
await this.taskStore.set(next);
|
|
4066
|
+
return { kind: 'returned', task: next };
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
if (task.status === 'approved' && task.postApproveRevoked === undefined) {
|
|
4070
|
+
const next = {
|
|
4071
|
+
...task,
|
|
4072
|
+
pendingRedispatch: true,
|
|
4073
|
+
consumedFeedback: consumed,
|
|
4074
|
+
...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
|
|
4075
|
+
...stamp,
|
|
4076
|
+
};
|
|
4077
|
+
await this.taskStore.set(next);
|
|
4078
|
+
return { kind: 'pending', task: next };
|
|
4079
|
+
}
|
|
4080
|
+
await this.taskStore.set({
|
|
4081
|
+
...task,
|
|
4082
|
+
consumedFeedback: consumed,
|
|
4083
|
+
...(opts.feedbackAt !== undefined ? { prFeedbackReceivedAt: opts.feedbackAt } : {}),
|
|
4084
|
+
...stamp,
|
|
4085
|
+
});
|
|
4086
|
+
return { kind: 'consumed' };
|
|
4087
|
+
});
|
|
4088
|
+
}
|
|
4089
|
+
// merge-ready 不是评论面的冻结点(spec §6 merge 条):新反馈单写退回 approved + 消费键,
|
|
4090
|
+
// 与 push 使 merge-ready 失效的既有语义对称。
|
|
4091
|
+
async returnMergeReadyToApproved(taskId, consume) {
|
|
4092
|
+
return this.withTaskLock(async () => {
|
|
4093
|
+
const task = await this.taskStore.get(taskId);
|
|
4094
|
+
if (!task || task.reviewMode !== 'git' || task.status !== 'merge-ready')
|
|
4095
|
+
return null;
|
|
4096
|
+
const approvedHead = typeof task.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(task.latestHeadSha)
|
|
4097
|
+
? task.latestHeadSha
|
|
4098
|
+
: undefined;
|
|
4099
|
+
if (!approvedHead)
|
|
4100
|
+
return null;
|
|
4101
|
+
// pendingRedispatch 与状态翻转/消费键同写:崩溃于派发前,恢复扫描凭它补派(spec §6 至少一次)
|
|
4102
|
+
const next = {
|
|
4103
|
+
...task,
|
|
4104
|
+
status: 'approved',
|
|
4105
|
+
postApproveHeadSha: approvedHead,
|
|
4106
|
+
pendingRedispatch: true,
|
|
4107
|
+
consumedFeedback: { ...task.consumedFeedback, [consume.key]: consume.versionTime },
|
|
4108
|
+
updatedAt: new Date().toISOString(),
|
|
4109
|
+
};
|
|
4110
|
+
await this.taskStore.set(next);
|
|
4111
|
+
return next;
|
|
3443
4112
|
});
|
|
3444
4113
|
}
|
|
3445
4114
|
async setupRecoveredPostApproveSignals() {
|
|
@@ -3448,14 +4117,26 @@ export class AgentManager {
|
|
|
3448
4117
|
if (task.postApproveRevoked) {
|
|
3449
4118
|
// Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
|
|
3450
4119
|
try {
|
|
3451
|
-
|
|
4120
|
+
if (task.reviewMode === 'git') {
|
|
4121
|
+
if (task.postApproveToken !== undefined) {
|
|
4122
|
+
await this.taskStore.set({
|
|
4123
|
+
...this.stripGitCompletionFields(task),
|
|
4124
|
+
updatedAt: new Date().toISOString(),
|
|
4125
|
+
});
|
|
4126
|
+
}
|
|
4127
|
+
}
|
|
4128
|
+
else {
|
|
4129
|
+
await this.postApproveStore.clear(task.id);
|
|
4130
|
+
}
|
|
3452
4131
|
}
|
|
3453
4132
|
catch (err) {
|
|
3454
4133
|
console.warn(`[AgentManager] setupRecoveredPostApproveSignals: revoked completion cleanup failed for task=${task.id}:`, err);
|
|
3455
4134
|
}
|
|
3456
4135
|
continue;
|
|
3457
4136
|
}
|
|
3458
|
-
const completion =
|
|
4137
|
+
const completion = task.reviewMode === 'git'
|
|
4138
|
+
? this.gitCompletionOf(task)
|
|
4139
|
+
: await this.postApproveStore.get(task.id);
|
|
3459
4140
|
if (!completion)
|
|
3460
4141
|
continue;
|
|
3461
4142
|
if (task.postApproveHeadSha === undefined) {
|
|
@@ -3465,7 +4146,14 @@ export class AgentManager {
|
|
|
3465
4146
|
}
|
|
3466
4147
|
if (completion.approvedHeadSha !== task.postApproveHeadSha) {
|
|
3467
4148
|
try {
|
|
3468
|
-
|
|
4149
|
+
if (task.reviewMode === 'git') {
|
|
4150
|
+
await this.taskStore.set({
|
|
4151
|
+
...this.stripGitCompletionFields(task),
|
|
4152
|
+
updatedAt: new Date().toISOString(),
|
|
4153
|
+
});
|
|
4154
|
+
}
|
|
4155
|
+
else
|
|
4156
|
+
await this.postApproveStore.clear(task.id);
|
|
3469
4157
|
console.warn(`[AgentManager] setupRecoveredPostApproveSignals: task=${task.id} retired a completion bound to head ${completion.approvedHeadSha} (persisted approved head ${task.postApproveHeadSha})`);
|
|
3470
4158
|
}
|
|
3471
4159
|
catch (err) {
|
|
@@ -3498,9 +4186,16 @@ export class AgentManager {
|
|
|
3498
4186
|
for (const task of tasks) {
|
|
3499
4187
|
if (!task.signalToken)
|
|
3500
4188
|
continue;
|
|
3501
|
-
const
|
|
3502
|
-
if (
|
|
4189
|
+
const mappings = this.mapTaskStateToExpectedWatchers(task);
|
|
4190
|
+
if (mappings.length === 0)
|
|
3503
4191
|
continue;
|
|
4192
|
+
for (const mapped of mappings) {
|
|
4193
|
+
await this.setupRecoveredWatcherForMapping(task, mapped.token ?? task.signalToken, mapped, mappings.length > 1);
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
async setupRecoveredWatcherForMapping(task, signalToken, mapped, hasSiblings) {
|
|
4198
|
+
{
|
|
3504
4199
|
const { expectedKinds, agentId } = mapped;
|
|
3505
4200
|
const specStage = isSpecStagePhase(task.phase);
|
|
3506
4201
|
const interventionKindLabel = specStage && task.status === 'review' ? 'spec-reviewed'
|
|
@@ -3522,9 +4217,10 @@ export class AgentManager {
|
|
|
3522
4217
|
projectId: task.projectId,
|
|
3523
4218
|
agentId,
|
|
3524
4219
|
expectedKinds,
|
|
3525
|
-
token:
|
|
4220
|
+
token: signalToken,
|
|
3526
4221
|
skipSnapshot: !scanSnapshotOnRecover,
|
|
3527
4222
|
recovered: true,
|
|
4223
|
+
...(hasSiblings ? { replaceScope: 'agent' } : {}),
|
|
3528
4224
|
...(allowRecoveredReadFile
|
|
3529
4225
|
? { onReadFile: (req) => { void this.handleReadFileRequest(task.id, agentId, req); } }
|
|
3530
4226
|
: {}),
|
|
@@ -3618,16 +4314,259 @@ export class AgentManager {
|
|
|
3618
4314
|
return undefined;
|
|
3619
4315
|
throw new Error(`gh pr view exited ${result.exitCode} for PR #${prNumber}: ${stderr.slice(0, 200)}`);
|
|
3620
4316
|
}
|
|
3621
|
-
const parts = result.stdout.trim().split('\t');
|
|
3622
|
-
const [headRefName, headSha, state, isCross] = parts;
|
|
3623
|
-
const body = parts.slice(4).join('\t');
|
|
3624
|
-
if (!headRefName || !/^[0-9a-f]{40}$/i.test(headSha))
|
|
3625
|
-
return undefined;
|
|
3626
|
-
if (state !== 'OPEN')
|
|
3627
|
-
return undefined;
|
|
3628
|
-
if (isCross === 'true')
|
|
3629
|
-
return undefined;
|
|
3630
|
-
return { headRefName, headSha, body };
|
|
4317
|
+
const parts = result.stdout.trim().split('\t');
|
|
4318
|
+
const [headRefName, headSha, state, isCross] = parts;
|
|
4319
|
+
const body = parts.slice(4).join('\t');
|
|
4320
|
+
if (!headRefName || !/^[0-9a-f]{40}$/i.test(headSha))
|
|
4321
|
+
return undefined;
|
|
4322
|
+
if (state !== 'OPEN')
|
|
4323
|
+
return undefined;
|
|
4324
|
+
if (isCross === 'true')
|
|
4325
|
+
return undefined;
|
|
4326
|
+
return { headRefName, headSha, body };
|
|
4327
|
+
}
|
|
4328
|
+
platformBindingFields(projectId) {
|
|
4329
|
+
if (this.effectiveReviewMode(projectId) !== 'git')
|
|
4330
|
+
return {};
|
|
4331
|
+
const project = this.getProjectConfig(projectId);
|
|
4332
|
+
if (!project)
|
|
4333
|
+
return {};
|
|
4334
|
+
const tool = resolveProjectTool(project);
|
|
4335
|
+
if (tool === undefined)
|
|
4336
|
+
return {};
|
|
4337
|
+
return { platformBinding: { mode: 'git', repoKey: repoIdentityKey(project.repo), tool } };
|
|
4338
|
+
}
|
|
4339
|
+
// 指纹为纯身份三元组(spec §4):binary/env/skill 文本 live 解析可演进,身份失配即拒绝
|
|
4340
|
+
assertPlatformBinding(task) {
|
|
4341
|
+
const binding = task.platformBinding;
|
|
4342
|
+
if (!binding) {
|
|
4343
|
+
if (task.reviewMode !== 'git')
|
|
4344
|
+
return;
|
|
4345
|
+
// D11 无兼容负担:不存在合法的无快照 git 任务,缺失即损坏/手工恢复,按失配同级拒绝
|
|
4346
|
+
void this.emitIntervention(task.projectId, task.agentId || undefined, task.id, {
|
|
4347
|
+
phase: 'platform-binding-mismatch',
|
|
4348
|
+
reason: 'missing-binding-snapshot',
|
|
4349
|
+
});
|
|
4350
|
+
throw new Error(`platform binding missing for git task ${task.id}; refusing platform operations`);
|
|
4351
|
+
}
|
|
4352
|
+
const project = this.getProjectConfig(task.projectId);
|
|
4353
|
+
const live = project === undefined ? undefined : {
|
|
4354
|
+
mode: this.effectiveReviewMode(task.projectId),
|
|
4355
|
+
repoKey: repoIdentityKey(project.repo),
|
|
4356
|
+
tool: resolveProjectTool(project),
|
|
4357
|
+
};
|
|
4358
|
+
if (live !== undefined && live.mode === binding.mode
|
|
4359
|
+
&& live.repoKey === binding.repoKey && live.tool === binding.tool)
|
|
4360
|
+
return;
|
|
4361
|
+
void this.emitIntervention(task.projectId, task.agentId || undefined, task.id, {
|
|
4362
|
+
phase: 'platform-binding-mismatch',
|
|
4363
|
+
binding,
|
|
4364
|
+
...(live !== undefined ? { live: { mode: live.mode, repoKey: live.repoKey, tool: live.tool ?? '' } } : {}),
|
|
4365
|
+
});
|
|
4366
|
+
throw new Error(`platform binding mismatch for task ${task.id}: snapshot ${binding.mode}/${binding.repoKey}/${binding.tool} no longer matches the live config`);
|
|
4367
|
+
}
|
|
4368
|
+
async platformTaskAndDriver(taskId) {
|
|
4369
|
+
const task = await this.taskStore.get(taskId);
|
|
4370
|
+
if (!task)
|
|
4371
|
+
throw new Error(`task ${taskId} not found`);
|
|
4372
|
+
this.assertPlatformBinding(task);
|
|
4373
|
+
const driver = this.platformDriverFor(task.projectId);
|
|
4374
|
+
if (!driver)
|
|
4375
|
+
throw new Error(`no git driver resolvable for project ${task.projectId}`);
|
|
4376
|
+
return { task, driver };
|
|
4377
|
+
}
|
|
4378
|
+
async listActiveGitTasks(projectId) {
|
|
4379
|
+
const tasks = await this.taskStore.list(projectId !== undefined ? { projectId } : undefined);
|
|
4380
|
+
return tasks.filter(t => t.reviewMode === 'git' && !TERMINAL_STATUSES.includes(t.status));
|
|
4381
|
+
}
|
|
4382
|
+
async platformFetchPrView(taskId) {
|
|
4383
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4384
|
+
if (task.prNumber === undefined)
|
|
4385
|
+
throw new Error(`task ${taskId} has no bound PR`);
|
|
4386
|
+
const [row] = await driver.runOp('prView', { prNumber: task.prNumber });
|
|
4387
|
+
return row;
|
|
4388
|
+
}
|
|
4389
|
+
async platformVerifyPrBinding(taskId, prNumber, opts = {}) {
|
|
4390
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4391
|
+
const [row] = await driver.runOp('prView', { prNumber });
|
|
4392
|
+
let expectedBase = task.baseBranch;
|
|
4393
|
+
if (expectedBase === undefined) {
|
|
4394
|
+
try {
|
|
4395
|
+
const [project] = await driver.runOp('projectView');
|
|
4396
|
+
if (typeof project?.defaultBranch === 'string')
|
|
4397
|
+
expectedBase = project.defaultBranch;
|
|
4398
|
+
}
|
|
4399
|
+
catch {
|
|
4400
|
+
// 快照与实时默认分支都拿不到 → unverifiable:核验失败不是核验通过(fail closed)
|
|
4401
|
+
}
|
|
4402
|
+
}
|
|
4403
|
+
const check = checkOpenPrBinding(row, { branch: opts.branchOverride ?? task.branch, expectedBase });
|
|
4404
|
+
if (!check.ok) {
|
|
4405
|
+
return {
|
|
4406
|
+
...check,
|
|
4407
|
+
...(check.reason === 'branch' && typeof row.branch === 'string' ? { prBranch: row.branch } : {}),
|
|
4408
|
+
};
|
|
4409
|
+
}
|
|
4410
|
+
return {
|
|
4411
|
+
ok: true,
|
|
4412
|
+
headSha: String(row.headSha),
|
|
4413
|
+
branch: String(row.branch),
|
|
4414
|
+
targetBranch: String(row.targetBranch),
|
|
4415
|
+
};
|
|
4416
|
+
}
|
|
4417
|
+
stopPhaseSignalWatcherAgent(taskId, agentId) {
|
|
4418
|
+
this.phaseSignalWatcher?.stopAgent(taskId, agentId);
|
|
4419
|
+
}
|
|
4420
|
+
async platformClosePr(taskId, opts) {
|
|
4421
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4422
|
+
if (task.prNumber === undefined)
|
|
4423
|
+
throw new Error(`task ${taskId} has no bound PR`);
|
|
4424
|
+
try {
|
|
4425
|
+
await driver.runOp('close', { prNumber: task.prNumber });
|
|
4426
|
+
}
|
|
4427
|
+
catch (err) {
|
|
4428
|
+
throw new PlatformCloseError('close', err);
|
|
4429
|
+
}
|
|
4430
|
+
const branch = task.branch;
|
|
4431
|
+
const deletable = opts.deleteBranch && branch !== undefined && isAutoDeletableTaskBranch({
|
|
4432
|
+
taskId: task.id,
|
|
4433
|
+
taskBranch: branch,
|
|
4434
|
+
branchCreatedByBaxian: task.branchCreatedByBaxian,
|
|
4435
|
+
actualRef: `refs/heads/${branch}`,
|
|
4436
|
+
});
|
|
4437
|
+
if (deletable) {
|
|
4438
|
+
try {
|
|
4439
|
+
await driver.runOp('deleteBranch', { branch });
|
|
4440
|
+
}
|
|
4441
|
+
catch (err) {
|
|
4442
|
+
throw new PlatformCloseError('deleteBranch', err);
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
async platformScanComments(driver, prNumber) {
|
|
4447
|
+
return scanCommentSourcesOnce(driver, prNumber, () => Date.now());
|
|
4448
|
+
}
|
|
4449
|
+
// merge-ready 迁移与 merge confirm 的 provenance/ack 双复核共用一次全源扫描(spec §6/§10)
|
|
4450
|
+
async platformVerifyAcceptedPass(taskId) {
|
|
4451
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4452
|
+
if (task.prNumber === undefined)
|
|
4453
|
+
return { ok: false, reason: 'no bound PR' };
|
|
4454
|
+
const provenance = task.passProvenance;
|
|
4455
|
+
if (!provenance)
|
|
4456
|
+
return { ok: false, reason: 'no accepted pass provenance' };
|
|
4457
|
+
const scans = await this.platformScanComments(driver, task.prNumber);
|
|
4458
|
+
const recheck = recheckPassProvenance({
|
|
4459
|
+
token: provenance.token,
|
|
4460
|
+
failToken: provenance.failToken,
|
|
4461
|
+
anchorSha: provenance.anchorSha,
|
|
4462
|
+
carrier: { sourceKey: provenance.sourceKey, id: provenance.id, bodyDigest: provenance.bodyDigest },
|
|
4463
|
+
}, scans);
|
|
4464
|
+
if (!recheck.ok)
|
|
4465
|
+
return { ok: false, reason: `provenance ${recheck.reason}` };
|
|
4466
|
+
const pending = collectPendingFeedback(scans, {
|
|
4467
|
+
replyActorId: task.replyActorId,
|
|
4468
|
+
replyActorStatus: task.replyActorStatus,
|
|
4469
|
+
});
|
|
4470
|
+
if (!pending.allSourcesOk)
|
|
4471
|
+
return { ok: false, reason: 'source scan incomplete' };
|
|
4472
|
+
return { ok: true, pendingCount: pending.pending.size };
|
|
4473
|
+
}
|
|
4474
|
+
async platformPendingFeedback(taskId) {
|
|
4475
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4476
|
+
if (task.prNumber === undefined)
|
|
4477
|
+
throw new Error(`task ${taskId} has no bound PR`);
|
|
4478
|
+
const scans = await this.platformScanComments(driver, task.prNumber);
|
|
4479
|
+
const failed = scans.filter(s => !s.ok).map(s => s.key);
|
|
4480
|
+
if (failed.length > 0) {
|
|
4481
|
+
throw new Error(`pending-feedback scan incomplete; failed sources: ${failed.join(', ')}`);
|
|
4482
|
+
}
|
|
4483
|
+
return collectPendingFeedback(scans, {
|
|
4484
|
+
replyActorId: task.replyActorId,
|
|
4485
|
+
replyActorStatus: task.replyActorStatus,
|
|
4486
|
+
});
|
|
4487
|
+
}
|
|
4488
|
+
async platformConfirmMerge(taskId, opts) {
|
|
4489
|
+
const { task, driver } = await this.platformTaskAndDriver(taskId);
|
|
4490
|
+
if (task.prNumber === undefined)
|
|
4491
|
+
throw new Error(`task ${taskId} has no bound PR`);
|
|
4492
|
+
const prNumber = task.prNumber;
|
|
4493
|
+
const [row] = await driver.runOp('prView', { prNumber });
|
|
4494
|
+
const binding = checkOpenPrBinding(row, { branch: task.branch, expectedBase: task.baseBranch });
|
|
4495
|
+
if (!binding.ok)
|
|
4496
|
+
throw new Error(`merge blocked: binding ${binding.reason}`);
|
|
4497
|
+
if (String(row.headSha) !== opts.expectedHeadSha) {
|
|
4498
|
+
throw new Error(`merge blocked: head moved (expected ${opts.expectedHeadSha}, saw ${String(row.headSha)})`);
|
|
4499
|
+
}
|
|
4500
|
+
// 人工兜底(max_rounds Mark complete)绕过 QA 授权复核(该路径本就无 pass),
|
|
4501
|
+
// binding/head/REST sha 的安全检查保留(spec §7 威胁模型:merge 有人工 confirm)
|
|
4502
|
+
if (opts.humanOverride !== true) {
|
|
4503
|
+
const provenance = task.passProvenance;
|
|
4504
|
+
if (!provenance)
|
|
4505
|
+
throw new PlatformMergeRecheckError('no accepted pass provenance on task');
|
|
4506
|
+
if (provenance.anchorSha.toLowerCase() !== opts.expectedHeadSha.toLowerCase()) {
|
|
4507
|
+
throw new PlatformMergeRecheckError(`pass provenance anchors ${provenance.anchorSha}, not the merge head ${opts.expectedHeadSha}`);
|
|
4508
|
+
}
|
|
4509
|
+
const scans = await this.platformScanComments(driver, prNumber);
|
|
4510
|
+
const recheck = recheckPassProvenance({
|
|
4511
|
+
token: provenance.token,
|
|
4512
|
+
failToken: provenance.failToken,
|
|
4513
|
+
anchorSha: provenance.anchorSha,
|
|
4514
|
+
carrier: { sourceKey: provenance.sourceKey, id: provenance.id, bodyDigest: provenance.bodyDigest },
|
|
4515
|
+
}, scans);
|
|
4516
|
+
if (!recheck.ok) {
|
|
4517
|
+
// 瞬态扫描不完整留在 gate 可重试;只有确定性失效(撤销/编辑/缺失)才退回 approved
|
|
4518
|
+
if (recheck.reason === 'source-scan-incomplete') {
|
|
4519
|
+
throw new Error('merge blocked: provenance source-scan-incomplete');
|
|
4520
|
+
}
|
|
4521
|
+
throw new PlatformMergeRecheckError(`provenance ${recheck.reason}`);
|
|
4522
|
+
}
|
|
4523
|
+
const pending = collectPendingFeedback(scans, {
|
|
4524
|
+
replyActorId: task.replyActorId,
|
|
4525
|
+
replyActorStatus: task.replyActorStatus,
|
|
4526
|
+
});
|
|
4527
|
+
if (!pending.allSourcesOk)
|
|
4528
|
+
throw new Error('merge blocked: pending-feedback scan incomplete');
|
|
4529
|
+
if (pending.pending.size > 0) {
|
|
4530
|
+
throw new PlatformMergeRecheckError(`${pending.pending.size} pending feedback revision(s) without ack`);
|
|
4531
|
+
}
|
|
4532
|
+
}
|
|
4533
|
+
// 临门 CAS:扫描期间 durable 退回(approved/pendingRedispatch/fixing)必须使本次 Confirm 失效;
|
|
4534
|
+
// REST sha 只保护 source head,保护不了评论面与 TaskState(残余网络窗口按 spec §6 如实声明)
|
|
4535
|
+
const fresh = await this.taskStore.get(taskId);
|
|
4536
|
+
if (!fresh
|
|
4537
|
+
|| (opts.humanOverride === true
|
|
4538
|
+
// override 只绕过 provenance/ack,不绕过本次认领的 gate 代际:并发 RC 转 fixing 即失效
|
|
4539
|
+
? fresh.status !== 'merge-ready'
|
|
4540
|
+
: (fresh.status !== 'merge-ready' && fresh.status !== 'ready'))
|
|
4541
|
+
|| fresh.latestHeadSha !== opts.expectedHeadSha
|
|
4542
|
+
|| fresh.pendingRedispatch === true) {
|
|
4543
|
+
throw new Error(`merge blocked: task left its merge gate (status=${fresh?.status ?? 'gone'})`);
|
|
4544
|
+
}
|
|
4545
|
+
try {
|
|
4546
|
+
await driver.runOp('merge', { prNumber, expectedHeadSha: opts.expectedHeadSha });
|
|
4547
|
+
}
|
|
4548
|
+
catch (err) {
|
|
4549
|
+
if (err instanceof DriverOpError && err.info.errorClass === 'MERGE_BLOCKED') {
|
|
4550
|
+
let mergeStatus;
|
|
4551
|
+
try {
|
|
4552
|
+
const [fresh] = await driver.runOp('prView', { prNumber });
|
|
4553
|
+
if (typeof fresh?.detailedMergeStatus === 'string')
|
|
4554
|
+
mergeStatus = fresh.detailedMergeStatus;
|
|
4555
|
+
}
|
|
4556
|
+
catch {
|
|
4557
|
+
// 归因查询失败不遮蔽原始错误
|
|
4558
|
+
}
|
|
4559
|
+
throw new Error(`merge blocked by platform${mergeStatus ? ` (${mergeStatus})` : ''}: ${err.message}`);
|
|
4560
|
+
}
|
|
4561
|
+
throw err;
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
mintReviewTokenPair() {
|
|
4565
|
+
const passToken = createSignalToken();
|
|
4566
|
+
let failToken = createSignalToken();
|
|
4567
|
+
while (failToken === passToken)
|
|
4568
|
+
failToken = createSignalToken();
|
|
4569
|
+
return { passToken, failToken };
|
|
3631
4570
|
}
|
|
3632
4571
|
async listTasksByProject(projectId) {
|
|
3633
4572
|
return this.taskStore.list({ projectId });
|
|
@@ -3684,6 +4623,7 @@ export class AgentManager {
|
|
|
3684
4623
|
return null;
|
|
3685
4624
|
if (!guard.fromStatus.includes(previousStatus))
|
|
3686
4625
|
return null;
|
|
4626
|
+
// 代际 CAS(spec §7):校验后提交前的并发换代(push/重派轮换 token+pair)使旧 verdict 失效
|
|
3687
4627
|
if (guard.expectSignalToken !== undefined && task.signalToken !== guard.expectSignalToken)
|
|
3688
4628
|
return null;
|
|
3689
4629
|
if (patch?.branch && patch.branch !== task.branch) {
|
|
@@ -3704,16 +4644,21 @@ export class AgentManager {
|
|
|
3704
4644
|
return { task, previousStatus };
|
|
3705
4645
|
});
|
|
3706
4646
|
}
|
|
3707
|
-
|
|
4647
|
+
// 送达即计轮:flag 是唯一入账凭据,与 verdict 公式(round + flag)按 token CAS 互斥消费,
|
|
4648
|
+
// successor 换代或 verdict 先行都自然跳过,恰好一次
|
|
4649
|
+
// 不设终态守卫:只写轮次不写 status(无复活风险),而终态任务的 recheck 派发是受支持的能力,
|
|
4650
|
+
// 其送达的轮次同样要入账
|
|
4651
|
+
async consumeReviewRoundIntent(taskId, expectedToken) {
|
|
3708
4652
|
await this.withTaskLock(async () => {
|
|
3709
|
-
const
|
|
3710
|
-
if (!
|
|
3711
|
-
return;
|
|
3712
|
-
if ((task.reviewRound ?? 0) !== expectedRound)
|
|
4653
|
+
const fresh = await this.taskStore.get(taskId);
|
|
4654
|
+
if (!fresh || fresh.signalToken !== expectedToken || fresh.reviewRoundPending !== true)
|
|
3713
4655
|
return;
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
4656
|
+
await this.taskStore.set({
|
|
4657
|
+
...fresh,
|
|
4658
|
+
reviewRound: fresh.reviewRound + 1,
|
|
4659
|
+
reviewRoundPending: undefined,
|
|
4660
|
+
updatedAt: new Date().toISOString(),
|
|
4661
|
+
});
|
|
3717
4662
|
});
|
|
3718
4663
|
}
|
|
3719
4664
|
createRunnerFor(agent) {
|
|
@@ -3743,6 +4688,9 @@ export class AgentManager {
|
|
|
3743
4688
|
return this.repoCache;
|
|
3744
4689
|
}
|
|
3745
4690
|
async rollbackFailedDispatch(taskId, agentId, reason, expectedLockToken) {
|
|
4691
|
+
// Pin the incarnation: task rollback / intervention below await, and a DELETE→same-id recreate in that
|
|
4692
|
+
// window must not let the final state write-back resurrect the deleted agent.
|
|
4693
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
3746
4694
|
const existing = await this.agentStore.get(agentId);
|
|
3747
4695
|
if (existing && existing.taskId !== undefined && existing.taskId !== taskId) {
|
|
3748
4696
|
console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
|
|
@@ -3789,12 +4737,16 @@ export class AgentManager {
|
|
|
3789
4737
|
else {
|
|
3790
4738
|
const now = new Date().toISOString();
|
|
3791
4739
|
await this.agentStore.update(agentId, (latest) => {
|
|
3792
|
-
if
|
|
4740
|
+
// Never resurrect a deleted/recreated incarnation: if the state is gone or the generation moved
|
|
4741
|
+
// since entry, this rollback is stale — clear nothing rather than rebuild from the old snapshot.
|
|
4742
|
+
if (!latest || !this.deletionGateOpen(agentId, genAtEntry))
|
|
3793
4743
|
return AGENT_STORE_NOOP;
|
|
3794
|
-
if (latest
|
|
4744
|
+
if (latest.taskId === taskId && latest.lockToken !== expectedLockToken)
|
|
4745
|
+
return AGENT_STORE_NOOP;
|
|
4746
|
+
if (latest.taskId !== undefined && latest.taskId !== taskId)
|
|
3795
4747
|
return AGENT_STORE_NOOP;
|
|
3796
4748
|
return {
|
|
3797
|
-
...
|
|
4749
|
+
...latest,
|
|
3798
4750
|
id: agentId,
|
|
3799
4751
|
projectId,
|
|
3800
4752
|
taskId: undefined,
|
|
@@ -3871,6 +4823,7 @@ export class AgentManager {
|
|
|
3871
4823
|
branch: taskBranch,
|
|
3872
4824
|
branchCreatedByBaxian,
|
|
3873
4825
|
reviewMode: this.effectiveReviewMode(projectId),
|
|
4826
|
+
...this.platformBindingFields(projectId),
|
|
3874
4827
|
createdAt: now,
|
|
3875
4828
|
updatedAt: now,
|
|
3876
4829
|
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
@@ -3884,6 +4837,8 @@ export class AgentManager {
|
|
|
3884
4837
|
};
|
|
3885
4838
|
return this.persistQueuedTask(unassigned, 'unassigned');
|
|
3886
4839
|
}
|
|
4840
|
+
// Capture generation before the first config/group read so a DELETE→same-id recreate in the read→commit window fails the gate.
|
|
4841
|
+
const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
|
|
3887
4842
|
const target = await this.pickAgent(projectId, input.preferredAgentId);
|
|
3888
4843
|
const targetConfig = this.getAgentConfig(input.preferredAgentId);
|
|
3889
4844
|
const group = this.findAgentGroup(input.preferredAgentId);
|
|
@@ -3895,6 +4850,12 @@ export class AgentManager {
|
|
|
3895
4850
|
const researchFields = research
|
|
3896
4851
|
? { researchAgentId: research.id, phase: 'research' }
|
|
3897
4852
|
: {};
|
|
4853
|
+
// 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.
|
|
4854
|
+
const participantGens = new Map([dev.id, ...(qa ? [qa.id] : []), ...(research ? [research.id] : [])]
|
|
4855
|
+
.map(id => [id, this.deletionGenerationOf(id)]));
|
|
4856
|
+
const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
|
|
4857
|
+
&& !this.isDeletionInFlight(input.preferredAgentId)
|
|
4858
|
+
&& [...participantGens].every(([id, gen]) => this.deletionGenerationOf(id) === gen && !this.isDeletionInFlight(id));
|
|
3898
4859
|
const queued = {
|
|
3899
4860
|
...taskBase,
|
|
3900
4861
|
agentId: '',
|
|
@@ -3903,12 +4864,81 @@ export class AgentManager {
|
|
|
3903
4864
|
status: 'pending',
|
|
3904
4865
|
...(qa ? { qaAgentId: qa.id } : {}),
|
|
3905
4866
|
};
|
|
4867
|
+
// A DELETE→recreate of the preferred OR any participant after the group snapshot must not persist a
|
|
4868
|
+
// pending task carrying its stale devAgentId/qaAgentId; reject and let the client re-select.
|
|
4869
|
+
const persistQueuedOrReject = (reason) => {
|
|
4870
|
+
if (!participantsFresh()) {
|
|
4871
|
+
throw new ApiError(409, `Agent ${input.preferredAgentId} or a group member is being deleted or recreated during task creation; please retry`);
|
|
4872
|
+
}
|
|
4873
|
+
return this.persistQueuedTask(queued, reason, input.preferredAgentId);
|
|
4874
|
+
};
|
|
3906
4875
|
if (!target) {
|
|
3907
|
-
return
|
|
4876
|
+
return persistQueuedOrReject('preferred_agent_busy');
|
|
4877
|
+
}
|
|
4878
|
+
if (this.isDeletionInFlight(target.id)) {
|
|
4879
|
+
return persistQueuedOrReject('agent_locked');
|
|
3908
4880
|
}
|
|
3909
4881
|
const lockToken = await this.lockManager.acquire(target.id, taskId);
|
|
3910
4882
|
if (!lockToken) {
|
|
3911
|
-
return
|
|
4883
|
+
return persistQueuedOrReject('agent_locked');
|
|
4884
|
+
}
|
|
4885
|
+
// 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.
|
|
4886
|
+
const releaseLock = async () => {
|
|
4887
|
+
try {
|
|
4888
|
+
await this.lockManager.releaseIfOwner(target.id, taskId, lockToken);
|
|
4889
|
+
}
|
|
4890
|
+
catch (releaseErr) {
|
|
4891
|
+
console.warn(`[AgentManager] createTask(${taskId}): lock release after failed assignment also failed:`, releaseErr);
|
|
4892
|
+
}
|
|
4893
|
+
};
|
|
4894
|
+
try {
|
|
4895
|
+
await this.taskStore.set(queued);
|
|
4896
|
+
}
|
|
4897
|
+
catch (err) {
|
|
4898
|
+
await releaseLock();
|
|
4899
|
+
throw err;
|
|
4900
|
+
}
|
|
4901
|
+
let commit;
|
|
4902
|
+
try {
|
|
4903
|
+
commit = await this.agentStore.update(target.id, (existing) => {
|
|
4904
|
+
if (existing === null && !this.bindingInitPredicate(target.id, genAtEntry))
|
|
4905
|
+
return AGENT_STORE_NOOP;
|
|
4906
|
+
if (!this.deletionGateOpen(target.id, genAtEntry))
|
|
4907
|
+
return AGENT_STORE_NOOP;
|
|
4908
|
+
return {
|
|
4909
|
+
id: target.id,
|
|
4910
|
+
projectId,
|
|
4911
|
+
taskId,
|
|
4912
|
+
lockToken,
|
|
4913
|
+
bootstrappingTaskId: taskId,
|
|
4914
|
+
updatedAt: now,
|
|
4915
|
+
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
4916
|
+
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
4917
|
+
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
4918
|
+
};
|
|
4919
|
+
});
|
|
4920
|
+
}
|
|
4921
|
+
catch (err) {
|
|
4922
|
+
await releaseLock();
|
|
4923
|
+
throw err;
|
|
4924
|
+
}
|
|
4925
|
+
if (commit !== 'committed') {
|
|
4926
|
+
await releaseLock();
|
|
4927
|
+
return (await this.taskStore.get(taskId)) ?? queued;
|
|
4928
|
+
}
|
|
4929
|
+
// 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.
|
|
4930
|
+
if (this.isDeletionInFlight(target.id)
|
|
4931
|
+
|| !this.deletionGateOpen(target.id, genAtEntry)
|
|
4932
|
+
|| !participantsFresh()
|
|
4933
|
+
|| !(await this.lockManager.isOwner(target.id, taskId, lockToken))) {
|
|
4934
|
+
await this.agentStore.update(target.id, (existing) => {
|
|
4935
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
|
|
4936
|
+
return AGENT_STORE_NOOP;
|
|
4937
|
+
const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
|
|
4938
|
+
return { ...rest, updatedAt: new Date().toISOString() };
|
|
4939
|
+
});
|
|
4940
|
+
await releaseLock();
|
|
4941
|
+
return (await this.taskStore.get(taskId)) ?? queued;
|
|
3912
4942
|
}
|
|
3913
4943
|
const task = {
|
|
3914
4944
|
...taskBase,
|
|
@@ -3918,18 +4948,24 @@ export class AgentManager {
|
|
|
3918
4948
|
...researchFields,
|
|
3919
4949
|
status: 'in_progress',
|
|
3920
4950
|
};
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
4951
|
+
try {
|
|
4952
|
+
await this.taskStore.set(task);
|
|
4953
|
+
}
|
|
4954
|
+
catch (err) {
|
|
4955
|
+
try {
|
|
4956
|
+
await this.agentStore.update(target.id, (existing) => {
|
|
4957
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
|
|
4958
|
+
return AGENT_STORE_NOOP;
|
|
4959
|
+
const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
|
|
4960
|
+
return { ...rest, updatedAt: new Date().toISOString() };
|
|
4961
|
+
});
|
|
4962
|
+
}
|
|
4963
|
+
catch (rollbackErr) {
|
|
4964
|
+
console.warn(`[AgentManager] createTask(${taskId}): binding rollback after task write failure also failed (stale-binding path will reclaim):`, rollbackErr);
|
|
4965
|
+
}
|
|
4966
|
+
await releaseLock();
|
|
4967
|
+
throw err;
|
|
4968
|
+
}
|
|
3933
4969
|
await this.safeEmit({
|
|
3934
4970
|
id: '',
|
|
3935
4971
|
type: 'task.assigned',
|
|
@@ -3946,7 +4982,7 @@ export class AgentManager {
|
|
|
3946
4982
|
const task = await this.createTask(projectId, input);
|
|
3947
4983
|
if (task.status === 'in_progress' && task.agentId) {
|
|
3948
4984
|
const signalToken = createSignalToken();
|
|
3949
|
-
await this.updateTask(task.id,
|
|
4985
|
+
await this.updateTask(task.id, this.dispatchTokenFields(task, signalToken));
|
|
3950
4986
|
const dispatchLockToken = (await this.agentStore.get(task.agentId))?.lockToken;
|
|
3951
4987
|
const start = this.startCreatedTaskSession(task.id, task.agentId, signalToken, dispatchLockToken);
|
|
3952
4988
|
if (opts.background) {
|
|
@@ -4037,6 +5073,9 @@ export class AgentManager {
|
|
|
4037
5073
|
const cfg = this.getAgentConfig(agentId);
|
|
4038
5074
|
if (!cfg)
|
|
4039
5075
|
throw new ApiError(404, `Unknown agent: ${agentId}`);
|
|
5076
|
+
// Pin the incarnation before reading paneId: paneId equality is ABA-blind (a recreate reuses %0), so a
|
|
5077
|
+
// DELETE→same-id recreate during the upload would otherwise paste the stale image into the new session.
|
|
5078
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
4040
5079
|
const state = await this.agentStore.get(agentId);
|
|
4041
5080
|
const paneId = state?.paneId;
|
|
4042
5081
|
if (!paneId)
|
|
@@ -4046,6 +5085,9 @@ export class AgentManager {
|
|
|
4046
5085
|
}
|
|
4047
5086
|
try {
|
|
4048
5087
|
const assertUploadStillValid = async () => {
|
|
5088
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
5089
|
+
throw new ApiError(409, `Agent ${agentId} was deleted or recreated while uploading; image paste aborted`);
|
|
5090
|
+
}
|
|
4049
5091
|
const held = await this.agentStore.get(agentId);
|
|
4050
5092
|
if (!held || held.paneId !== paneId) {
|
|
4051
5093
|
throw new ApiError(409, `Agent ${agentId} session changed while uploading; image paste aborted`);
|
|
@@ -4065,7 +5107,10 @@ export class AgentManager {
|
|
|
4065
5107
|
await writeImageToHost(runner, path, bytes);
|
|
4066
5108
|
await assertUploadStillValid();
|
|
4067
5109
|
const tmux = new TmuxManager(runner);
|
|
4068
|
-
await
|
|
5110
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
|
|
5111
|
+
// 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.
|
|
5112
|
+
await assertUploadStillValid();
|
|
5113
|
+
await tmux.injectPrompt(pane, `${path} `, agentId);
|
|
4069
5114
|
return { path };
|
|
4070
5115
|
}
|
|
4071
5116
|
finally {
|
|
@@ -4097,9 +5142,10 @@ export class AgentManager {
|
|
|
4097
5142
|
}
|
|
4098
5143
|
};
|
|
4099
5144
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
5145
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
|
|
4100
5146
|
const waitReady = async () => {
|
|
4101
5147
|
try {
|
|
4102
|
-
await this.waitForReplPromptReady(tmux,
|
|
5148
|
+
await this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.manualCompactWaitMs);
|
|
4103
5149
|
}
|
|
4104
5150
|
catch (err) {
|
|
4105
5151
|
const detail = err instanceof Error ? err.message : String(err);
|
|
@@ -4108,13 +5154,13 @@ export class AgentManager {
|
|
|
4108
5154
|
};
|
|
4109
5155
|
await waitReady();
|
|
4110
5156
|
await assertSessionUnchanged();
|
|
4111
|
-
await tmux.clearComposerDraft(
|
|
5157
|
+
await tmux.clearComposerDraft(pane);
|
|
4112
5158
|
await waitReady();
|
|
4113
5159
|
await assertSessionUnchanged();
|
|
4114
|
-
await tmux.sendKeysLiteral(
|
|
4115
|
-
await tmux.sendEnter(
|
|
5160
|
+
await tmux.sendKeysLiteral(pane, command);
|
|
5161
|
+
await tmux.sendEnter(pane);
|
|
4116
5162
|
guardHandedOff = true;
|
|
4117
|
-
void this.waitForReplPromptReady(tmux,
|
|
5163
|
+
void this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs)
|
|
4118
5164
|
.catch(err => {
|
|
4119
5165
|
console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) post idle wait failed:`, err);
|
|
4120
5166
|
})
|
|
@@ -4133,25 +5179,98 @@ export class AgentManager {
|
|
|
4133
5179
|
async clearAgent(agentId) {
|
|
4134
5180
|
return this.sendSlashCommand(agentId, '/clear');
|
|
4135
5181
|
}
|
|
4136
|
-
|
|
5182
|
+
registerPendingDispatchRetry(taskId, entry, budgetOverride) {
|
|
5183
|
+
const prev = this.pendingDispatchRetryByTask.get(taskId);
|
|
5184
|
+
// 预算随登记代走:同 kind 但 pass 换代(token/agent 变化)时必须重置 since 与告警标志。
|
|
5185
|
+
// budgetOverride 供对账内部重排延续同一笔欠投递的预算——它只经 startSession 的 armedToken
|
|
5186
|
+
// fence 注入,外部 successor 的登记结构上拿不到
|
|
5187
|
+
const sameGeneration = prev?.kind === entry.kind
|
|
5188
|
+
&& prev.agentId === entry.agentId
|
|
5189
|
+
&& prev.signalToken === entry.signalToken;
|
|
5190
|
+
const since = sameGeneration ? prev.since : (budgetOverride?.since ?? Date.now());
|
|
5191
|
+
const budgetAlerted = sameGeneration
|
|
5192
|
+
? prev.budgetAlerted === true
|
|
5193
|
+
: budgetOverride?.budgetAlerted === true;
|
|
5194
|
+
this.pendingDispatchRetryByTask.set(taskId, {
|
|
5195
|
+
...entry,
|
|
5196
|
+
since,
|
|
5197
|
+
...(budgetAlerted ? { budgetAlerted: true } : {}),
|
|
5198
|
+
});
|
|
5199
|
+
}
|
|
5200
|
+
getPendingDispatchRetry(taskId) {
|
|
5201
|
+
return this.pendingDispatchRetryByTask.get(taskId);
|
|
5202
|
+
}
|
|
5203
|
+
listPendingDispatchRetries() {
|
|
5204
|
+
return this.pendingDispatchRetryByTask;
|
|
5205
|
+
}
|
|
5206
|
+
clearPendingDispatchRetry(taskId) {
|
|
5207
|
+
this.pendingDispatchRetryByTask.delete(taskId);
|
|
5208
|
+
}
|
|
5209
|
+
clearPendingDispatchRetryIfMatches(taskId, expected) {
|
|
5210
|
+
const entry = this.pendingDispatchRetryByTask.get(taskId);
|
|
5211
|
+
if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
|
|
5212
|
+
this.pendingDispatchRetryByTask.delete(taskId);
|
|
5213
|
+
}
|
|
5214
|
+
}
|
|
5215
|
+
markPendingDispatchRetryBudgetAlerted(taskId, expected) {
|
|
5216
|
+
const entry = this.pendingDispatchRetryByTask.get(taskId);
|
|
5217
|
+
if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
|
|
5218
|
+
this.pendingDispatchRetryByTask.set(taskId, { ...entry, budgetAlerted: true });
|
|
5219
|
+
}
|
|
5220
|
+
}
|
|
5221
|
+
async queueQaBusyPendingRetry(taskId, agentId, phase, createdSession, err, dispatch) {
|
|
5222
|
+
if (!(err instanceof ReplNotReadyError))
|
|
5223
|
+
return null;
|
|
5224
|
+
if (phase !== 'review' && phase !== 'recheck')
|
|
5225
|
+
return null;
|
|
5226
|
+
// 无本次派发的 pass 令牌就不登记(宁走既有失败路径,不造无 fence 重试);
|
|
5227
|
+
// 令牌漂移说明 pass 已被接管,忙碌属于旧派发,不得登记到 successor 上
|
|
5228
|
+
const passToken = dispatch.passToken;
|
|
5229
|
+
if (!passToken)
|
|
5230
|
+
return null;
|
|
5231
|
+
let current;
|
|
5232
|
+
try {
|
|
5233
|
+
current = await this.taskStore.get(taskId);
|
|
5234
|
+
}
|
|
5235
|
+
catch (readErr) {
|
|
5236
|
+
console.warn(`[AgentManager] startSession[${phase}]: busy on task ${taskId} but pass token could not be verified; ` +
|
|
5237
|
+
`falling back to the non-pending failure path:`, readErr);
|
|
5238
|
+
return null;
|
|
5239
|
+
}
|
|
5240
|
+
if (current?.signalToken !== passToken) {
|
|
5241
|
+
console.warn(`[AgentManager] startSession[${phase}]: busy on task ${taskId} but the review pass was superseded ` +
|
|
5242
|
+
`(expected ${passToken}, now ${current?.signalToken ?? 'gone'}); not queueing a pending retry`);
|
|
5243
|
+
return null;
|
|
5244
|
+
}
|
|
5245
|
+
this.registerPendingDispatchRetry(taskId, {
|
|
5246
|
+
kind: 'qa-recheck',
|
|
5247
|
+
agentId,
|
|
5248
|
+
signalToken: passToken,
|
|
5249
|
+
qaPhase: phase,
|
|
5250
|
+
}, dispatch.pendingBudget);
|
|
5251
|
+
console.warn(`[AgentManager] startSession[${phase}]: QA pane busy for task ${taskId}; ` +
|
|
5252
|
+
`${phase} queued for redispatch when idle (${(err.message.split('\n')[0])})`);
|
|
5253
|
+
return new EnsureSessionError({ createdSession, agentId, handled: true, busyPending: true }, `QA REPL busy; ${phase} dispatch for task ${taskId} queued for redispatch when idle`);
|
|
5254
|
+
}
|
|
5255
|
+
async clearRuntimeForTaskBoundary(tmux, pane, agentId, runtime, revalidate) {
|
|
4137
5256
|
await this.acquireCompactGuard(agentId);
|
|
4138
5257
|
try {
|
|
4139
|
-
await this.waitForReplPromptReady(tmux,
|
|
5258
|
+
await this.waitForReplPromptReady(tmux, pane, runtime, this.cleanComposerWaitMs, { stableIdle: true });
|
|
4140
5259
|
await revalidate();
|
|
4141
|
-
await tmux.clearComposerDraft(
|
|
4142
|
-
const baseline = await tmux.captureSettledSnapshot(
|
|
5260
|
+
await tmux.clearComposerDraft(pane);
|
|
5261
|
+
const baseline = await tmux.captureSettledSnapshot(pane, {
|
|
4143
5262
|
timeoutMs: this.dispatchSettleTimeoutMs,
|
|
4144
5263
|
});
|
|
4145
|
-
const baselineTitle = await tmux.readPaneTitle(
|
|
4146
|
-
await tmux.sendKeysLiteral(
|
|
4147
|
-
await tmux.sendEnter(
|
|
4148
|
-
await tmux.waitSubmitAck(
|
|
5264
|
+
const baselineTitle = await tmux.readPaneTitle(pane);
|
|
5265
|
+
await tmux.sendKeysLiteral(pane, '/clear');
|
|
5266
|
+
await tmux.sendEnter(pane);
|
|
5267
|
+
await tmux.waitSubmitAck(pane, baseline, runtime, {
|
|
4149
5268
|
timeoutMs: this.dispatchAckTimeoutMs,
|
|
4150
5269
|
baselineTitle,
|
|
4151
5270
|
acceptComposerChange: true,
|
|
4152
5271
|
});
|
|
4153
|
-
await this.waitForReplPromptReady(tmux,
|
|
4154
|
-
if (await this.hasRuntimeSlashCommandRejection(tmux,
|
|
5272
|
+
await this.waitForReplPromptReady(tmux, pane, runtime, this.dispatchAckTimeoutMs, { stableIdle: true });
|
|
5273
|
+
if (await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear')) {
|
|
4155
5274
|
throw new Error('Runtime rejected /clear at the task boundary; refusing to reuse prior task context');
|
|
4156
5275
|
}
|
|
4157
5276
|
await revalidate();
|
|
@@ -4228,6 +5347,8 @@ export class AgentManager {
|
|
|
4228
5347
|
error: `Task ${taskId} has no preferredAgentId; agentId is required in request body`,
|
|
4229
5348
|
};
|
|
4230
5349
|
}
|
|
5350
|
+
// Capture generation before the first config/state read so a DELETE→same-id recreate in the read→commit window fails the gate.
|
|
5351
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
4231
5352
|
const cfg = this.getAgentConfig(agentId);
|
|
4232
5353
|
if (!cfg)
|
|
4233
5354
|
return { task: fresh, errorCode: 400, error: `Unknown agent: ${agentId}` };
|
|
@@ -4248,6 +5369,9 @@ export class AgentManager {
|
|
|
4248
5369
|
if (!canDispatchWithBinding(state)) {
|
|
4249
5370
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is busy or awaiting human` };
|
|
4250
5371
|
}
|
|
5372
|
+
if (this.isDeletionInFlight(agentId)) {
|
|
5373
|
+
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
|
|
5374
|
+
}
|
|
4251
5375
|
const lockToken = await this.lockManager.acquire(agentId, taskId);
|
|
4252
5376
|
if (!lockToken) {
|
|
4253
5377
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
|
|
@@ -4266,6 +5390,10 @@ export class AgentManager {
|
|
|
4266
5390
|
? group?.find(agent => agent.role === 'qa')?.id
|
|
4267
5391
|
: fresh.qaAgentId;
|
|
4268
5392
|
const researchId = initiallyUnassigned && cfg.role === 'research' ? cfg.id : fresh.researchAgentId;
|
|
5393
|
+
// Pin every participant's generation (mirror createTask) so a member deleted→recreated before the active write isn't baked in.
|
|
5394
|
+
const participantGens = new Map([devId, ...(qaId ? [qaId] : []), ...(researchId ? [researchId] : [])]
|
|
5395
|
+
.map(pid => [pid, this.deletionGenerationOf(pid)]));
|
|
5396
|
+
const participantsFresh = () => [...participantGens].every(([pid, gen]) => this.deletionGenerationOf(pid) === gen && !this.isDeletionInFlight(pid));
|
|
4269
5397
|
const claimedTask = {
|
|
4270
5398
|
...fresh,
|
|
4271
5399
|
preferredAgentId: agentId,
|
|
@@ -4277,18 +5405,76 @@ export class AgentManager {
|
|
|
4277
5405
|
status: 'in_progress',
|
|
4278
5406
|
updatedAt: now,
|
|
4279
5407
|
};
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
5408
|
+
// Binding-first: the fresh task snapshot already exists on disk, so a failure at any later
|
|
5409
|
+
// step leaves at worst a stale binding to a still-unclaimed task — an existing reclaimable state.
|
|
5410
|
+
const releaseLock = async () => {
|
|
5411
|
+
try {
|
|
5412
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
|
|
5413
|
+
}
|
|
5414
|
+
catch (releaseErr) {
|
|
5415
|
+
console.warn(`[AgentManager] claimTask(${taskId}): lock release after failed claim also failed:`, releaseErr);
|
|
5416
|
+
}
|
|
5417
|
+
};
|
|
5418
|
+
let commit;
|
|
5419
|
+
try {
|
|
5420
|
+
commit = await this.agentStore.update(agentId, (existing) => {
|
|
5421
|
+
if (existing === null && !this.bindingInitPredicate(agentId, genAtEntry))
|
|
5422
|
+
return AGENT_STORE_NOOP;
|
|
5423
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
5424
|
+
return AGENT_STORE_NOOP;
|
|
5425
|
+
return {
|
|
5426
|
+
id: agentId,
|
|
5427
|
+
projectId: cfg.projectId,
|
|
5428
|
+
taskId,
|
|
5429
|
+
lockToken,
|
|
5430
|
+
bootstrappingTaskId: taskId,
|
|
5431
|
+
updatedAt: now,
|
|
5432
|
+
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
5433
|
+
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
5434
|
+
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
5435
|
+
};
|
|
5436
|
+
});
|
|
5437
|
+
}
|
|
5438
|
+
catch (err) {
|
|
5439
|
+
await releaseLock();
|
|
5440
|
+
throw err;
|
|
5441
|
+
}
|
|
5442
|
+
if (commit !== 'committed') {
|
|
5443
|
+
await releaseLock();
|
|
5444
|
+
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
|
|
5445
|
+
}
|
|
5446
|
+
// Re-verify lock ownership + preferred/participant generations before the active write, else a DELETE after the binding commit orphans it.
|
|
5447
|
+
if (this.isDeletionInFlight(agentId)
|
|
5448
|
+
|| !this.deletionGateOpen(agentId, genAtEntry)
|
|
5449
|
+
|| !participantsFresh()
|
|
5450
|
+
|| !(await this.lockManager.isOwner(agentId, taskId, lockToken))) {
|
|
5451
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
5452
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
|
|
5453
|
+
return AGENT_STORE_NOOP;
|
|
5454
|
+
const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
|
|
5455
|
+
return { ...rest, updatedAt: new Date().toISOString() };
|
|
5456
|
+
});
|
|
5457
|
+
await releaseLock();
|
|
5458
|
+
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
|
|
5459
|
+
}
|
|
5460
|
+
try {
|
|
5461
|
+
await this.taskStore.set(claimedTask);
|
|
5462
|
+
}
|
|
5463
|
+
catch (err) {
|
|
5464
|
+
try {
|
|
5465
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
5466
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
|
|
5467
|
+
return AGENT_STORE_NOOP;
|
|
5468
|
+
const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
|
|
5469
|
+
return { ...rest, updatedAt: new Date().toISOString() };
|
|
5470
|
+
});
|
|
5471
|
+
}
|
|
5472
|
+
catch (rollbackErr) {
|
|
5473
|
+
console.warn(`[AgentManager] claimTask(${taskId}): binding rollback after task write failure also failed (stale-binding path will reclaim):`, rollbackErr);
|
|
5474
|
+
}
|
|
5475
|
+
await releaseLock();
|
|
5476
|
+
throw err;
|
|
5477
|
+
}
|
|
4292
5478
|
await this.safeEmit({
|
|
4293
5479
|
id: '',
|
|
4294
5480
|
type: 'task.assigned',
|
|
@@ -4306,7 +5492,7 @@ export class AgentManager {
|
|
|
4306
5492
|
return claim;
|
|
4307
5493
|
const claimed = claim.task;
|
|
4308
5494
|
const signalToken = createSignalToken();
|
|
4309
|
-
await this.updateTask(claimed.id,
|
|
5495
|
+
await this.updateTask(claimed.id, this.dispatchTokenFields(claimed, signalToken));
|
|
4310
5496
|
let started = false;
|
|
4311
5497
|
let dispatchErr = null;
|
|
4312
5498
|
const initialDispatch = this.resolveInitialDispatch(claimed);
|
|
@@ -4388,6 +5574,8 @@ export class AgentManager {
|
|
|
4388
5574
|
const agent = this.getAgentConfig(agentId);
|
|
4389
5575
|
if (!agent)
|
|
4390
5576
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
5577
|
+
// Pin the incarnation: a DELETE completing during dispatch bumps this, and every gated commit/side-effect below refuses the stale generation.
|
|
5578
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
4391
5579
|
const task = await this.taskStore.get(taskId);
|
|
4392
5580
|
if (!task)
|
|
4393
5581
|
throw new Error(`Unknown task: ${taskId}`);
|
|
@@ -4433,6 +5621,11 @@ export class AgentManager {
|
|
|
4433
5621
|
return false;
|
|
4434
5622
|
}
|
|
4435
5623
|
const assertOwner = async () => {
|
|
5624
|
+
// Also gate on the entry generation: a DELETE→same-id recreate can pass the task/lock check on the
|
|
5625
|
+
// reused binding, so every Workdir/runtime side-effect guarded by assertOwner must fail closed on it.
|
|
5626
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
5627
|
+
throw new Error(`Agent ${agentId} is being deleted or was recreated for task ${taskId}; operation aborted`);
|
|
5628
|
+
}
|
|
4436
5629
|
const stateNow = await this.agentStore.get(agentId);
|
|
4437
5630
|
if (stateNow?.taskId !== taskId || stateNow.lockToken !== lockToken) {
|
|
4438
5631
|
throw new Error(`Agent ${agentId} binding changed for task ${taskId}; operation aborted`);
|
|
@@ -4443,10 +5636,10 @@ export class AgentManager {
|
|
|
4443
5636
|
const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
|
|
4444
5637
|
let ensure;
|
|
4445
5638
|
try {
|
|
4446
|
-
ensure = await this.ensureSession(agentId, 'runtime');
|
|
5639
|
+
ensure = await this.ensureSession(agentId, 'runtime', { expectedGeneration: genAtEntry });
|
|
4447
5640
|
}
|
|
4448
5641
|
catch (err) {
|
|
4449
|
-
if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses })) {
|
|
5642
|
+
if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses, expectedGeneration: genAtEntry })) {
|
|
4450
5643
|
throw err;
|
|
4451
5644
|
}
|
|
4452
5645
|
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
@@ -4464,7 +5657,7 @@ export class AgentManager {
|
|
|
4464
5657
|
}
|
|
4465
5658
|
throw err;
|
|
4466
5659
|
}
|
|
4467
|
-
const { paneId, workdir } = ensure;
|
|
5660
|
+
const { paneId, pane, workdir } = ensure;
|
|
4468
5661
|
const runner = this.createRunnerFor(agent);
|
|
4469
5662
|
const tmux = new TmuxManager(runner);
|
|
4470
5663
|
const branchManager = new BranchManager(runner);
|
|
@@ -4472,7 +5665,15 @@ export class AgentManager {
|
|
|
4472
5665
|
if (!lockState || lockState.taskId !== taskId || lockState.lockToken !== lockToken)
|
|
4473
5666
|
return false;
|
|
4474
5667
|
await assertOwner();
|
|
4475
|
-
|
|
5668
|
+
try {
|
|
5669
|
+
await this.waitForReplPromptReady(tmux, pane, agent.runtime, this.cleanComposerWaitMs, { stableIdle: true });
|
|
5670
|
+
}
|
|
5671
|
+
catch (err) {
|
|
5672
|
+
const busyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
|
|
5673
|
+
if (busyPend)
|
|
5674
|
+
throw busyPend;
|
|
5675
|
+
throw err;
|
|
5676
|
+
}
|
|
4476
5677
|
const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
|
|
4477
5678
|
const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
|
|
4478
5679
|
let reviewCheckout;
|
|
@@ -4508,12 +5709,15 @@ export class AgentManager {
|
|
|
4508
5709
|
}
|
|
4509
5710
|
await assertOwner();
|
|
4510
5711
|
if (!ensure.freshRuntime) {
|
|
4511
|
-
await this.clearRuntimeForTaskBoundary(tmux,
|
|
5712
|
+
await this.clearRuntimeForTaskBoundary(tmux, pane, agentId, agent.runtime, assertOwner);
|
|
4512
5713
|
if (ensure.skillsStale)
|
|
4513
5714
|
await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
|
|
4514
5715
|
}
|
|
4515
5716
|
}
|
|
4516
5717
|
catch (err) {
|
|
5718
|
+
const busyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
|
|
5719
|
+
if (busyPend)
|
|
5720
|
+
throw busyPend;
|
|
4517
5721
|
const message = err instanceof Error ? err.message : String(err);
|
|
4518
5722
|
const holdPhase = err instanceof DirtyWorkdirError
|
|
4519
5723
|
? 'dirty-workdir'
|
|
@@ -4658,12 +5862,20 @@ export class AgentManager {
|
|
|
4658
5862
|
let agentMarkedRunning = false;
|
|
4659
5863
|
try {
|
|
4660
5864
|
let cancelHoldWon = false;
|
|
4661
|
-
|
|
5865
|
+
let deletedDuringDispatch = false;
|
|
5866
|
+
const runningCommit = await this.agentStore.update(agentId, (existing) => {
|
|
4662
5867
|
if (isCancelCleanupHold(existing)) {
|
|
4663
5868
|
cancelHoldWon = true;
|
|
4664
5869
|
return AGENT_STORE_NOOP;
|
|
4665
5870
|
}
|
|
5871
|
+
// 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).
|
|
5872
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken
|
|
5873
|
+
|| !this.deletionGateOpen(agentId, genAtEntry)) {
|
|
5874
|
+
deletedDuringDispatch = true;
|
|
5875
|
+
return AGENT_STORE_NOOP;
|
|
5876
|
+
}
|
|
4666
5877
|
return {
|
|
5878
|
+
...existing,
|
|
4667
5879
|
id: agentId,
|
|
4668
5880
|
projectId: agent.projectId,
|
|
4669
5881
|
paneId,
|
|
@@ -4673,7 +5885,6 @@ export class AgentManager {
|
|
|
4673
5885
|
startedAt: now,
|
|
4674
5886
|
bootstrappingTaskId: taskId,
|
|
4675
5887
|
updatedAt: now,
|
|
4676
|
-
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
4677
5888
|
};
|
|
4678
5889
|
});
|
|
4679
5890
|
if (cancelHoldWon) {
|
|
@@ -4682,8 +5893,39 @@ export class AgentManager {
|
|
|
4682
5893
|
await cleanupCheckoutOrHold();
|
|
4683
5894
|
return false;
|
|
4684
5895
|
}
|
|
5896
|
+
if (deletedDuringDispatch || runningCommit !== 'committed') {
|
|
5897
|
+
console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} binding vanished or was deleted/recreated ` +
|
|
5898
|
+
`during dispatch; not marking running (no orphan state)`);
|
|
5899
|
+
return false;
|
|
5900
|
+
}
|
|
4685
5901
|
agentMarkedRunning = true;
|
|
4686
|
-
|
|
5902
|
+
// armed pass 的 fence 延伸到最终 paste:checkout//clear/artifacts 的 await 窗口里 pass 可被
|
|
5903
|
+
// push/其他 dispatcher 轮换,旧 prompt 不得注入 successor;guard 在 composer/paste/Enter
|
|
5904
|
+
// 的 task-mutation 队列槽内复核(与轮换串行),漂移即放弃投递。
|
|
5905
|
+
// cancel 只写终态不轮换 token,故 fence 须同时复核任务仍处本相位的可投递状态
|
|
5906
|
+
const passDeliverableStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
|
|
5907
|
+
const passStillArmed = opts.dispatchPassToken !== undefined
|
|
5908
|
+
? async () => {
|
|
5909
|
+
const freshTask = await this.taskStore.get(taskId);
|
|
5910
|
+
if (!freshTask || freshTask.signalToken !== opts.dispatchPassToken)
|
|
5911
|
+
return false;
|
|
5912
|
+
return passDeliverableStatuses.includes(freshTask.status);
|
|
5913
|
+
}
|
|
5914
|
+
: undefined;
|
|
5915
|
+
if (passStillArmed && !(await passStillArmed())) {
|
|
5916
|
+
console.warn(`[AgentManager] startSession[${phase}]: review pass superseded before injection for task ${taskId}; aborting paste`);
|
|
5917
|
+
return false;
|
|
5918
|
+
}
|
|
5919
|
+
const injectResult = await this.injectAndAwaitAck(tmux, pane, prompt, agentId, agent.runtime, passStillArmed);
|
|
5920
|
+
if (injectResult.aborted === true) {
|
|
5921
|
+
console.warn(`[AgentManager] startSession[${phase}]: injection fenced off (pass superseded) for task ${taskId}`);
|
|
5922
|
+
return false;
|
|
5923
|
+
}
|
|
5924
|
+
// prompt 已确认投递:只 CAS 清理本次派发那一代的登记;successor 在窗口内登记的新 pending 不受影响,
|
|
5925
|
+
// 且清理先于可失败的审计事件——事件写失败不能把已投递的 prompt 重新变回 pending
|
|
5926
|
+
if (opts.dispatchPassToken !== undefined) {
|
|
5927
|
+
this.clearPendingDispatchRetryIfMatches(taskId, { agentId, signalToken: opts.dispatchPassToken });
|
|
5928
|
+
}
|
|
4687
5929
|
try {
|
|
4688
5930
|
await this.clearBootstrapMarker(agentId, taskId);
|
|
4689
5931
|
}
|
|
@@ -4707,6 +5949,11 @@ export class AgentManager {
|
|
|
4707
5949
|
return true;
|
|
4708
5950
|
}
|
|
4709
5951
|
catch (err) {
|
|
5952
|
+
// 注入阶段的忙碌(预注入活回合检查)仍在任何按键之前:登记 pending 并保持 checkout/绑定原样,
|
|
5953
|
+
// 在途回合还在使用该工作树,park/release 会拽走它
|
|
5954
|
+
const injectBusyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
|
|
5955
|
+
if (injectBusyPend)
|
|
5956
|
+
throw injectBusyPend;
|
|
4710
5957
|
const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
|
|
4711
5958
|
if (!isAckUnknown) {
|
|
4712
5959
|
await cleanupCheckoutOrHold();
|
|
@@ -4749,7 +5996,7 @@ export class AgentManager {
|
|
|
4749
5996
|
throw err;
|
|
4750
5997
|
}
|
|
4751
5998
|
}
|
|
4752
|
-
async injectAndAwaitAck(tmux,
|
|
5999
|
+
async injectAndAwaitAck(tmux, pane, prompt, agentId, runtime, guardBeforePaste) {
|
|
4753
6000
|
const before = await this.agentStore.get(agentId);
|
|
4754
6001
|
const beforeLockToken = before?.taskId
|
|
4755
6002
|
? await this.resolveTaskLockToken(before, before.taskId)
|
|
@@ -4801,21 +6048,22 @@ export class AgentManager {
|
|
|
4801
6048
|
if (guardBeforePaste && !(await guardBeforePaste())) {
|
|
4802
6049
|
return { acked: false, composerDelivered: false, aborted: true };
|
|
4803
6050
|
}
|
|
4804
|
-
return await this.injectAndAwaitAckSteps(tmux,
|
|
6051
|
+
return await this.injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste);
|
|
4805
6052
|
}
|
|
4806
6053
|
finally {
|
|
4807
6054
|
this.compactInFlight.delete(agentId);
|
|
4808
6055
|
}
|
|
4809
6056
|
}
|
|
4810
|
-
async injectAndAwaitAckSteps(tmux,
|
|
6057
|
+
async injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste) {
|
|
6058
|
+
const paneId = pane.paneId;
|
|
4811
6059
|
// 静态忙信号(文本忙样式/working title)可能是残稿或 stale title 冒充,一律由帧活性仲裁;ready 视图覆盖 stale title。
|
|
4812
|
-
const preTitle = await tmux.readPaneTitle(
|
|
4813
|
-
const preFrame = await tmux.capturePaneById(
|
|
6060
|
+
const preTitle = await tmux.readPaneTitle(pane);
|
|
6061
|
+
const preFrame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
4814
6062
|
const staticBusy = hasOscTitleWorking(preTitle)
|
|
4815
6063
|
? (runtimeBusyCheck(preFrame, runtime) || !hasRuntimeReadyView(preFrame, runtime))
|
|
4816
6064
|
: runtimeBusyCheck(preFrame, runtime);
|
|
4817
|
-
if (staticBusy && await this.paneHasLiveTurn(tmux,
|
|
4818
|
-
throw new
|
|
6065
|
+
if (staticBusy && await this.paneHasLiveTurn(tmux, pane, runtime)) {
|
|
6066
|
+
throw new ReplNotReadyError(paneId, runtime, preFrame, `pre-inject busy check: pane ${paneId} is still running a turn; dispatch aborted`);
|
|
4819
6067
|
}
|
|
4820
6068
|
await revalidate?.();
|
|
4821
6069
|
if (guardBeforePaste) {
|
|
@@ -4830,7 +6078,7 @@ export class AgentManager {
|
|
|
4830
6078
|
pasted = await this.withTaskLock(async () => {
|
|
4831
6079
|
if (!(await guardBeforePaste()))
|
|
4832
6080
|
return false;
|
|
4833
|
-
await tmux.clearComposerDraft(
|
|
6081
|
+
await tmux.clearComposerDraft(pane);
|
|
4834
6082
|
await tmux.pasteStagedBuffer(paneId, staged.buf);
|
|
4835
6083
|
return true;
|
|
4836
6084
|
});
|
|
@@ -4847,7 +6095,7 @@ export class AgentManager {
|
|
|
4847
6095
|
}
|
|
4848
6096
|
if (bufferConsumed) {
|
|
4849
6097
|
try {
|
|
4850
|
-
await tmux.clearComposerDraft(
|
|
6098
|
+
await tmux.clearComposerDraft(pane);
|
|
4851
6099
|
}
|
|
4852
6100
|
catch (clearErr) {
|
|
4853
6101
|
console.warn(`[AgentManager] composer scrub after unknown paste outcome failed for pane ${paneId}:`, clearErr);
|
|
@@ -4866,26 +6114,26 @@ export class AgentManager {
|
|
|
4866
6114
|
}
|
|
4867
6115
|
}
|
|
4868
6116
|
else {
|
|
4869
|
-
await tmux.clearComposerDraft(
|
|
6117
|
+
await tmux.clearComposerDraft(pane);
|
|
4870
6118
|
await revalidate?.();
|
|
4871
|
-
await tmux.injectPrompt(
|
|
6119
|
+
await tmux.injectPrompt(pane, prompt, agentId);
|
|
4872
6120
|
}
|
|
4873
6121
|
let baseline;
|
|
4874
6122
|
let baselineTitle = '';
|
|
4875
6123
|
try {
|
|
4876
|
-
baseline = await tmux.captureSettledSnapshot(
|
|
4877
|
-
baselineTitle = await tmux.readPaneTitle(
|
|
6124
|
+
baseline = await tmux.captureSettledSnapshot(pane, { timeoutMs: this.dispatchSettleTimeoutMs });
|
|
6125
|
+
baselineTitle = await tmux.readPaneTitle(pane);
|
|
4878
6126
|
if (guardBeforePaste) {
|
|
4879
6127
|
// The Enter is the actual submission; it takes the same fenced queue slot.
|
|
4880
6128
|
const submitted = await this.withTaskLock(async () => {
|
|
4881
6129
|
if (!(await guardBeforePaste()))
|
|
4882
6130
|
return false;
|
|
4883
|
-
await tmux.sendEnter(
|
|
6131
|
+
await tmux.sendEnter(pane);
|
|
4884
6132
|
return true;
|
|
4885
6133
|
});
|
|
4886
6134
|
if (!submitted) {
|
|
4887
6135
|
try {
|
|
4888
|
-
await tmux.clearComposerDraft(
|
|
6136
|
+
await tmux.clearComposerDraft(pane);
|
|
4889
6137
|
}
|
|
4890
6138
|
catch (err) {
|
|
4891
6139
|
// Fail closed: a submit-ready stale prompt could not be confirmed gone.
|
|
@@ -4896,18 +6144,18 @@ export class AgentManager {
|
|
|
4896
6144
|
}
|
|
4897
6145
|
}
|
|
4898
6146
|
else {
|
|
4899
|
-
await tmux.sendEnter(
|
|
6147
|
+
await tmux.sendEnter(pane);
|
|
4900
6148
|
}
|
|
4901
6149
|
}
|
|
4902
6150
|
catch (preAckErr) {
|
|
4903
|
-
if (await this.clearComposerForReuse(tmux,
|
|
6151
|
+
if (await this.clearComposerForReuse(tmux, pane, agentId))
|
|
4904
6152
|
throw preAckErr;
|
|
4905
6153
|
const message = preAckErr instanceof Error ? preAckErr.message : String(preAckErr);
|
|
4906
6154
|
throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on live pane ${paneId}: ${message}`);
|
|
4907
6155
|
}
|
|
4908
6156
|
let staleDuringResend = false;
|
|
4909
6157
|
try {
|
|
4910
|
-
await tmux.waitSubmitAck(
|
|
6158
|
+
await tmux.waitSubmitAck(pane, baseline, runtime, {
|
|
4911
6159
|
timeoutMs: this.dispatchAckTimeoutMs,
|
|
4912
6160
|
baselineTitle,
|
|
4913
6161
|
resend: guardBeforePaste
|
|
@@ -4916,13 +6164,13 @@ export class AgentManager {
|
|
|
4916
6164
|
const resent = await this.withTaskLock(async () => {
|
|
4917
6165
|
if (!(await guardBeforePaste()))
|
|
4918
6166
|
return false;
|
|
4919
|
-
await tmux.sendEnter(
|
|
6167
|
+
await tmux.sendEnter(pane);
|
|
4920
6168
|
return true;
|
|
4921
6169
|
});
|
|
4922
6170
|
if (!resent)
|
|
4923
6171
|
staleDuringResend = true;
|
|
4924
6172
|
}
|
|
4925
|
-
: () => tmux.sendEnter(
|
|
6173
|
+
: () => tmux.sendEnter(pane),
|
|
4926
6174
|
resendIntervalMs: this.dispatchAckResendIntervalMs,
|
|
4927
6175
|
});
|
|
4928
6176
|
return { acked: true, composerDelivered: true };
|
|
@@ -4934,7 +6182,7 @@ export class AgentManager {
|
|
|
4934
6182
|
}
|
|
4935
6183
|
if (staleDuringResend) {
|
|
4936
6184
|
try {
|
|
4937
|
-
await tmux.clearComposerDraft(
|
|
6185
|
+
await tmux.clearComposerDraft(pane);
|
|
4938
6186
|
}
|
|
4939
6187
|
catch (scrubErr) {
|
|
4940
6188
|
const scrubMessage = scrubErr instanceof Error ? scrubErr.message : String(scrubErr);
|
|
@@ -4956,9 +6204,10 @@ export class AgentManager {
|
|
|
4956
6204
|
return { acked: false, composerDelivered };
|
|
4957
6205
|
}
|
|
4958
6206
|
}
|
|
4959
|
-
async clearComposerForReuse(tmux,
|
|
6207
|
+
async clearComposerForReuse(tmux, pane, agentId) {
|
|
6208
|
+
const paneId = pane.paneId;
|
|
4960
6209
|
try {
|
|
4961
|
-
await tmux.clearComposerDraft(
|
|
6210
|
+
await tmux.clearComposerDraft(pane);
|
|
4962
6211
|
return true;
|
|
4963
6212
|
}
|
|
4964
6213
|
catch (err) {
|
|
@@ -5035,7 +6284,7 @@ export class AgentManager {
|
|
|
5035
6284
|
}
|
|
5036
6285
|
throw err;
|
|
5037
6286
|
}
|
|
5038
|
-
const { paneId } = ensure;
|
|
6287
|
+
const { paneId, pane } = ensure;
|
|
5039
6288
|
const runner = this.createRunnerFor(agent);
|
|
5040
6289
|
const tmux = new TmuxManager(runner);
|
|
5041
6290
|
const stateAfterEnsure = await this.agentStore.get(agentId);
|
|
@@ -5238,7 +6487,7 @@ export class AgentManager {
|
|
|
5238
6487
|
console.warn(`[AgentManager] continueSession[${phase}]: pre-inject guard rejected the paste for task ${taskId}; skipping`);
|
|
5239
6488
|
return false;
|
|
5240
6489
|
}
|
|
5241
|
-
const delivery = await this.injectAndAwaitAck(tmux,
|
|
6490
|
+
const delivery = await this.injectAndAwaitAck(tmux, pane, prompt, agentId, agent.runtime, guardBeforeInject);
|
|
5242
6491
|
if (delivery.aborted) {
|
|
5243
6492
|
console.warn(`[AgentManager] continueSession[${phase}]: pre-paste guard rejected the inject for task ${taskId}; skipping`);
|
|
5244
6493
|
return false;
|
|
@@ -5385,6 +6634,13 @@ export class AgentManager {
|
|
|
5385
6634
|
}
|
|
5386
6635
|
}
|
|
5387
6636
|
async recover() {
|
|
6637
|
+
// 'git' outbox 重投先行:崩溃于「效果已写、通知未达」的窗口由此闭合(spec §6),legacy 任务零涉
|
|
6638
|
+
await this.flushGitOutbox().catch(err => {
|
|
6639
|
+
console.warn('[AgentManager] recover: git outbox flush failed:', err);
|
|
6640
|
+
});
|
|
6641
|
+
await this.retryGitRemoteBranchCleanup().catch(err => {
|
|
6642
|
+
console.warn('[AgentManager] recover: git remote branch cleanup retry failed:', err);
|
|
6643
|
+
});
|
|
5388
6644
|
const states = await this.agentStore.list();
|
|
5389
6645
|
await this.releaseOrphanedLocks(states);
|
|
5390
6646
|
const deferredCleanups = [];
|
|
@@ -5423,7 +6679,7 @@ export class AgentManager {
|
|
|
5423
6679
|
continue;
|
|
5424
6680
|
if (state.creationToken && !state.taskId) {
|
|
5425
6681
|
const ct = state.creationToken;
|
|
5426
|
-
const pane = result.
|
|
6682
|
+
const pane = result.pane;
|
|
5427
6683
|
const cfg = agentConfig;
|
|
5428
6684
|
const agentId = state.id;
|
|
5429
6685
|
void (async () => {
|
|
@@ -5432,7 +6688,7 @@ export class AgentManager {
|
|
|
5432
6688
|
if (!latest || latest.creationToken !== ct)
|
|
5433
6689
|
return AGENT_STORE_NOOP;
|
|
5434
6690
|
const { creationToken: _ct, status: _s, awaitingPhase: _ap, awaitingReason: _ar, awaitingSince: _as, ...ready } = latest;
|
|
5435
|
-
return { ...ready, paneId: pane, updatedAt: new Date().toISOString() };
|
|
6691
|
+
return { ...ready, paneId: pane.paneId, updatedAt: new Date().toISOString() };
|
|
5436
6692
|
});
|
|
5437
6693
|
}
|
|
5438
6694
|
else {
|
|
@@ -5775,7 +7031,11 @@ export class AgentManager {
|
|
|
5775
7031
|
}
|
|
5776
7032
|
const project = this.getProjectConfig(result.projectId);
|
|
5777
7033
|
try {
|
|
5778
|
-
if (project) {
|
|
7034
|
+
if (project && result.reviewMode === 'git') {
|
|
7035
|
+
// close 不附评论(spec §10 有意差异);deleteBranch 由 isAutoDeletableTaskBranch 守卫
|
|
7036
|
+
await this.platformClosePr(taskId, { deleteBranch: true });
|
|
7037
|
+
}
|
|
7038
|
+
else if (project) {
|
|
5779
7039
|
const close = await execNetwork(this.platformRunner, `gh pr close ${publishedCleanup.prNumber} --repo ${shellQuote(repoSlug(project.repo))} ` +
|
|
5780
7040
|
`--comment ${shellQuote('Task cancelled in baxian; closing the published PR.')}`, GH_NET);
|
|
5781
7041
|
if (close.exitCode !== 0)
|
|
@@ -5784,6 +7044,26 @@ export class AgentManager {
|
|
|
5784
7044
|
}
|
|
5785
7045
|
catch (err) {
|
|
5786
7046
|
console.warn(`[AgentManager] cancelTask remote retirement failed for ${taskId}:`, err);
|
|
7047
|
+
if (result.reviewMode === 'git') {
|
|
7048
|
+
await this.withTaskLock(async () => {
|
|
7049
|
+
const fresh = await this.taskStore.get(taskId);
|
|
7050
|
+
if (!fresh)
|
|
7051
|
+
return;
|
|
7052
|
+
await this.taskStore.set({
|
|
7053
|
+
...fresh,
|
|
7054
|
+
branchCleanupPending: {
|
|
7055
|
+
agentId: fresh.devAgentId,
|
|
7056
|
+
reason: err instanceof PlatformCloseError && err.step === 'close'
|
|
7057
|
+
? 'remote-close-failed'
|
|
7058
|
+
: 'remote-delete-failed',
|
|
7059
|
+
updatedAt: new Date().toISOString(),
|
|
7060
|
+
},
|
|
7061
|
+
updatedAt: new Date().toISOString(),
|
|
7062
|
+
});
|
|
7063
|
+
}).catch(persistErr => {
|
|
7064
|
+
console.warn(`[AgentManager] cancelTask cleanup marker persist failed for ${taskId}:`, persistErr);
|
|
7065
|
+
});
|
|
7066
|
+
}
|
|
5787
7067
|
await this.emitIntervention(result.projectId, undefined, taskId, {
|
|
5788
7068
|
phase: 'cancel-published-artifact-cleanup-failed',
|
|
5789
7069
|
afterDone: publishedCleanup.afterDone,
|
|
@@ -5826,19 +7106,22 @@ export class AgentManager {
|
|
|
5826
7106
|
async dispatchReviewToQa(taskId, opts = {}) {
|
|
5827
7107
|
const claim = await this.withTaskLock(async () => {
|
|
5828
7108
|
if (this.manualReviewInFlight.has(taskId)) {
|
|
5829
|
-
throw new ApiError(409, `Manual review already in progress for task ${taskId}
|
|
7109
|
+
throw new ApiError(409, `Manual review already in progress for task ${taskId}`, 'dispatch-in-flight');
|
|
5830
7110
|
}
|
|
5831
7111
|
if (this.markCompleteInFlight.has(taskId)) {
|
|
5832
|
-
throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly
|
|
7112
|
+
throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`, 'dispatch-in-flight');
|
|
5833
7113
|
}
|
|
5834
7114
|
const task = await this.taskStore.get(taskId);
|
|
5835
7115
|
if (!task)
|
|
5836
7116
|
throw new ApiError(404, `Task ${taskId} not found`);
|
|
5837
7117
|
if (opts.fromStatus && !opts.fromStatus.includes(task.status)) {
|
|
5838
|
-
throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}
|
|
7118
|
+
throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
|
|
5839
7119
|
}
|
|
5840
7120
|
if (opts.expectSignalToken !== undefined && task.signalToken !== opts.expectSignalToken) {
|
|
5841
|
-
throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting
|
|
7121
|
+
throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`, 'dispatch-superseded');
|
|
7122
|
+
}
|
|
7123
|
+
if (opts.expectReviewDispatchPending === true && task.reviewDispatchPending !== true) {
|
|
7124
|
+
throw new ApiError(409, `Task ${taskId} review dispatch pending was already consumed; aborting stale sweep`, 'dispatch-superseded');
|
|
5842
7125
|
}
|
|
5843
7126
|
if (task.reviewMode === 'server' || isSpecStagePhase(task.phase)) {
|
|
5844
7127
|
if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
|
|
@@ -5879,17 +7162,33 @@ export class AgentManager {
|
|
|
5879
7162
|
throw new ApiError(409, `Task ${taskId} QA participant ${qaId} is unavailable`);
|
|
5880
7163
|
}
|
|
5881
7164
|
this.manualReviewInFlight.add(taskId);
|
|
5882
|
-
return {
|
|
7165
|
+
return {
|
|
7166
|
+
mode: 'github',
|
|
7167
|
+
qaId,
|
|
7168
|
+
devAgentId: task.agentId,
|
|
7169
|
+
taskStatusAtClaim: task.status,
|
|
7170
|
+
signalTokenAtClaim: task.signalToken,
|
|
7171
|
+
reviewRoundAtClaim: task.reviewRound,
|
|
7172
|
+
};
|
|
5883
7173
|
});
|
|
5884
7174
|
if (claim.mode === 'server') {
|
|
5885
7175
|
return this.runManualServerReview(taskId, claim);
|
|
5886
7176
|
}
|
|
5887
7177
|
try {
|
|
5888
|
-
const { qaId, devAgentId, taskStatusAtClaim } = claim;
|
|
7178
|
+
const { qaId, devAgentId, taskStatusAtClaim, signalTokenAtClaim, reviewRoundAtClaim } = claim;
|
|
5889
7179
|
const isTerminal = TERMINAL_STATUSES.includes(taskStatusAtClaim);
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
7180
|
+
// 相位恢复归标准入口自己(手工 review API 与各补派方一视同仁):同代 pending 登记的
|
|
7181
|
+
// 原始相位最权威;重启丢登记时以持久化 round=0 兜底——轮次送达才入账,故 0 即首评从未送达
|
|
7182
|
+
const pendingEntry = this.getPendingDispatchRetry(taskId);
|
|
7183
|
+
const queuedPhase = pendingEntry?.kind === 'qa-recheck'
|
|
7184
|
+
&& pendingEntry.signalToken === signalTokenAtClaim
|
|
7185
|
+
? pendingEntry.qaPhase
|
|
7186
|
+
: undefined;
|
|
7187
|
+
const qaPhase = opts.qaPhase
|
|
7188
|
+
?? queuedPhase
|
|
7189
|
+
?? (taskStatusAtClaim === 'pending' || taskStatusAtClaim === 'in_progress' || reviewRoundAtClaim === 0
|
|
7190
|
+
? 'review'
|
|
7191
|
+
: 'recheck');
|
|
5893
7192
|
const prevQa = await this.agentStore.get(qaId);
|
|
5894
7193
|
const resumingHeldDispatch = prevQa?.taskId === taskId && prevQa.status === 'awaiting_human';
|
|
5895
7194
|
if (prevQa?.taskId === taskId) {
|
|
@@ -5914,12 +7213,13 @@ export class AgentManager {
|
|
|
5914
7213
|
throw new ApiError(409, `QA agent ${qaId} could not be released from task ${taskId}${holdNote}; review dispatch aborted`);
|
|
5915
7214
|
}
|
|
5916
7215
|
}
|
|
5917
|
-
|
|
7216
|
+
let acquiredLockToken;
|
|
7217
|
+
const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase, {
|
|
7218
|
+
onAcquired: (lockToken) => { acquiredLockToken = lockToken; },
|
|
7219
|
+
});
|
|
5918
7220
|
if (!acquired) {
|
|
5919
7221
|
throw new ApiError(409, `QA agent ${qaId} is busy or unavailable`);
|
|
5920
7222
|
}
|
|
5921
|
-
const acquiredClaim = await this.lockManager.claimOf(qaId);
|
|
5922
|
-
const acquiredLockToken = acquiredClaim?.taskId === taskId ? acquiredClaim.token : undefined;
|
|
5923
7223
|
const releaseOwnAcquire = acquiredLockToken !== undefined
|
|
5924
7224
|
? { expectedLockToken: acquiredLockToken }
|
|
5925
7225
|
: {};
|
|
@@ -5946,22 +7246,64 @@ export class AgentManager {
|
|
|
5946
7246
|
signalToken: pre?.signalToken,
|
|
5947
7247
|
reviewHeadAnchorSha: pre?.reviewHeadAnchorSha,
|
|
5948
7248
|
reviewDispatchedAt: pre?.reviewDispatchedAt,
|
|
7249
|
+
reviewRoundPending: pre?.reviewRoundPending,
|
|
7250
|
+
passToken: pre?.passToken,
|
|
7251
|
+
failToken: pre?.failToken,
|
|
5949
7252
|
};
|
|
5950
7253
|
const isTerminalAtClaim = TERMINAL_STATUSES.includes(taskStatusAtClaim);
|
|
5951
|
-
// 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass
|
|
5952
|
-
|
|
7254
|
+
// 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮。
|
|
7255
|
+
// 显式 bumpRound:true 是补派方读到 reviewRoundPending 后的权威意图(该轮从未计数),越过恢复期抑制
|
|
7256
|
+
const bumpRound = opts.bumpRound === true
|
|
7257
|
+
|| (opts.bumpRound !== false && !(resumingHeldDispatch && taskStatusAtClaim === 'review'));
|
|
5953
7258
|
// 回滚在锁内判定 pass 是否已被并发接管并返回;仅未被接管时才释放本次 acquire 的 QA,
|
|
5954
7259
|
// 否则会误清接管方 re-acquire 的同一 QA 绑定(同 task 同 agent,绕过 release 的 mismatch 保护)
|
|
5955
7260
|
const abortDispatch = async (dispatchToken) => {
|
|
5956
|
-
const takenOver = await this.rollbackDispatchReviewPhase1(taskId, taskStatusAtClaim, isTerminalAtClaim, snapshot,
|
|
7261
|
+
const takenOver = await this.rollbackDispatchReviewPhase1(taskId, taskStatusAtClaim, isTerminalAtClaim, snapshot, dispatchToken);
|
|
5957
7262
|
if (!takenOver)
|
|
5958
|
-
await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
|
|
7263
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
|
|
5959
7264
|
if (devParked)
|
|
5960
7265
|
await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
|
|
5961
7266
|
};
|
|
5962
7267
|
let transitionToken;
|
|
7268
|
+
let reviewAnchor;
|
|
5963
7269
|
if (!isTerminalAtClaim) {
|
|
5964
|
-
|
|
7270
|
+
let gitDispatchBlocked;
|
|
7271
|
+
const anchorViaDriver = async () => {
|
|
7272
|
+
const preTask = await this.taskStore.get(taskId);
|
|
7273
|
+
if (preTask?.prNumber === undefined) {
|
|
7274
|
+
gitDispatchBlocked = 'no bound PR';
|
|
7275
|
+
return undefined;
|
|
7276
|
+
}
|
|
7277
|
+
// 与收编/pr-fixed/merge 同一完整谓词:closed/draft/retarget 的 PR 派出去只会造出
|
|
7278
|
+
// 不可裁决的评审轮(spec §6 统一谓词)
|
|
7279
|
+
const verify = await this.platformVerifyPrBinding(taskId, preTask.prNumber);
|
|
7280
|
+
if (!verify.ok) {
|
|
7281
|
+
gitDispatchBlocked = `binding ${verify.reason}`;
|
|
7282
|
+
return undefined;
|
|
7283
|
+
}
|
|
7284
|
+
return PLATFORM_HEAD_SHA_RE.test(verify.headSha) ? verify.headSha : undefined;
|
|
7285
|
+
};
|
|
7286
|
+
const isGitDispatch = (await this.taskStore.get(taskId))?.reviewMode === 'git';
|
|
7287
|
+
reviewAnchor = await (isGitDispatch ? anchorViaDriver() : this.fetchPrHeadSha(taskId))
|
|
7288
|
+
.catch(() => undefined);
|
|
7289
|
+
if (isGitDispatch && reviewAnchor === undefined) {
|
|
7290
|
+
// 无锚评审轮不可裁决(verdictEligible 要求 anchorSha 且 pane verdict 已退役):
|
|
7291
|
+
// 派出去只会卡死在 review,fail loud 让调用方看到确定性原因或重试瞬态失败
|
|
7292
|
+
const released = await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => false);
|
|
7293
|
+
if (!released) {
|
|
7294
|
+
// 预检失败叠加释放失败:QA 仍绑定会让下一次派发报 busy,必须显式暴露
|
|
7295
|
+
const blockedTask = await this.taskStore.get(taskId);
|
|
7296
|
+
await this.emitIntervention(blockedTask?.projectId ?? '', qaId, taskId, {
|
|
7297
|
+
phase: 'qa-release-failed-after-precheck',
|
|
7298
|
+
qaAgentId: qaId,
|
|
7299
|
+
}).catch(() => undefined);
|
|
7300
|
+
}
|
|
7301
|
+
if (devParked)
|
|
7302
|
+
await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
|
|
7303
|
+
throw gitDispatchBlocked !== undefined
|
|
7304
|
+
? new ApiError(409, `Cannot dispatch git review for ${taskId}: ${gitDispatchBlocked}`)
|
|
7305
|
+
: new ApiError(502, `Cannot anchor the review round for ${taskId}: platform prView failed`);
|
|
7306
|
+
}
|
|
5965
7307
|
const preDispatched = await this.transitionTaskStatus(taskId, 'review', {
|
|
5966
7308
|
fromStatus: [taskStatusAtClaim],
|
|
5967
7309
|
// 入口 claim 到此处之间 pass 可能被并发轮换;expect token 必须与写入同一原子段复核
|
|
@@ -5970,13 +7312,18 @@ export class AgentManager {
|
|
|
5970
7312
|
reviewHeadAnchorSha: reviewAnchor,
|
|
5971
7313
|
reviewDispatchedAt: new Date().toISOString(),
|
|
5972
7314
|
signalToken: createSignalToken(),
|
|
7315
|
+
// 未计轮 intent 与进入 review 同一原子写:两写之间崩溃会留下「有派发可重放、却无轮次可补计」
|
|
7316
|
+
// 的状态,恢复后首评仍停在旧轮
|
|
7317
|
+
...(bumpRound ? { reviewRoundPending: true } : {}),
|
|
7318
|
+
// pair/pending 与新轮锚点同一原子写可见(spec §7):两写之间崩溃不得留下无 pending 的新轮
|
|
7319
|
+
...(isGitDispatch ? { ...this.mintReviewTokenPair(), reviewDispatchPending: true } : {}),
|
|
5973
7320
|
});
|
|
5974
7321
|
if (!preDispatched) {
|
|
5975
7322
|
// fence 拒绝可能源于 successor 已换代并 re-acquire 同一 QA;释放必须钉在本次 acquire 的锁代上
|
|
5976
7323
|
await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
|
|
5977
7324
|
if (devParked)
|
|
5978
7325
|
await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
|
|
5979
|
-
throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review
|
|
7326
|
+
throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review`, 'dispatch-superseded');
|
|
5980
7327
|
}
|
|
5981
7328
|
transitionToken = preDispatched.task.signalToken;
|
|
5982
7329
|
}
|
|
@@ -5990,10 +7337,18 @@ export class AgentManager {
|
|
|
5990
7337
|
if (!isTerminalAtClaim && (fresh.status !== 'review' || fresh.signalToken !== transitionToken)) {
|
|
5991
7338
|
return { ok: false, reason: fresh.status !== 'review' ? `status=${fresh.status}` : 'pass superseded' };
|
|
5992
7339
|
}
|
|
7340
|
+
// 并发 push 在 prView 读取与本提交之间前进 head 时,本次手动锚点已陈旧(TOCTOU)
|
|
7341
|
+
if (fresh.reviewMode === 'git' && fresh.latestHeadSha !== undefined
|
|
7342
|
+
&& reviewAnchor !== undefined && fresh.latestHeadSha !== reviewAnchor) {
|
|
7343
|
+
return { ok: false, reason: 'head advanced past the manual anchor' };
|
|
7344
|
+
}
|
|
5993
7345
|
await this.taskStore.set({
|
|
5994
7346
|
...fresh,
|
|
5995
|
-
|
|
7347
|
+
// 计轮送达时才落账:claim 段只持久化「未计轮」intent(与 push 直派链路同构),
|
|
7348
|
+
// busy/hold/崩溃等一切未送达出口都保留 intent,任何补派方(含重启后)恰好补计一次
|
|
7349
|
+
...(bumpRound ? { reviewRoundPending: true } : {}),
|
|
5996
7350
|
signalToken: armedToken,
|
|
7351
|
+
...(fresh.reviewMode === 'git' ? { reviewDispatchPending: true } : {}),
|
|
5997
7352
|
updatedAt: new Date().toISOString(),
|
|
5998
7353
|
});
|
|
5999
7354
|
return { ok: true };
|
|
@@ -6001,9 +7356,14 @@ export class AgentManager {
|
|
|
6001
7356
|
if (!claim2.ok) {
|
|
6002
7357
|
// 未写入 armedToken:task 仍持 transitionToken,或已被接管方 rotate/漂移 → 用 transitionToken 判 takeover
|
|
6003
7358
|
await abortDispatch(transitionToken);
|
|
6004
|
-
throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})
|
|
7359
|
+
throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})`, 'dispatch-superseded');
|
|
7360
|
+
}
|
|
7361
|
+
opts.onPassArmed?.(armedToken);
|
|
7362
|
+
const dispatchTaskAfterClaim = await this.taskStore.get(taskId);
|
|
7363
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken, dispatchTaskAfterClaim?.reviewMode === 'git' ? { replaceScope: 'agent' } : {});
|
|
7364
|
+
if (armed && dispatchTaskAfterClaim?.reviewMode === 'git') {
|
|
7365
|
+
await this.rearmGitReconciliationWatcher(taskId);
|
|
6005
7366
|
}
|
|
6006
|
-
const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken);
|
|
6007
7367
|
if (!armed) {
|
|
6008
7368
|
await abortDispatch(armedToken);
|
|
6009
7369
|
throw new ApiError(500, `Failed to arm review verdict watcher for ${taskId}`);
|
|
@@ -6013,7 +7373,7 @@ export class AgentManager {
|
|
|
6013
7373
|
const preStart = await this.taskStore.get(taskId);
|
|
6014
7374
|
if (!preStart || !opts.fromStatus.includes(preStart.status) || preStart.signalToken !== armedToken) {
|
|
6015
7375
|
await abortDispatch(armedToken);
|
|
6016
|
-
throw new ApiError(409, `Task ${taskId} left ${opts.fromStatus.join('/')} during dispatch (now ${preStart?.status ?? 'gone'})
|
|
7376
|
+
throw new ApiError(409, `Task ${taskId} left ${opts.fromStatus.join('/')} during dispatch (now ${preStart?.status ?? 'gone'})`, 'dispatch-superseded');
|
|
6017
7377
|
}
|
|
6018
7378
|
}
|
|
6019
7379
|
let started = false;
|
|
@@ -6021,6 +7381,8 @@ export class AgentManager {
|
|
|
6021
7381
|
started = await this.startSession(taskId, qaId, qaPhase, {
|
|
6022
7382
|
bypassTaskStatusGate: true,
|
|
6023
7383
|
dialogFailFromStatuses: [isTerminalAtClaim ? taskStatusAtClaim : 'review'],
|
|
7384
|
+
dispatchPassToken: armedToken,
|
|
7385
|
+
...(opts.pendingBudget !== undefined ? { dispatchPendingBudget: opts.pendingBudget } : {}),
|
|
6024
7386
|
});
|
|
6025
7387
|
}
|
|
6026
7388
|
catch (err) {
|
|
@@ -6034,6 +7396,13 @@ export class AgentManager {
|
|
|
6034
7396
|
}
|
|
6035
7397
|
throw err;
|
|
6036
7398
|
}
|
|
7399
|
+
if (err instanceof EnsureSessionError && err.partial.busyPending) {
|
|
7400
|
+
// 遇忙不算失败:pass 保持 armed、QA 保持绑定、未计轮 intent 原样在库(送达时才消费),
|
|
7401
|
+
// 由对账按 pending 登记补派
|
|
7402
|
+
console.warn(`[AgentManager] dispatchReviewToQa: QA busy for ${taskId}; dispatch queued (${err.message})`);
|
|
7403
|
+
const pending = await this.taskStore.get(taskId);
|
|
7404
|
+
return pending;
|
|
7405
|
+
}
|
|
6037
7406
|
if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
6038
7407
|
await abortDispatch(armedToken);
|
|
6039
7408
|
}
|
|
@@ -6043,6 +7412,8 @@ export class AgentManager {
|
|
|
6043
7412
|
await abortDispatch(armedToken);
|
|
6044
7413
|
throw new ApiError(500, `Failed to start QA review session for ${taskId}`);
|
|
6045
7414
|
}
|
|
7415
|
+
await this.consumeReviewRoundIntent(taskId, armedToken);
|
|
7416
|
+
await this.clearReviewDispatchPending(taskId, armedToken);
|
|
6046
7417
|
const final = await this.taskStore.get(taskId);
|
|
6047
7418
|
return final;
|
|
6048
7419
|
}
|
|
@@ -6182,7 +7553,7 @@ export class AgentManager {
|
|
|
6182
7553
|
// 返回 qaTakenOverInReview:仍在 review 但 token 已换代 = 另一 dispatcher 已 release+acquire 同一 QA
|
|
6183
7554
|
// (同 task 同 agent,releaseAgentForTask 的 mismatch 保护不设防),此时调用方不得再 release QA。
|
|
6184
7555
|
// 判定在回滚同一锁段内基于 fresh 完成,是那一刻的权威结论——不受本函数随后恢复 snapshot token 的影响。
|
|
6185
|
-
async rollbackDispatchReviewPhase1(taskId, originalStatus, isTerminalAtClaim, snapshot,
|
|
7556
|
+
async rollbackDispatchReviewPhase1(taskId, originalStatus, isTerminalAtClaim, snapshot, dispatchToken) {
|
|
6186
7557
|
let qaTakenOverInReview = false;
|
|
6187
7558
|
await this.withTaskLock(async () => {
|
|
6188
7559
|
const fresh = await this.taskStore.get(taskId);
|
|
@@ -6192,24 +7563,25 @@ export class AgentManager {
|
|
|
6192
7563
|
if (!isTerminalAtClaim && TERMINAL_STATUSES.includes(fresh.status))
|
|
6193
7564
|
return;
|
|
6194
7565
|
// transition 后的中间态恒为 review 且持有本次 dispatch 的 token;status 漂移(如 REQUEST_CHANGES→fixing)
|
|
6195
|
-
// 或 review 内 token 换代(push recheck 接管)都表示 pass
|
|
7566
|
+
// 或 review 内 token 换代(push recheck 接管)都表示 pass 已归并发推进方所有。
|
|
7567
|
+
// 计轮送达时才落账,claim 段无轮次残留可撤——drift 后不覆盖接管方的任何写入
|
|
6196
7568
|
const passTakenOver = dispatchToken !== undefined && fresh.signalToken !== dispatchToken;
|
|
6197
7569
|
qaTakenOverInReview = !isTerminalAtClaim && fresh.status === 'review' && passTakenOver;
|
|
6198
7570
|
const drifted = !isTerminalAtClaim && (fresh.status !== 'review' || passTakenOver);
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
const
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
:
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
if (!isTerminalAtClaim
|
|
7571
|
+
if (drifted)
|
|
7572
|
+
return;
|
|
7573
|
+
const next = {
|
|
7574
|
+
...fresh,
|
|
7575
|
+
qaAgentId: snapshot.qaAgentId,
|
|
7576
|
+
signalToken: snapshot.signalToken,
|
|
7577
|
+
reviewDispatchedAt: snapshot.reviewDispatchedAt,
|
|
7578
|
+
reviewRoundPending: snapshot.reviewRoundPending,
|
|
7579
|
+
// 本代新铸的 pair 从未交付 QA:不随快照恢复会让旧 QA 的在途 verdict 被拒、新 pair 无人知
|
|
7580
|
+
passToken: snapshot.passToken,
|
|
7581
|
+
failToken: snapshot.failToken,
|
|
7582
|
+
updatedAt: new Date().toISOString(),
|
|
7583
|
+
};
|
|
7584
|
+
if (!isTerminalAtClaim) {
|
|
6213
7585
|
next.status = originalStatus;
|
|
6214
7586
|
next.reviewHeadAnchorSha = snapshot.reviewHeadAnchorSha;
|
|
6215
7587
|
}
|
|
@@ -6224,8 +7596,10 @@ export class AgentManager {
|
|
|
6224
7596
|
return qaTakenOverInReview;
|
|
6225
7597
|
}
|
|
6226
7598
|
if (afterRollback?.status === 'approved') {
|
|
6227
|
-
const completion =
|
|
6228
|
-
|
|
7599
|
+
const completion = afterRollback.reviewMode === 'git'
|
|
7600
|
+
? this.gitCompletionOf(afterRollback)
|
|
7601
|
+
: await this.postApproveStore.get(taskId);
|
|
7602
|
+
const task = afterRollback;
|
|
6229
7603
|
if (completion && task) {
|
|
6230
7604
|
try {
|
|
6231
7605
|
await this.startPhaseSignalWatch({
|
|
@@ -6246,21 +7620,30 @@ export class AgentManager {
|
|
|
6246
7620
|
const restored = await this.taskStore.get(taskId);
|
|
6247
7621
|
if (!restored || !restored.signalToken)
|
|
6248
7622
|
return qaTakenOverInReview;
|
|
6249
|
-
const
|
|
6250
|
-
if (
|
|
7623
|
+
const mappings = this.mapTaskStateToExpectedWatchers(restored);
|
|
7624
|
+
if (mappings.length === 0)
|
|
6251
7625
|
return qaTakenOverInReview;
|
|
7626
|
+
for (const mapped of mappings) {
|
|
7627
|
+
try {
|
|
7628
|
+
await this.startPhaseSignalWatch({
|
|
7629
|
+
taskId,
|
|
7630
|
+
projectId: restored.projectId,
|
|
7631
|
+
agentId: mapped.agentId,
|
|
7632
|
+
expectedKinds: mapped.expectedKinds,
|
|
7633
|
+
token: mapped.token ?? restored.signalToken,
|
|
7634
|
+
...(mappings.length > 1 ? { replaceScope: 'agent' } : {}),
|
|
7635
|
+
});
|
|
7636
|
+
}
|
|
7637
|
+
catch (err) {
|
|
7638
|
+
console.warn(`[AgentManager] rollback: re-establish ${mapped.expectedKinds.join(',')} failed for task=${taskId}:`, err);
|
|
7639
|
+
}
|
|
7640
|
+
}
|
|
7641
|
+
// 回滚路径不得因 tearDown 探测失败上抛:那会顶替调用方本应抛出的 ApiError 并跳过 QA 释放
|
|
6252
7642
|
try {
|
|
6253
|
-
await this.startPhaseSignalWatch({
|
|
6254
|
-
taskId,
|
|
6255
|
-
projectId: restored.projectId,
|
|
6256
|
-
agentId: mapped.agentId,
|
|
6257
|
-
expectedKinds: mapped.expectedKinds,
|
|
6258
|
-
token: restored.signalToken,
|
|
6259
|
-
});
|
|
6260
7643
|
await this.tearDownWatcherIfTaskTerminal(taskId);
|
|
6261
7644
|
}
|
|
6262
7645
|
catch (err) {
|
|
6263
|
-
console.warn(`[AgentManager] rollback:
|
|
7646
|
+
console.warn(`[AgentManager] rollback: terminal watcher teardown failed for task=${taskId}:`, err);
|
|
6264
7647
|
}
|
|
6265
7648
|
return qaTakenOverInReview;
|
|
6266
7649
|
}
|
|
@@ -6282,6 +7665,22 @@ export class AgentManager {
|
|
|
6282
7665
|
return { phase: 'research', kinds: ['spec-done'] };
|
|
6283
7666
|
return { phase: 'develop', kinds: this.devInitialSignalKinds(task.reviewMode) };
|
|
6284
7667
|
}
|
|
7668
|
+
mapTaskStateToExpectedWatchers(task) {
|
|
7669
|
+
const single = this.mapTaskStateToExpectedWatcher(task);
|
|
7670
|
+
const mappings = single ? [single] : [];
|
|
7671
|
+
// review 窗口内 dev/QA 双条目并存:review 之外 dev 侧另有 pr-fixed/pr-merge-ready 同 agent
|
|
7672
|
+
// 期望,(taskId, agentId) 基数无法同 agent 双 token 并存——错过窗口按 fail-closed 交人工(spec §5.3 ④)
|
|
7673
|
+
if (task.reviewMode === 'git' && task.status === 'review'
|
|
7674
|
+
&& task.replyActorStatus !== 'verified' && task.pendingPrSignalToken !== undefined
|
|
7675
|
+
&& task.devAgentId && task.prNumber !== undefined) {
|
|
7676
|
+
mappings.push({
|
|
7677
|
+
expectedKinds: ['pr-created'],
|
|
7678
|
+
agentId: task.devAgentId,
|
|
7679
|
+
token: task.pendingPrSignalToken,
|
|
7680
|
+
});
|
|
7681
|
+
}
|
|
7682
|
+
return mappings;
|
|
7683
|
+
}
|
|
6285
7684
|
mapTaskStateToExpectedWatcher(task) {
|
|
6286
7685
|
if (task.reviewMode === 'server')
|
|
6287
7686
|
return this.mapServerTaskToExpectedWatcher(task);
|
|
@@ -6334,8 +7733,9 @@ export class AgentManager {
|
|
|
6334
7733
|
const task = await this.taskStore.get(taskId);
|
|
6335
7734
|
if (!task?.signalToken)
|
|
6336
7735
|
return false;
|
|
6337
|
-
return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, {
|
|
7736
|
+
return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, opts.tokenOverride ?? task.signalToken, {
|
|
6338
7737
|
skipSnapshot: opts.skipSnapshot ?? false,
|
|
7738
|
+
...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
|
|
6339
7739
|
});
|
|
6340
7740
|
}
|
|
6341
7741
|
async emitManualReviewDevParkedQaFailedIntervention(agentId, expectedTaskId) {
|
|
@@ -6443,6 +7843,17 @@ export class AgentManager {
|
|
|
6443
7843
|
if (!project) {
|
|
6444
7844
|
throw new Error(`mergePr: unknown project ${task.projectId}`);
|
|
6445
7845
|
}
|
|
7846
|
+
if (task.reviewMode === 'git') {
|
|
7847
|
+
const expectedHeadSha = opts.matchHeadSha ?? task.latestHeadSha;
|
|
7848
|
+
if (!expectedHeadSha)
|
|
7849
|
+
throw new Error(`mergePr: no expected head sha for git task ${taskId}`);
|
|
7850
|
+
// max_rounds 的 Mark complete 在调用前已被转成 merge-ready:override 必须显式随调用链传入
|
|
7851
|
+
await this.platformConfirmMerge(taskId, {
|
|
7852
|
+
expectedHeadSha,
|
|
7853
|
+
...(opts.humanOverride === true || task.status === 'max_rounds' ? { humanOverride: true } : {}),
|
|
7854
|
+
});
|
|
7855
|
+
return;
|
|
7856
|
+
}
|
|
6446
7857
|
const matchHead = opts.matchHeadSha
|
|
6447
7858
|
? ` --match-head-commit ${shellQuote(opts.matchHeadSha)}`
|
|
6448
7859
|
: '';
|
|
@@ -6517,7 +7928,8 @@ export class AgentManager {
|
|
|
6517
7928
|
throw new ApiError(409, `Task ${taskId} changed status during mark-complete; aborted`);
|
|
6518
7929
|
}
|
|
6519
7930
|
try {
|
|
6520
|
-
|
|
7931
|
+
// 从 max_rounds 认领而来:人工兜底 override 必须显式随调用链传入(此刻 status 已是 merge-ready)
|
|
7932
|
+
await this.mergePr(taskId, { humanOverride: true });
|
|
6521
7933
|
}
|
|
6522
7934
|
catch (err) {
|
|
6523
7935
|
await this.transitionTaskStatus(taskId, 'max_rounds', { fromStatus: ['merge-ready'] })
|
|
@@ -6567,6 +7979,9 @@ export class AgentManager {
|
|
|
6567
7979
|
const agent = this.getAgentConfig(agentId);
|
|
6568
7980
|
if (!agent)
|
|
6569
7981
|
return;
|
|
7982
|
+
// Pin the incarnation before the first state/paneId read: paneId equality is ABA-blind (a recreated
|
|
7983
|
+
// agent reuses %0), so a DELETE→same-id recreate would otherwise let post-merge /clear hit a new session.
|
|
7984
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
6570
7985
|
const state = await this.agentStore.get(agentId);
|
|
6571
7986
|
if (!state)
|
|
6572
7987
|
return;
|
|
@@ -6581,12 +7996,16 @@ export class AgentManager {
|
|
|
6581
7996
|
return;
|
|
6582
7997
|
}
|
|
6583
7998
|
if (!state.taskId) {
|
|
7999
|
+
if (this.isDeletionInFlight(agentId))
|
|
8000
|
+
return;
|
|
6584
8001
|
const lockToken = await this.lockManager.acquire(agentId, ctx.taskId);
|
|
6585
8002
|
if (!lockToken)
|
|
6586
8003
|
return;
|
|
6587
|
-
await this.agentStore.update(agentId, (existing) => {
|
|
8004
|
+
const commit = await this.agentStore.update(agentId, (existing) => {
|
|
6588
8005
|
if (!existing)
|
|
6589
8006
|
return AGENT_STORE_NOOP;
|
|
8007
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
8008
|
+
return AGENT_STORE_NOOP;
|
|
6590
8009
|
if (existing.taskId && existing.taskId !== ctx.taskId)
|
|
6591
8010
|
return AGENT_STORE_NOOP;
|
|
6592
8011
|
if (existing.creationToken)
|
|
@@ -6601,17 +8020,35 @@ export class AgentManager {
|
|
|
6601
8020
|
lockToken,
|
|
6602
8021
|
updatedAt: new Date().toISOString(),
|
|
6603
8022
|
};
|
|
8023
|
+
}).catch(async (err) => {
|
|
8024
|
+
await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken).catch((releaseErr) => {
|
|
8025
|
+
console.warn(`[AgentManager] postMergeCleanup(${agentId}): lock release after commit failure also failed:`, releaseErr);
|
|
8026
|
+
});
|
|
8027
|
+
throw err;
|
|
6604
8028
|
});
|
|
8029
|
+
if (commit !== 'committed') {
|
|
8030
|
+
await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
|
|
8031
|
+
return;
|
|
8032
|
+
}
|
|
6605
8033
|
const fresh = await this.agentStore.get(agentId);
|
|
6606
8034
|
if (fresh?.taskId !== ctx.taskId || fresh.lockToken !== lockToken) {
|
|
6607
8035
|
await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
|
|
6608
8036
|
return;
|
|
6609
8037
|
}
|
|
6610
8038
|
}
|
|
8039
|
+
// A DELETE→recreate (gen bump) before this pane work must skip it, or /clear lands on the new incarnation's reused pane.
|
|
8040
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
8041
|
+
return;
|
|
6611
8042
|
const runner = this.createRunnerFor(agent);
|
|
6612
8043
|
const tmux = new TmuxManager(runner);
|
|
6613
8044
|
const runtime = agentRuntimeKindFor(agent);
|
|
6614
|
-
|
|
8045
|
+
const expectedPaneId = state.paneId;
|
|
8046
|
+
void (async () => {
|
|
8047
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
8048
|
+
return;
|
|
8049
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, expectedPaneId);
|
|
8050
|
+
await this.runPostMergeCompaction(tmux, pane, agentId, ctx.taskId, runtime);
|
|
8051
|
+
})().catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
|
|
6615
8052
|
}
|
|
6616
8053
|
async releasePostMergeAgent(agentId, taskId) {
|
|
6617
8054
|
const state = await this.agentStore.get(agentId);
|
|
@@ -6624,10 +8061,10 @@ export class AgentManager {
|
|
|
6624
8061
|
console.warn(`[AgentManager] releasePostMergeAgent: releaseAgentForTask(${agentId}, ${taskId}) failed:`, err);
|
|
6625
8062
|
}
|
|
6626
8063
|
}
|
|
6627
|
-
async runPostMergeCompaction(tmux,
|
|
8064
|
+
async runPostMergeCompaction(tmux, pane, agentId, originalTaskId, runtime) {
|
|
6628
8065
|
await this.acquireCompactGuard(agentId);
|
|
6629
8066
|
try {
|
|
6630
|
-
await this.runPostMergeCompactionSteps(tmux,
|
|
8067
|
+
await this.runPostMergeCompactionSteps(tmux, pane, agentId, originalTaskId, runtime);
|
|
6631
8068
|
}
|
|
6632
8069
|
finally {
|
|
6633
8070
|
this.compactInFlight.delete(agentId);
|
|
@@ -6653,7 +8090,8 @@ export class AgentManager {
|
|
|
6653
8090
|
this.compactInFlight.add(agentId);
|
|
6654
8091
|
return true;
|
|
6655
8092
|
}
|
|
6656
|
-
async runPostMergeCompactionSteps(tmux,
|
|
8093
|
+
async runPostMergeCompactionSteps(tmux, pane, agentId, originalTaskId, runtime) {
|
|
8094
|
+
const paneId = pane.paneId;
|
|
6657
8095
|
const initial = await this.agentStore.get(agentId);
|
|
6658
8096
|
if (!initial || initial.taskId !== originalTaskId || initial.paneId !== paneId)
|
|
6659
8097
|
return;
|
|
@@ -6673,13 +8111,13 @@ export class AgentManager {
|
|
|
6673
8111
|
let cleared = false;
|
|
6674
8112
|
let clearError;
|
|
6675
8113
|
try {
|
|
6676
|
-
cleared = await this.sendPostMergeSlashCommand(tmux,
|
|
8114
|
+
cleared = await this.sendPostMergeSlashCommand(tmux, pane, agentId, runtime, bindingStillOurs);
|
|
6677
8115
|
}
|
|
6678
8116
|
catch (err) {
|
|
6679
8117
|
clearError = err;
|
|
6680
8118
|
}
|
|
6681
8119
|
if (!cleared) {
|
|
6682
|
-
if (await this.recoverPostMergeExitedRuntime(tmux,
|
|
8120
|
+
if (await this.recoverPostMergeExitedRuntime(tmux, pane, agentId, originalTaskId, lockToken, runtime)) {
|
|
6683
8121
|
cleared = true;
|
|
6684
8122
|
}
|
|
6685
8123
|
else if (!await bindingStillOurs()) {
|
|
@@ -6688,7 +8126,7 @@ export class AgentManager {
|
|
|
6688
8126
|
else if (clearError instanceof Error
|
|
6689
8127
|
&& clearError.message.includes('runtime rejected /clear')) {
|
|
6690
8128
|
try {
|
|
6691
|
-
await this.waitForReplPromptReady(tmux,
|
|
8129
|
+
await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
|
|
6692
8130
|
}
|
|
6693
8131
|
catch (err) {
|
|
6694
8132
|
clearError = err;
|
|
@@ -6706,10 +8144,11 @@ export class AgentManager {
|
|
|
6706
8144
|
}
|
|
6707
8145
|
await this.releasePostMergeAgent(agentId, originalTaskId);
|
|
6708
8146
|
}
|
|
6709
|
-
async recoverPostMergeExitedRuntime(tmux,
|
|
8147
|
+
async recoverPostMergeExitedRuntime(tmux, pane, agentId, taskId, lockToken, runtime) {
|
|
8148
|
+
const paneId = pane.paneId;
|
|
6710
8149
|
let paneExists = true;
|
|
6711
8150
|
try {
|
|
6712
|
-
if (hasReplProcTitle(await tmux.displayMessage(
|
|
8151
|
+
if (hasReplProcTitle(await tmux.displayMessage(pane, '#{pane_current_command}'), runtime))
|
|
6713
8152
|
return false;
|
|
6714
8153
|
}
|
|
6715
8154
|
catch (err) {
|
|
@@ -6728,7 +8167,7 @@ export class AgentManager {
|
|
|
6728
8167
|
return false;
|
|
6729
8168
|
try {
|
|
6730
8169
|
if (paneExists)
|
|
6731
|
-
await tmux.sendKeysToPane(
|
|
8170
|
+
await tmux.sendKeysToPane(pane, 'C-c');
|
|
6732
8171
|
const ensure = await this.ensureSession(agentId, 'runtime');
|
|
6733
8172
|
if (!ensure.freshRuntime)
|
|
6734
8173
|
return false;
|
|
@@ -6757,22 +8196,22 @@ export class AgentManager {
|
|
|
6757
8196
|
return false;
|
|
6758
8197
|
}
|
|
6759
8198
|
}
|
|
6760
|
-
async sendPostMergeSlashCommand(tmux,
|
|
8199
|
+
async sendPostMergeSlashCommand(tmux, pane, agentId, runtime, bindingStillOurs) {
|
|
6761
8200
|
let rejection;
|
|
6762
8201
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
6763
8202
|
if (!await bindingStillOurs())
|
|
6764
8203
|
return false;
|
|
6765
|
-
await tmux.clearComposerDraft(
|
|
6766
|
-
await this.waitForReplPromptReady(tmux,
|
|
8204
|
+
await tmux.clearComposerDraft(pane);
|
|
8205
|
+
await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
|
|
6767
8206
|
if (!await bindingStillOurs())
|
|
6768
8207
|
return false;
|
|
6769
|
-
await tmux.sendKeysLiteral(
|
|
6770
|
-
await tmux.sendEnter(
|
|
8208
|
+
await tmux.sendKeysLiteral(pane, '/clear');
|
|
8209
|
+
await tmux.sendEnter(pane);
|
|
6771
8210
|
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
6772
|
-
await this.waitForReplPromptReady(tmux,
|
|
8211
|
+
await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
|
|
6773
8212
|
if (!await bindingStillOurs())
|
|
6774
8213
|
return false;
|
|
6775
|
-
if (!await this.hasRuntimeSlashCommandRejection(tmux,
|
|
8214
|
+
if (!await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear'))
|
|
6776
8215
|
return true;
|
|
6777
8216
|
rejection = new Error('runtime rejected /clear because a task is still in progress');
|
|
6778
8217
|
if (attempt < 2) {
|
|
@@ -6782,29 +8221,33 @@ export class AgentManager {
|
|
|
6782
8221
|
}
|
|
6783
8222
|
throw rejection ?? new Error('runtime rejected /clear');
|
|
6784
8223
|
}
|
|
6785
|
-
async hasRuntimeSlashCommandRejection(tmux,
|
|
6786
|
-
const cap = await tmux.capturePaneById(
|
|
8224
|
+
async hasRuntimeSlashCommandRejection(tmux, pane, command) {
|
|
8225
|
+
const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
6787
8226
|
return this.runtimeSlashCommandRejectedPattern(command).test(cap);
|
|
6788
8227
|
}
|
|
6789
8228
|
runtimeSlashCommandRejectedPattern(command) {
|
|
6790
8229
|
return new RegExp(`["'“”‘’]?${command}["'“”‘’]?\\s+is disabled while a task is in progress\\.`, 'gi');
|
|
6791
8230
|
}
|
|
6792
|
-
async waitForReplPromptReady(tmux,
|
|
8231
|
+
async waitForReplPromptReady(tmux, pane, runtime, timeoutMs, opts = {}) {
|
|
8232
|
+
if (opts.stableIdle) {
|
|
8233
|
+
return this.waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs);
|
|
8234
|
+
}
|
|
8235
|
+
const paneId = pane.paneId;
|
|
6793
8236
|
const deadline = Date.now() + timeoutMs;
|
|
6794
|
-
await tmux.waitReplReady(
|
|
8237
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
6795
8238
|
timeoutMs,
|
|
6796
8239
|
intervalMs: this.compactIdlePollMs,
|
|
6797
8240
|
titleIdleFastPath: true,
|
|
6798
8241
|
});
|
|
6799
8242
|
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
6800
8243
|
while (true) {
|
|
6801
|
-
const current = await tmux.displayMessage(
|
|
8244
|
+
const current = await tmux.displayMessage(pane, '#{pane_current_command}');
|
|
6802
8245
|
if (!hasReplProcTitle(current, runtime)) {
|
|
6803
8246
|
throw new Error(`waitForReplPromptReady: pane ${paneId} pane_current_command=${current.trim()} (not runtime, REPL may have exited)`);
|
|
6804
8247
|
}
|
|
6805
|
-
const cap = await tmux.capturePaneById(
|
|
8248
|
+
const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
6806
8249
|
const ready = hasRuntimeReadyView(cap, runtime)
|
|
6807
|
-
|| (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(
|
|
8250
|
+
|| (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(pane), runtime));
|
|
6808
8251
|
if (detectRuntimeMenu(cap) || (!ready && detectStartupDialog(cap, runtime))) {
|
|
6809
8252
|
throw new Error(`waitForReplPromptReady: pane ${paneId} shows menu/dialog, not a ready REPL prompt`);
|
|
6810
8253
|
}
|
|
@@ -6815,11 +8258,41 @@ export class AgentManager {
|
|
|
6815
8258
|
return;
|
|
6816
8259
|
}
|
|
6817
8260
|
if (Date.now() >= deadline) {
|
|
6818
|
-
|
|
8261
|
+
// 与 stableIdle 分支同一类型:忙碌超时是「常态忙」而非故障,调用方(release/派发)
|
|
8262
|
+
// 据此延后重排;菜单/对话框/REPL 退出等仍是普通 Error,不得被当成可延后
|
|
8263
|
+
throw new ReplNotReadyError(paneId, runtime, cap, `stayed busy past ${timeoutMs}ms`);
|
|
6819
8264
|
}
|
|
6820
8265
|
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
6821
8266
|
}
|
|
6822
8267
|
}
|
|
8268
|
+
// 注入前置门:codex 忙碌回合的工具间隙会闪现单帧 idle footer(#558 事故放大器),
|
|
8269
|
+
// 连续 K 帧稳定就绪才放行,任何忙碌/不确定帧清零计数;超时按忙碌分类抛 ReplNotReadyError。
|
|
8270
|
+
async waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs) {
|
|
8271
|
+
const paneId = pane.paneId;
|
|
8272
|
+
const samples = Math.max(1, this.readyStableSamples);
|
|
8273
|
+
const spacing = this.readyStableSpacingMs;
|
|
8274
|
+
const deadline = Date.now() + timeoutMs + (samples - 1) * spacing;
|
|
8275
|
+
let streak = 0;
|
|
8276
|
+
while (true) {
|
|
8277
|
+
const current = await tmux.displayMessage(pane, '#{pane_current_command}');
|
|
8278
|
+
if (!hasReplProcTitle(current, runtime)) {
|
|
8279
|
+
throw new Error(`waitForReplPromptReady: pane ${paneId} pane_current_command=${current.trim()} (not runtime, REPL may have exited)`);
|
|
8280
|
+
}
|
|
8281
|
+
const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
8282
|
+
const ready = hasRuntimeReadyView(cap, runtime)
|
|
8283
|
+
|| (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(pane), runtime));
|
|
8284
|
+
if (detectRuntimeMenu(cap) || (!ready && detectStartupDialog(cap, runtime))) {
|
|
8285
|
+
throw new Error(`waitForReplPromptReady: pane ${paneId} shows menu/dialog, not a ready REPL prompt`);
|
|
8286
|
+
}
|
|
8287
|
+
streak = ready && !runtimeBusyCheck(cap, runtime) ? streak + 1 : 0;
|
|
8288
|
+
if (streak >= samples)
|
|
8289
|
+
return;
|
|
8290
|
+
if (Date.now() >= deadline) {
|
|
8291
|
+
throw new ReplNotReadyError(paneId, runtime, cap, `stable idle not confirmed within ${timeoutMs}ms (+${(samples - 1) * spacing}ms stability window)`);
|
|
8292
|
+
}
|
|
8293
|
+
await new Promise(r => setTimeout(r, spacing));
|
|
8294
|
+
}
|
|
8295
|
+
}
|
|
6823
8296
|
stopPhaseSignalWatcher(taskId) {
|
|
6824
8297
|
this.phaseSignalWatcher?.stop(taskId);
|
|
6825
8298
|
}
|
|
@@ -6847,6 +8320,7 @@ export class AgentManager {
|
|
|
6847
8320
|
token,
|
|
6848
8321
|
skipSnapshot: opts.skipSnapshot ?? false,
|
|
6849
8322
|
...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
|
|
8323
|
+
...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
|
|
6850
8324
|
...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
|
|
6851
8325
|
});
|
|
6852
8326
|
}
|
|
@@ -6867,10 +8341,40 @@ export class AgentManager {
|
|
|
6867
8341
|
const label = (Array.isArray(expectedKinds) ? [...expectedKinds] : [expectedKinds]).join(',');
|
|
6868
8342
|
await this.markAwaitingHuman(agentId, `signal-arm-failed:${label}`, 'Pane-signal watcher failed to arm after dispatch; the prompt expects a signal with no consumer. Cancel the task or delete the agent to retry.', { expectedTaskId: taskId });
|
|
6869
8343
|
}
|
|
8344
|
+
// git 任务的初始 dev 派发把 pr-created 期望 token 独立持久化:评审轮轮换不得使 late
|
|
8345
|
+
// publish 信号失去 reconciliation 凭据(spec §5.3 ④)。
|
|
8346
|
+
dispatchTokenFields(task, signalToken) {
|
|
8347
|
+
const wantsPrCreated = task.reviewMode === 'git'
|
|
8348
|
+
&& this.devInitialSignalKinds(task.reviewMode).includes('pr-created');
|
|
8349
|
+
return { signalToken, ...(wantsPrCreated ? { pendingPrSignalToken: signalToken } : {}) };
|
|
8350
|
+
}
|
|
8351
|
+
// review 窗口内 QA verdict arm 之后重建 dev reconciliation 条目(spec §5.3 ④):approval 路径的
|
|
8352
|
+
// stop(taskId) 与 fix 轮的同键替换都会拆掉它,重入 review 时在此恢复;skipSnapshot=false 让仍在
|
|
8353
|
+
// 面板滚动区的迟到信号立即重放。
|
|
8354
|
+
async rearmGitReconciliationWatcher(taskId, opts = {}) {
|
|
8355
|
+
const task = await this.taskStore.get(taskId);
|
|
8356
|
+
if (!task || task.reviewMode !== 'git' || task.status !== 'review')
|
|
8357
|
+
return;
|
|
8358
|
+
if (task.replyActorStatus === 'verified' || task.pendingPrSignalToken === undefined)
|
|
8359
|
+
return;
|
|
8360
|
+
if (!task.devAgentId || task.prNumber === undefined)
|
|
8361
|
+
return;
|
|
8362
|
+
await this.setupPhaseSignalWatcher(taskId, task.devAgentId, ['pr-created'], task.pendingPrSignalToken, {
|
|
8363
|
+
replaceScope: 'agent',
|
|
8364
|
+
...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
|
|
8365
|
+
}).catch(() => false);
|
|
8366
|
+
}
|
|
6870
8367
|
async rotateAndSetupPhaseSignal(taskId, agentId, expectedKinds) {
|
|
6871
8368
|
const newToken = createSignalToken();
|
|
8369
|
+
const task = await this.taskStore.get(taskId);
|
|
8370
|
+
const kinds = typeof expectedKinds === 'string' ? [expectedKinds] : [...expectedKinds];
|
|
8371
|
+
const isGit = task?.reviewMode === 'git';
|
|
8372
|
+
// pair 的重铸已随「进入 review 的 transition」单写落盘(spec §7 与新轮可见性原子);
|
|
8373
|
+
// 这里只轮换 signal token 并布防
|
|
6872
8374
|
await this.updateTask(taskId, { signalToken: newToken });
|
|
6873
|
-
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken);
|
|
8375
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, isGit ? { replaceScope: 'agent' } : {});
|
|
8376
|
+
if (armed && isGit && kinds.includes('pr-approved'))
|
|
8377
|
+
await this.rearmGitReconciliationWatcher(taskId);
|
|
6874
8378
|
return { token: newToken, armed };
|
|
6875
8379
|
}
|
|
6876
8380
|
async rollbackVerdictArmFailure(taskId, restore, opts = {}) {
|
|
@@ -6890,6 +8394,10 @@ export class AgentManager {
|
|
|
6890
8394
|
signalToken: restore.signalToken,
|
|
6891
8395
|
reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
|
|
6892
8396
|
reviewDispatchedAt: restore.reviewDispatchedAt,
|
|
8397
|
+
reviewRoundPending: restore.reviewRoundPending,
|
|
8398
|
+
...(restore.restorePair === true
|
|
8399
|
+
? { passToken: restore.passToken, failToken: restore.failToken }
|
|
8400
|
+
: {}),
|
|
6893
8401
|
updatedAt: new Date().toISOString(),
|
|
6894
8402
|
});
|
|
6895
8403
|
return true;
|
|
@@ -6907,10 +8415,16 @@ export class AgentManager {
|
|
|
6907
8415
|
return;
|
|
6908
8416
|
if (TERMINAL_STATUSES.includes(task.status))
|
|
6909
8417
|
return;
|
|
6910
|
-
const
|
|
6911
|
-
if (
|
|
8418
|
+
const mappings = this.mapTaskStateToExpectedWatchers(task);
|
|
8419
|
+
if (mappings.length === 0)
|
|
6912
8420
|
return;
|
|
6913
|
-
|
|
8421
|
+
for (const mapped of mappings) {
|
|
8422
|
+
await this.setupPhaseSignal(taskId, mapped.agentId, mapped.expectedKinds, {
|
|
8423
|
+
...(opts.skipSnapshot ? { skipSnapshot: true } : {}),
|
|
8424
|
+
...(mappings.length > 1 ? { replaceScope: 'agent' } : {}),
|
|
8425
|
+
...(mapped.token !== undefined ? { tokenOverride: mapped.token } : {}),
|
|
8426
|
+
});
|
|
8427
|
+
}
|
|
6914
8428
|
await this.tearDownWatcherIfTaskTerminal(taskId);
|
|
6915
8429
|
}
|
|
6916
8430
|
async parkTaskAtSpecReady(taskId, opts = {}) {
|
|
@@ -7328,7 +8842,7 @@ export class AgentManager {
|
|
|
7328
8842
|
const acquiredLockToken = (await this.agentStore.get(devAgentId))?.lockToken;
|
|
7329
8843
|
const newToken = createSignalToken();
|
|
7330
8844
|
// spec 阶段的扩轮决策不延续到 code 评审:cap 共享字段,进入编码时归零
|
|
7331
|
-
const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'] }, { agentId: devAgentId, phase: 'code',
|
|
8845
|
+
const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'] }, { agentId: devAgentId, phase: 'code', maxRoundsContinues: 0, ...this.dispatchTokenFields({ ...task, phase: 'code' }, newToken) });
|
|
7332
8846
|
if (!transition) {
|
|
7333
8847
|
await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
|
|
7334
8848
|
.catch(() => undefined);
|
|
@@ -7753,7 +9267,7 @@ export class AgentManager {
|
|
|
7753
9267
|
// Callers without a baseSha (publish) run long after the review diff was
|
|
7754
9268
|
// read; refresh origin/HEAD first or upstream commits merged since then
|
|
7755
9269
|
// would linger in base..HEAD and flag tasks that leak nothing.
|
|
7756
|
-
const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
|
|
9270
|
+
const fetch = await execNetwork(runner, `${canonicalSelfGuard(workdir)} && ${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
|
|
7757
9271
|
if (fetch.exitCode !== 0) {
|
|
7758
9272
|
throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
|
|
7759
9273
|
}
|
|
@@ -7884,6 +9398,8 @@ export class AgentManager {
|
|
|
7884
9398
|
const cfg = this.getAgentConfig(agentId);
|
|
7885
9399
|
if (!cfg)
|
|
7886
9400
|
throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
|
|
9401
|
+
// Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the text to the new session.
|
|
9402
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
7887
9403
|
await this.acquireCompactGuard(agentId);
|
|
7888
9404
|
try {
|
|
7889
9405
|
const state = await this.agentStore.get(agentId);
|
|
@@ -7918,8 +9434,18 @@ export class AgentManager {
|
|
|
7918
9434
|
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
7919
9435
|
}
|
|
7920
9436
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
7921
|
-
await
|
|
7922
|
-
|
|
9437
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
|
|
9438
|
+
// resolveClaimedPane resolved by the CURRENT same-name session; re-verify incarnation + binding before paste (same as the image path).
|
|
9439
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
9440
|
+
throw new Error(`injectTextToAgent: agent ${agentId} was deleted or recreated before paste`);
|
|
9441
|
+
}
|
|
9442
|
+
const afterResolve = await this.agentStore.get(agentId);
|
|
9443
|
+
if (!afterResolve || afterResolve.paneId !== paneId || afterResolve.taskId !== taskId
|
|
9444
|
+
|| (taskId !== undefined && afterResolve.lockToken !== lockToken)) {
|
|
9445
|
+
throw new Error(`injectTextToAgent: agent ${agentId} binding changed before paste`);
|
|
9446
|
+
}
|
|
9447
|
+
await tmux.injectPrompt(pane, text, agentId);
|
|
9448
|
+
await tmux.sendEnter(pane);
|
|
7923
9449
|
}
|
|
7924
9450
|
finally {
|
|
7925
9451
|
this.compactInFlight.delete(agentId);
|
|
@@ -8007,6 +9533,38 @@ export class AgentManager {
|
|
|
8007
9533
|
}
|
|
8008
9534
|
catch (err) {
|
|
8009
9535
|
const message = err instanceof Error ? err.message : String(err);
|
|
9536
|
+
if (err instanceof PlatformMergeRecheckError && task.reviewMode === 'git' && task.status === 'merge-ready') {
|
|
9537
|
+
// 复核类失效(provenance 撤销/未 ack 反馈)没有「再 Confirm」的出路:退回 approved 置
|
|
9538
|
+
// durable pending 重入 post-approve 闭环(计划 Task 8),把失效授权留在 gate 只剩 Cancel
|
|
9539
|
+
const retreated = await this.withTaskLock(async () => {
|
|
9540
|
+
const fresh = await this.taskStore.get(task.id);
|
|
9541
|
+
if (!fresh || fresh.status !== 'merge-ready')
|
|
9542
|
+
return false;
|
|
9543
|
+
const approvedHead = typeof fresh.latestHeadSha === 'string' && PLATFORM_HEAD_SHA_RE.test(fresh.latestHeadSha)
|
|
9544
|
+
? fresh.latestHeadSha
|
|
9545
|
+
: undefined;
|
|
9546
|
+
if (approvedHead === undefined)
|
|
9547
|
+
return false;
|
|
9548
|
+
await this.taskStore.set({
|
|
9549
|
+
...fresh,
|
|
9550
|
+
status: 'approved',
|
|
9551
|
+
postApproveHeadSha: approvedHead,
|
|
9552
|
+
pendingRedispatch: true,
|
|
9553
|
+
updatedAt: new Date().toISOString(),
|
|
9554
|
+
});
|
|
9555
|
+
return true;
|
|
9556
|
+
});
|
|
9557
|
+
await this.emitIntervention(task.projectId, undefined, task.id, {
|
|
9558
|
+
phase: 'confirm-merge-recheck-failed',
|
|
9559
|
+
gate: task.status,
|
|
9560
|
+
error: message,
|
|
9561
|
+
retreated,
|
|
9562
|
+
note: retreated
|
|
9563
|
+
? 'Accepted pass no longer verifies; task returned to approved for the post-approve loop to re-settle.'
|
|
9564
|
+
: 'Accepted pass no longer verifies and the task could not be returned automatically; inspect and re-dispatch.',
|
|
9565
|
+
});
|
|
9566
|
+
throw new ApiError(409, `Merge blocked for task ${task.id}: ${message}`);
|
|
9567
|
+
}
|
|
8010
9568
|
await this.emitIntervention(task.projectId, undefined, task.id, {
|
|
8011
9569
|
phase: 'confirm-merge-failed',
|
|
8012
9570
|
gate: task.status,
|
|
@@ -8044,14 +9602,17 @@ export class AgentManager {
|
|
|
8044
9602
|
}
|
|
8045
9603
|
startCompactionThenRelease(agentId, state, taskId) {
|
|
8046
9604
|
// 人工介入/重建中的 pane 不发 /clear;confirm 语义要求解绑,落回直接释放
|
|
8047
|
-
|
|
9605
|
+
const expectedPaneId = state.paneId;
|
|
9606
|
+
if (!expectedPaneId || state.status === 'awaiting_human' || state.creationToken)
|
|
8048
9607
|
return false;
|
|
8049
9608
|
const cfg = this.getAgentConfig(agentId);
|
|
8050
9609
|
if (!cfg)
|
|
8051
9610
|
return false;
|
|
8052
9611
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
8053
|
-
void
|
|
8054
|
-
|
|
9612
|
+
void (async () => {
|
|
9613
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, expectedPaneId);
|
|
9614
|
+
await this.runPostMergeCompaction(tmux, pane, agentId, taskId, agentRuntimeKindFor(cfg));
|
|
9615
|
+
})().catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
|
|
8055
9616
|
return true;
|
|
8056
9617
|
}
|
|
8057
9618
|
repoMergeQueue = new Map();
|
|
@@ -8067,13 +9628,15 @@ export class AgentManager {
|
|
|
8067
9628
|
const runner = this.createRunnerFor(dev);
|
|
8068
9629
|
const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
|
|
8069
9630
|
const run = prev.then(async () => {
|
|
9631
|
+
// fetch/push reach a remote derived from THIS Workdir; a rebound Workdir would redirect the push to a foreign repo, so each mutation carries the canonical self-guard in its own command.
|
|
9632
|
+
const guard = `${canonicalSelfGuard(workdir)} && `;
|
|
8070
9633
|
const cd = `cd ${shellQuote(workdir)} && `;
|
|
8071
9634
|
const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
|
|
8072
9635
|
const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
|
|
8073
9636
|
if (db.exitCode !== 0 || defaultBranch === '') {
|
|
8074
9637
|
throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
|
|
8075
9638
|
}
|
|
8076
|
-
const fetch = await execNetwork(runner, `${cd}${GIT_NET_ENV} git fetch origin`);
|
|
9639
|
+
const fetch = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git fetch origin`);
|
|
8077
9640
|
if (fetch.exitCode !== 0) {
|
|
8078
9641
|
throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
|
|
8079
9642
|
}
|
|
@@ -8087,7 +9650,7 @@ export class AgentManager {
|
|
|
8087
9650
|
else {
|
|
8088
9651
|
throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
|
|
8089
9652
|
}
|
|
8090
|
-
const push = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
|
|
9653
|
+
const push = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
|
|
8091
9654
|
if (push.exitCode !== 0) {
|
|
8092
9655
|
throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
|
|
8093
9656
|
}
|