baxian 1.2.59 → 1.2.61
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 +123 -12
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +1607 -420
- 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 +43 -2
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +301 -43
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +9 -0
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +63 -5
- package/dist/agent/phase-signal.js.map +1 -1
- package/dist/agent/preflight.d.ts +20 -1
- package/dist/agent/preflight.d.ts.map +1 -1
- package/dist/agent/preflight.js +235 -27
- package/dist/agent/preflight.js.map +1 -1
- package/dist/agent/prompt.d.ts +8 -0
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +45 -2
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/repo-store.d.ts +5 -1
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +199 -70
- 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 +18 -0
- package/dist/api/config.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +241 -98
- package/dist/api/projects.js.map +1 -1
- package/dist/api/tasks.d.ts.map +1 -1
- package/dist/api/tasks.js +29 -2
- 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 +18 -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 +11 -1
- 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.map +1 -1
- package/dist/event/handlers.js +138 -41
- package/dist/event/handlers.js.map +1 -1
- package/dist/event/log.d.ts.map +1 -1
- package/dist/event/log.js +17 -2
- package/dist/event/log.js.map +1 -1
- package/dist/github/pr-conversation-cache.d.ts +48 -0
- package/dist/github/pr-conversation-cache.d.ts.map +1 -0
- package/dist/github/pr-conversation-cache.js +199 -0
- package/dist/github/pr-conversation-cache.js.map +1 -0
- package/dist/github/pr-conversation.d.ts +2 -2
- package/dist/github/pr-conversation.d.ts.map +1 -1
- package/dist/github/pr-conversation.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -1
- package/dist/index.js.map +1 -1
- package/dist/platform/driver-spec.d.ts.map +1 -1
- package/dist/platform/driver-spec.js +33 -0
- package/dist/platform/driver-spec.js.map +1 -1
- package/dist/platform/git-driver.d.ts +7 -1
- package/dist/platform/git-driver.d.ts.map +1 -1
- package/dist/platform/git-driver.js +33 -2
- package/dist/platform/git-driver.js.map +1 -1
- package/dist/platform/platform-poller.d.ts +8 -0
- package/dist/platform/platform-poller.d.ts.map +1 -1
- package/dist/platform/platform-poller.js +80 -1
- package/dist/platform/platform-poller.js.map +1 -1
- package/dist/platform/plugin-registry.d.ts.map +1 -1
- package/dist/platform/plugin-registry.js +14 -2
- package/dist/platform/plugin-registry.js.map +1 -1
- package/dist/platform/plugins/github/driver.json +3 -0
- package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +16 -7
- package/dist/platform/preflight-exec.d.ts +21 -0
- package/dist/platform/preflight-exec.d.ts.map +1 -0
- package/dist/platform/preflight-exec.js +62 -0
- package/dist/platform/preflight-exec.js.map +1 -0
- package/dist/platform/review-timeline.d.ts +16 -0
- package/dist/platform/review-timeline.d.ts.map +1 -0
- package/dist/platform/review-timeline.js +195 -0
- package/dist/platform/review-timeline.js.map +1 -0
- package/dist/platform/startup.d.ts +5 -1
- package/dist/platform/startup.d.ts.map +1 -1
- package/dist/platform/startup.js +25 -0
- package/dist/platform/startup.js.map +1 -1
- package/dist/platform/types.d.ts +2 -0
- package/dist/platform/types.d.ts.map +1 -1
- package/dist/platform/types.js +1 -1
- package/dist/platform/types.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/git-url.d.ts +1 -0
- package/dist/shared/git-url.d.ts.map +1 -1
- package/dist/shared/git-url.js +3 -0
- package/dist/shared/git-url.js.map +1 -1
- package/dist/shared/types.d.ts +23 -6
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/skill/registry.d.ts +9 -2
- package/dist/skill/registry.d.ts.map +1 -1
- package/dist/skill/registry.js +88 -54
- package/dist/skill/registry.js.map +1 -1
- package/dist/skills/baxian-pr-feedback/SKILL.md +2 -0
- package/dist/skills/baxian-pr-recheck/SKILL.md +2 -0
- package/dist/skills/baxian-pr-review/SKILL.md +2 -0
- package/dist/skills/baxian-research/SKILL.md +1 -1
- package/dist/skills/baxian-signals/SKILL.md +19 -4
- package/dist/skills/baxian-task-check/SKILL.md +4 -2
- 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 +56 -17
- 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.js +4 -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 +41 -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-BPv6vv3U.css} +1 -1
- package/dist/web/assets/index-CUJtd0bC.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
|
@@ -6,7 +6,7 @@ import { createSignalToken } from './phase-signal.js';
|
|
|
6
6
|
import { tmuxInstallHint } from './preflight.js';
|
|
7
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
8
|
import { buildProjectDriver, makeDriverExec } from '../platform/driver-host.js';
|
|
9
|
-
import { DriverOpError } from '../platform/git-driver.js';
|
|
9
|
+
import { DriverOpError, buildDriverRunContext, GitDriver } from '../platform/git-driver.js';
|
|
10
10
|
import { checkOpenPrBinding } from '../platform/pr-binding.js';
|
|
11
11
|
import { collectPendingFeedback, scanCommentSourcesOnce } from '../platform/feedback.js';
|
|
12
12
|
import { recheckPassProvenance } from '../platform/verdict-engine.js';
|
|
@@ -17,15 +17,15 @@ import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey,
|
|
|
17
17
|
import { GH_EXEC_TIMEOUT_MS, GIT_NET_ENV, execNetwork } from './net-exec.js';
|
|
18
18
|
import { findForeignTaskTip } from './lineage.js';
|
|
19
19
|
import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
|
|
20
|
-
import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
|
|
20
|
+
import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, PaneGoneError, } from './tmux.js';
|
|
21
21
|
import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError, isAutoDeletableTaskBranch } from './branch.js';
|
|
22
|
-
import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded } from './repo-store.js';
|
|
23
|
-
import { PhaseSignalWatcher } from './phase-signal-watcher.js';
|
|
22
|
+
import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded, canonicalSelfGuard } from './repo-store.js';
|
|
23
|
+
import { PhaseSignalWatcher, } from './phase-signal-watcher.js';
|
|
24
24
|
import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
|
|
25
25
|
import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
|
|
26
26
|
import { ApiError } from '../errors.js';
|
|
27
27
|
import { prepareConfig } from '../config/loader.js';
|
|
28
|
-
import { resolveProjectTool } from '../config/validator.js';
|
|
28
|
+
import { projectReviewMode, resolveProjectTool } from '../config/validator.js';
|
|
29
29
|
import { SHA_HEX_SOURCE } from '../platform/types.js';
|
|
30
30
|
export class EnsureSessionError extends Error {
|
|
31
31
|
partial;
|
|
@@ -146,7 +146,11 @@ export function skillSubdirFor(runtime) {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
const DEFAULT_DISPATCH_ACK_TIMEOUT_MS = 30_000;
|
|
149
|
+
const DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS = 5_000;
|
|
149
150
|
const DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS = 3_000;
|
|
151
|
+
// DELETE runtime cleanup runs while holding the deletion tombstone + rotated lock; an unbounded remote
|
|
152
|
+
// probe/kill would block rollback/finally forever, so every tmux round-trip in it carries this deadline.
|
|
153
|
+
const DELETE_CLEANUP_TIMEOUT_MS = 15_000;
|
|
150
154
|
const GH_NET = { timeout: GH_EXEC_TIMEOUT_MS, retries: 1 };
|
|
151
155
|
const PLATFORM_HEAD_SHA_RE = new RegExp(`^${SHA_HEX_SOURCE}$`);
|
|
152
156
|
// 复核类失败(provenance/ack)与平台/瞬态失败分型:前者调用方退回 approved 重入闭环(计划 Task 8)
|
|
@@ -230,6 +234,7 @@ export class AgentManager {
|
|
|
230
234
|
paneStreamerManager;
|
|
231
235
|
postApproveStore;
|
|
232
236
|
pluginRegistry;
|
|
237
|
+
platformDefaultBranchOf;
|
|
233
238
|
platformDriverExec;
|
|
234
239
|
phaseSignalWatcher;
|
|
235
240
|
errorRecordStore;
|
|
@@ -237,6 +242,7 @@ export class AgentManager {
|
|
|
237
242
|
reviewTransportInstance;
|
|
238
243
|
serverReviewDriver;
|
|
239
244
|
dispatchAckTimeoutMs;
|
|
245
|
+
needInputRetryIntervalMs;
|
|
240
246
|
dispatchSettleTimeoutMs;
|
|
241
247
|
dispatchAckResendIntervalMs = 3_000;
|
|
242
248
|
taskMutationQueue = Promise.resolve();
|
|
@@ -252,6 +258,9 @@ export class AgentManager {
|
|
|
252
258
|
manualCompactWaitMs = 5_000;
|
|
253
259
|
runtimeLivenessProbeMs = 700;
|
|
254
260
|
cleanComposerWaitMs = 5_000;
|
|
261
|
+
readyStableSamples = 3;
|
|
262
|
+
readyStableSpacingMs = 1_000;
|
|
263
|
+
pendingDispatchRetryByTask = new Map();
|
|
255
264
|
cancelInterruptGuardWaitMs = DEFAULT_DISPATCH_ACK_TIMEOUT_MS + 5_000;
|
|
256
265
|
manualReviewInFlight = new Set();
|
|
257
266
|
markCompleteInFlight = new Set();
|
|
@@ -260,6 +269,9 @@ export class AgentManager {
|
|
|
260
269
|
// 引用计数:cancelTask 与 startCreatedTaskSession 的清理段可合法并存(启动期 Stop),先完成者不得清掉后者的 guard。
|
|
261
270
|
cancelCleanupInFlight = new Map();
|
|
262
271
|
deletionInFlight = new Set();
|
|
272
|
+
// Diverged ids live in a separate set the DELETE finally never clears: a post-commit replaceConfig throw must fail-stop for the whole process.
|
|
273
|
+
divergedAgents = new Set();
|
|
274
|
+
deletionGeneration = new Map();
|
|
263
275
|
compactInFlight = new Set();
|
|
264
276
|
constructor(deps) {
|
|
265
277
|
const config = prepareConfig(deps.config);
|
|
@@ -274,6 +286,7 @@ export class AgentManager {
|
|
|
274
286
|
this.paneStreamerManager = deps.paneStreamerManager;
|
|
275
287
|
this.postApproveStore = deps.postApproveStore ?? new PostApproveStore();
|
|
276
288
|
this.pluginRegistry = deps.pluginRegistry;
|
|
289
|
+
this.platformDefaultBranchOf = deps.platformDefaultBranchOf;
|
|
277
290
|
this.errorRecordStore = deps.errorRecordStore;
|
|
278
291
|
this.phaseSignalWatcher = deps.phaseSignalWatcher
|
|
279
292
|
?? (deps.paneStreamerManager
|
|
@@ -281,8 +294,10 @@ export class AgentManager {
|
|
|
281
294
|
paneStreamerManager: deps.paneStreamerManager,
|
|
282
295
|
eventBus: deps.eventBus,
|
|
283
296
|
resolveAgent: (id) => this.getAgentConfig(id),
|
|
297
|
+
commitNeedInputWatermark: (intent) => this.commitNeedInputWatermark(intent),
|
|
284
298
|
})
|
|
285
299
|
: undefined);
|
|
300
|
+
this.needInputRetryIntervalMs = deps.needInputRetryIntervalMs ?? DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS;
|
|
286
301
|
this.reviewStore = deps.reviewStore;
|
|
287
302
|
this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
|
|
288
303
|
this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
|
|
@@ -455,14 +470,62 @@ export class AgentManager {
|
|
|
455
470
|
this.deletionInFlight.add(id);
|
|
456
471
|
return null;
|
|
457
472
|
}
|
|
473
|
+
// Scan + claim share createTask/dispatchPendingTask's withTaskLock, so a delete can't pass an active-scan that a concurrent in_progress commit then orphans.
|
|
474
|
+
async scanActiveThenClaimDeletion(targets) {
|
|
475
|
+
return this.withTaskLock(async () => {
|
|
476
|
+
for (const id of targets) {
|
|
477
|
+
const state = await this.agentStore.get(id);
|
|
478
|
+
const task = state?.taskId ? await this.taskStore.get(state.taskId) : null;
|
|
479
|
+
if (task && ACTIVE_TASK_STATUSES.has(task.status)) {
|
|
480
|
+
return { ok: false, code: 'active', agentId: id, taskId: task.id };
|
|
481
|
+
}
|
|
482
|
+
if (state?.status === 'awaiting_human')
|
|
483
|
+
continue;
|
|
484
|
+
if (state?.creationToken)
|
|
485
|
+
return { ok: false, code: 'bootstrapping', agentId: id };
|
|
486
|
+
}
|
|
487
|
+
const targetSet = new Set(targets);
|
|
488
|
+
const referencing = (await this.taskStore.list({})).find(t => ACTIVE_TASK_STATUSES.has(t.status)
|
|
489
|
+
&& ((t.preferredAgentId !== '' && targetSet.has(t.preferredAgentId))
|
|
490
|
+
|| (!!t.agentId && targetSet.has(t.agentId))
|
|
491
|
+
|| (!!t.devAgentId && targetSet.has(t.devAgentId))
|
|
492
|
+
|| (!!t.qaAgentId && targetSet.has(t.qaAgentId))
|
|
493
|
+
|| (!!t.researchAgentId && targetSet.has(t.researchAgentId))));
|
|
494
|
+
if (referencing)
|
|
495
|
+
return { ok: false, code: 'referencing', taskId: referencing.id };
|
|
496
|
+
const conflict = this.tryClaimDeletion(targets);
|
|
497
|
+
if (conflict)
|
|
498
|
+
return { ok: false, code: 'already-deleting', agentId: conflict };
|
|
499
|
+
return { ok: true };
|
|
500
|
+
});
|
|
501
|
+
}
|
|
458
502
|
releaseDeletionClaim(agentIds) {
|
|
459
503
|
for (const id of agentIds)
|
|
460
504
|
this.deletionInFlight.delete(id);
|
|
461
505
|
}
|
|
506
|
+
// A completed DELETE keeps blocking this id past tombstone release; the process dies with any suspended flow, so no persistence is needed.
|
|
507
|
+
bumpDeletionGeneration(agentId) {
|
|
508
|
+
this.deletionGeneration.set(agentId, this.deletionGenerationOf(agentId) + 1);
|
|
509
|
+
}
|
|
510
|
+
deletionGenerationOf(agentId) {
|
|
511
|
+
return this.deletionGeneration.get(agentId) ?? 0;
|
|
512
|
+
}
|
|
513
|
+
// Boolean tombstone alone is ABA-prone (delete→recreate same id); commits must hold both the tombstone-clear and an unchanged generation from entry.
|
|
514
|
+
deletionGateOpen(agentId, genAtEntry) {
|
|
515
|
+
return !this.isDeletionInFlight(agentId) && this.deletionGenerationOf(agentId) === genAtEntry;
|
|
516
|
+
}
|
|
462
517
|
isDeletionInFlight(agentId) {
|
|
463
|
-
return this.deletionInFlight.has(agentId);
|
|
518
|
+
return this.deletionInFlight.has(agentId) || this.divergedAgents.has(agentId);
|
|
519
|
+
}
|
|
520
|
+
// replaceConfig 抛错后的 fail-stop:进入 divergedAgents(不随 DELETE finally 释放),重建/分配持续被拒直到重启。
|
|
521
|
+
retainTombstoneForDivergence(agentId) {
|
|
522
|
+
this.divergedAgents.add(agentId);
|
|
464
523
|
}
|
|
465
|
-
async ensureSession(agentId, mode) {
|
|
524
|
+
async ensureSession(agentId, mode, opts = {}) {
|
|
525
|
+
const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
|
|
526
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
527
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `agent ${agentId} is being deleted (or was deleted and recreated); refusing session work`);
|
|
528
|
+
}
|
|
466
529
|
const agent = this.getAgentConfig(agentId);
|
|
467
530
|
if (!agent) {
|
|
468
531
|
throw new EnsureSessionError({ createdSession: false, agentId }, `Unknown agent: ${agentId}`);
|
|
@@ -473,52 +536,81 @@ export class AgentManager {
|
|
|
473
536
|
}
|
|
474
537
|
const runner = this.createRunnerFor(agent);
|
|
475
538
|
const tmux = new TmuxManager(runner);
|
|
539
|
+
// 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.
|
|
540
|
+
return this.runUnderSessionLifecycle(agentId, () => this.ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry));
|
|
541
|
+
}
|
|
542
|
+
async ensureSessionLocked(tmux, runner, agent, project, agentId, mode, genAtEntry) {
|
|
543
|
+
const assertGenerationOpen = () => {
|
|
544
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
545
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `agent ${agentId} is being deleted (or was deleted and recreated); refusing session work`);
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
assertGenerationOpen();
|
|
476
549
|
let workdir;
|
|
477
550
|
try {
|
|
478
551
|
const resolved = await this.ensureWorkdir(agent, project, runner);
|
|
479
552
|
workdir = resolved.workdir;
|
|
480
|
-
await this.agentStore.update(agentId, (existing) => {
|
|
481
|
-
if (!existing)
|
|
482
|
-
return AGENT_STORE_NOOP;
|
|
483
|
-
return {
|
|
484
|
-
...existing,
|
|
485
|
-
workdir,
|
|
486
|
-
updatedAt: new Date().toISOString(),
|
|
487
|
-
};
|
|
488
|
-
});
|
|
489
553
|
}
|
|
490
554
|
catch (err) {
|
|
491
555
|
throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
492
556
|
}
|
|
557
|
+
// Gate the Workdir write too: a DELETE→recreate during the slow ensureWorkdir must not persist a stale Workdir onto the new incarnation.
|
|
558
|
+
assertGenerationOpen();
|
|
559
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
560
|
+
if (!existing || !this.deletionGateOpen(agentId, genAtEntry))
|
|
561
|
+
return AGENT_STORE_NOOP;
|
|
562
|
+
return { ...existing, workdir, updatedAt: new Date().toISOString() };
|
|
563
|
+
});
|
|
564
|
+
assertGenerationOpen();
|
|
493
565
|
try {
|
|
494
566
|
await this.provisionRepoSkills(runner, agent, workdir);
|
|
495
567
|
}
|
|
496
568
|
catch (err) {
|
|
497
569
|
throw new EnsureSessionError({ createdSession: false, agentId }, `skill provisioning failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
498
570
|
}
|
|
499
|
-
|
|
571
|
+
assertGenerationOpen();
|
|
572
|
+
let snapshot;
|
|
500
573
|
try {
|
|
501
|
-
|
|
574
|
+
snapshot = await tmux.getSessionSnapshot(agentId);
|
|
502
575
|
}
|
|
503
576
|
catch (err) {
|
|
504
577
|
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux probe failed: ${err instanceof Error ? err.message : String(err)}; ` +
|
|
505
578
|
`if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
|
|
506
579
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
580
|
+
// 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.
|
|
581
|
+
assertGenerationOpen();
|
|
582
|
+
if (!snapshot) {
|
|
583
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
|
|
584
|
+
}
|
|
585
|
+
if (snapshot.claim === null) {
|
|
586
|
+
if (await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
|
|
587
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, workdir);
|
|
512
588
|
}
|
|
589
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
|
|
590
|
+
`it creates itself — kill it manually or pick a different agent id`);
|
|
591
|
+
}
|
|
592
|
+
if (mode === 'create' || snapshot.claim !== agentId) {
|
|
593
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, mode === 'create'
|
|
594
|
+
? `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
|
|
595
|
+
`it creates itself — kill it manually or pick a different agent id`
|
|
596
|
+
: `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
|
|
597
|
+
`(got "${snapshot.claim}"); baxian will not adopt foreign session — operator must intervene`);
|
|
513
598
|
}
|
|
514
|
-
|
|
515
|
-
|
|
599
|
+
return this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, workdir, snapshot);
|
|
600
|
+
}
|
|
601
|
+
agentSkillScope(agentId) {
|
|
602
|
+
const projectId = this.getAgentConfig(agentId)?.projectId;
|
|
603
|
+
if (projectId === undefined || this.effectiveReviewMode(projectId) !== 'git') {
|
|
604
|
+
return { pluginTools: [] };
|
|
516
605
|
}
|
|
517
|
-
|
|
606
|
+
const project = this.getProjectConfig(projectId);
|
|
607
|
+
const tool = project === undefined ? undefined : resolveProjectTool(project);
|
|
608
|
+
return { pluginTools: tool === undefined ? [] : [tool] };
|
|
518
609
|
}
|
|
519
610
|
async provisionRepoSkills(runner, agent, workdir) {
|
|
520
611
|
if (this.skillRegistry.names().length === 0)
|
|
521
612
|
return;
|
|
613
|
+
const scope = this.agentSkillScope(agent.id);
|
|
522
614
|
const subdir = skillSubdirFor(agent.runtime);
|
|
523
615
|
const destRoot = `${workdir}/${subdir}`;
|
|
524
616
|
await this.excludeInjectedSkills(runner, workdir, subdir);
|
|
@@ -528,8 +620,11 @@ export class AgentManager {
|
|
|
528
620
|
// would execute a `!`cmd`` / inline a @path embedded in an untrusted task title/description
|
|
529
621
|
// before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
|
|
530
622
|
await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
|
|
531
|
-
await this.ensureSkillDirSafe(runner, workdir, subdir
|
|
532
|
-
|
|
623
|
+
await this.ensureSkillDirSafe(runner, workdir, subdir, [
|
|
624
|
+
...this.skillRegistry.names(),
|
|
625
|
+
...this.skillRegistry.pluginSkillNames(scope),
|
|
626
|
+
]);
|
|
627
|
+
await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot, scope);
|
|
533
628
|
});
|
|
534
629
|
}
|
|
535
630
|
// A rollback only kills if no adoption bumped the generation since its create.
|
|
@@ -583,9 +678,10 @@ export class AgentManager {
|
|
|
583
678
|
return;
|
|
584
679
|
}
|
|
585
680
|
const runner = this.createRunnerFor(cfg);
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
681
|
+
// Rollback may run before or after the claim write, so accept empty-or-ours.
|
|
682
|
+
const outcome = await new TmuxManager(runner).killSessionRef(ref, { kind: 'emptyOr', claim: agentId });
|
|
683
|
+
if (outcome === 'refused') {
|
|
684
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux generation/claim changed — leaving session ${ref.sessionId} untouched`);
|
|
589
685
|
}
|
|
590
686
|
else if (outcome === 'killed') {
|
|
591
687
|
console.warn(`[AgentManager] ${agentId} rollback (${reason}): killed created session ${ref.sessionId}`);
|
|
@@ -629,10 +725,12 @@ export class AgentManager {
|
|
|
629
725
|
throw new Error(`atomic skill write failed (${finalPath}): ${err instanceof Error ? err.message : String(err)}`);
|
|
630
726
|
}
|
|
631
727
|
}
|
|
632
|
-
async ensureSkillDirSafe(runner, workdir, subdir) {
|
|
728
|
+
async ensureSkillDirSafe(runner, workdir, subdir, keepNames) {
|
|
633
729
|
const top = subdir.split('/')[0];
|
|
634
|
-
const keep =
|
|
635
|
-
const inner =
|
|
730
|
+
const keep = keepNames.map((n) => `! -name ${shellQuote(n)}`).join(' ');
|
|
731
|
+
const inner =
|
|
732
|
+
// canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor, not just a symlinked leaf, so the find/rm can't escape.
|
|
733
|
+
`${canonicalSelfGuard(workdir)} && ` +
|
|
636
734
|
`cd ${shellQuote(workdir)} && ` +
|
|
637
735
|
`for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
|
|
638
736
|
`printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
|
|
@@ -650,17 +748,19 @@ export class AgentManager {
|
|
|
650
748
|
if (this.skillRegistry.names().length === 0)
|
|
651
749
|
return;
|
|
652
750
|
await this.setSessionOptions(tmux, agentId, ref, [
|
|
653
|
-
['@baxian-skills-version', this.skillRegistry.contentHash()],
|
|
751
|
+
['@baxian-skills-version', this.skillRegistry.contentHash(this.agentSkillScope(agentId))],
|
|
654
752
|
]);
|
|
655
753
|
}
|
|
656
|
-
async replSkillsStale(tmux, agentId) {
|
|
754
|
+
async replSkillsStale(tmux, agentId, ref) {
|
|
657
755
|
if (this.skillRegistry.names().length === 0)
|
|
658
756
|
return false;
|
|
659
|
-
const tagged = await tmux.
|
|
660
|
-
return tagged !== this.skillRegistry.contentHash();
|
|
757
|
+
const tagged = await tmux.getSessionOptionByRef(ref, agentId, '@baxian-skills-version');
|
|
758
|
+
return tagged !== this.skillRegistry.contentHash(this.agentSkillScope(agentId));
|
|
661
759
|
}
|
|
662
760
|
async excludeInjectedSkills(runner, workdir, subdir) {
|
|
663
|
-
const inner =
|
|
761
|
+
const inner =
|
|
762
|
+
// canonicalSelfGuard (cd+pwd -P) rejects a rebound MIDDLE ancestor too, so the info/exclude write can't escape.
|
|
763
|
+
`${canonicalSelfGuard(workdir)} && ` +
|
|
664
764
|
`cd ${shellQuote(workdir)} && ` +
|
|
665
765
|
`if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
|
|
666
766
|
`tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
|
|
@@ -693,12 +793,26 @@ export class AgentManager {
|
|
|
693
793
|
}
|
|
694
794
|
// A name (or even a bare $id) can rebind to a same-name successor mid-flight;
|
|
695
795
|
// every option write goes through the identity-checked by-ref batch.
|
|
696
|
-
async setSessionOptions(tmux, agentId, ref, entries) {
|
|
697
|
-
const outcome = await tmux.setSessionOptionsIfAlive(ref, entries);
|
|
796
|
+
async setSessionOptions(tmux, agentId, ref, entries, expectedClaim = agentId) {
|
|
797
|
+
const outcome = await tmux.setSessionOptionsIfAlive(ref, entries, { expectedClaim });
|
|
698
798
|
if (outcome === 'gone') {
|
|
699
799
|
throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
|
|
700
800
|
}
|
|
701
801
|
}
|
|
802
|
+
// Snapshot's claim check is a filter; the returned PaneRef re-proves generation+claim on every op.
|
|
803
|
+
async resolveClaimedPane(tmux, agentId, expectedPaneId) {
|
|
804
|
+
const snapshot = await tmux.getSessionSnapshot(agentId);
|
|
805
|
+
if (!snapshot)
|
|
806
|
+
throw new PaneGoneError(agentId, 'no tmux session');
|
|
807
|
+
if (snapshot.claim !== agentId) {
|
|
808
|
+
throw new PaneGoneError(agentId, `session claim mismatch (got "${snapshot.claim ?? 'null'}")`);
|
|
809
|
+
}
|
|
810
|
+
const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
|
|
811
|
+
if (expectedPaneId !== undefined && pane.paneId !== expectedPaneId) {
|
|
812
|
+
throw new PaneGoneError(agentId, `pane id changed (state ${expectedPaneId} != live ${pane.paneId})`);
|
|
813
|
+
}
|
|
814
|
+
return pane;
|
|
815
|
+
}
|
|
702
816
|
async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
|
|
703
817
|
return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
|
|
704
818
|
}
|
|
@@ -710,6 +824,7 @@ export class AgentManager {
|
|
|
710
824
|
try {
|
|
711
825
|
createdRef = await tmux.createSession(agentId, sessionDir);
|
|
712
826
|
createdSession = true;
|
|
827
|
+
// First batch writes the claim itself, so it must prove the session is still unclaimed.
|
|
713
828
|
await this.setSessionOptions(tmux, agentId, createdRef, [
|
|
714
829
|
['@baxian-agent-id', agentId],
|
|
715
830
|
['@baxian-runtime', agent.runtime],
|
|
@@ -719,20 +834,21 @@ export class AgentManager {
|
|
|
719
834
|
['window-size', 'latest'],
|
|
720
835
|
...this.runtimePinEntries(),
|
|
721
836
|
['status-right', ''],
|
|
722
|
-
]);
|
|
837
|
+
], '');
|
|
723
838
|
await tmux.setServerOption('extended-keys', 'on');
|
|
724
839
|
await tmux.appendServerOptionIfMissing('terminal-features', 'xterm*:extkeys');
|
|
725
|
-
const
|
|
726
|
-
await tmux.
|
|
727
|
-
await tmux.
|
|
840
|
+
const pane = await tmux.getSinglePaneByRef(createdRef, agentId);
|
|
841
|
+
await tmux.sendKeysLiteral(pane, launchCommandIn(sessionDir, agent));
|
|
842
|
+
await tmux.sendEnter(pane);
|
|
843
|
+
await tmux.handleTrustDialog(pane, runtime, {
|
|
728
844
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
729
845
|
});
|
|
730
|
-
await tmux.waitReplReady(
|
|
846
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
731
847
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
732
848
|
scrollback: 0,
|
|
733
849
|
});
|
|
734
850
|
await this.tagSessionSkillsVersion(tmux, agentId, createdRef);
|
|
735
|
-
return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir, sessionRef: createdRef };
|
|
851
|
+
return { ok: true, createdSession: true, freshRuntime: true, paneId: pane.paneId, pane, workdir, sessionRef: createdRef };
|
|
736
852
|
}
|
|
737
853
|
catch (err) {
|
|
738
854
|
const partial = { createdSession, agentId, genAtCreate };
|
|
@@ -755,7 +871,7 @@ export class AgentManager {
|
|
|
755
871
|
}
|
|
756
872
|
try {
|
|
757
873
|
const result = await this.ensureSession(agentId, 'create');
|
|
758
|
-
if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.
|
|
874
|
+
if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.pane))) {
|
|
759
875
|
const current = await this.agentStore.get(agentId);
|
|
760
876
|
if (current && current.creationToken !== creationToken) {
|
|
761
877
|
console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — leaving the session to its successor`);
|
|
@@ -816,7 +932,7 @@ export class AgentManager {
|
|
|
816
932
|
await this.markBootstrapFailed(agentId, creationToken, message);
|
|
817
933
|
}
|
|
818
934
|
}
|
|
819
|
-
async runGreetingHandshake(agentId, agent,
|
|
935
|
+
async runGreetingHandshake(agentId, agent, pane) {
|
|
820
936
|
const watcher = this.phaseSignalWatcher;
|
|
821
937
|
if (!watcher)
|
|
822
938
|
return true;
|
|
@@ -824,7 +940,7 @@ export class AgentManager {
|
|
|
824
940
|
for (let attempt = 1; attempt <= this.greetingMaxAttempts; attempt++) {
|
|
825
941
|
const token = createSignalToken();
|
|
826
942
|
try {
|
|
827
|
-
await this.injectAndAwaitAckSteps(tmux,
|
|
943
|
+
await this.injectAndAwaitAckSteps(tmux, pane, buildGreetingPrompt(token, agent.runtime), agentId, agent.runtime);
|
|
828
944
|
}
|
|
829
945
|
catch (err) {
|
|
830
946
|
console.warn(`[bootstrap] greeting inject failed for ${agentId} (attempt ${attempt}):`, err);
|
|
@@ -843,7 +959,7 @@ export class AgentManager {
|
|
|
843
959
|
console.warn(`[bootstrap] greeting attempt ${attempt}/${this.greetingMaxAttempts} for ${agentId}: ${outcome}`);
|
|
844
960
|
if (outcome === 'no-agent')
|
|
845
961
|
break;
|
|
846
|
-
if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux,
|
|
962
|
+
if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, pane, agentId))) {
|
|
847
963
|
break;
|
|
848
964
|
}
|
|
849
965
|
}
|
|
@@ -885,21 +1001,25 @@ export class AgentManager {
|
|
|
885
1001
|
const agent = this.getAgentConfig(agentId);
|
|
886
1002
|
if (!agent)
|
|
887
1003
|
return false;
|
|
1004
|
+
// Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the greeting handshake to the new session.
|
|
1005
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
888
1006
|
const state = await this.agentStore.get(agentId);
|
|
889
1007
|
if (state?.awaitingPhase !== 'greeting_failed')
|
|
890
1008
|
return false;
|
|
891
1009
|
const guardSince = state.awaitingSince;
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1010
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
1011
|
+
return false;
|
|
1012
|
+
let pane;
|
|
1013
|
+
try {
|
|
1014
|
+
pane = await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(agent)), agentId, state.paneId);
|
|
1015
|
+
}
|
|
1016
|
+
catch (err) {
|
|
1017
|
+
console.warn(`[regreet] cannot resolve pane for ${agentId}:`, err);
|
|
1018
|
+
return true;
|
|
901
1019
|
}
|
|
902
|
-
if (!
|
|
1020
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
1021
|
+
return false;
|
|
1022
|
+
if (!(await this.runGreetingHandshake(agentId, agent, pane))) {
|
|
903
1023
|
return true;
|
|
904
1024
|
}
|
|
905
1025
|
await this.agentStore.update(agentId, (fresh) => {
|
|
@@ -936,8 +1056,7 @@ export class AgentManager {
|
|
|
936
1056
|
let paneId = existing.paneId;
|
|
937
1057
|
if (cfg && !paneId) {
|
|
938
1058
|
try {
|
|
939
|
-
|
|
940
|
-
paneId = await new TmuxManager(runner).getSinglePaneId(agentId);
|
|
1059
|
+
paneId = (await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), agentId)).paneId;
|
|
941
1060
|
}
|
|
942
1061
|
catch {
|
|
943
1062
|
}
|
|
@@ -1014,8 +1133,10 @@ export class AgentManager {
|
|
|
1014
1133
|
if (!(err instanceof EnsureSessionError) || !err.partial.dialogPending) {
|
|
1015
1134
|
return false;
|
|
1016
1135
|
}
|
|
1136
|
+
// 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.
|
|
1137
|
+
const gateOpen = () => opts.expectedGeneration === undefined || this.deletionGateOpen(agentId, opts.expectedGeneration);
|
|
1017
1138
|
let state = await this.agentStore.get(agentId);
|
|
1018
|
-
if (!state)
|
|
1139
|
+
if (!state || !gateOpen())
|
|
1019
1140
|
return false;
|
|
1020
1141
|
if (state.paneId === undefined && err.partial.createdSession) {
|
|
1021
1142
|
const cfg = this.getAgentConfig(agentId);
|
|
@@ -1023,8 +1144,7 @@ export class AgentManager {
|
|
|
1023
1144
|
return false;
|
|
1024
1145
|
let discoveredPaneId;
|
|
1025
1146
|
try {
|
|
1026
|
-
|
|
1027
|
-
discoveredPaneId = await new TmuxManager(runner).getSinglePaneId(agentId);
|
|
1147
|
+
discoveredPaneId = (await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), agentId)).paneId;
|
|
1028
1148
|
}
|
|
1029
1149
|
catch (probeErr) {
|
|
1030
1150
|
console.warn(`[AgentManager] handleDialogPendingFromRuntime: tmux probe paneId failed for ${agentId}:`, probeErr);
|
|
@@ -1034,7 +1154,7 @@ export class AgentManager {
|
|
|
1034
1154
|
return false;
|
|
1035
1155
|
const probeNow = new Date().toISOString();
|
|
1036
1156
|
await this.agentStore.update(agentId, (fresh) => {
|
|
1037
|
-
if (!fresh)
|
|
1157
|
+
if (!fresh || !gateOpen())
|
|
1038
1158
|
return AGENT_STORE_NOOP;
|
|
1039
1159
|
if (fresh.paneId !== undefined)
|
|
1040
1160
|
return AGENT_STORE_NOOP;
|
|
@@ -1048,6 +1168,8 @@ export class AgentManager {
|
|
|
1048
1168
|
console.warn(`[AgentManager] handleDialogPendingFromRuntime: ${agentId} has no paneId/taskId snapshot (no generation guard); refusing — caller should rollback`);
|
|
1049
1169
|
return false;
|
|
1050
1170
|
}
|
|
1171
|
+
if (!gateOpen())
|
|
1172
|
+
return false;
|
|
1051
1173
|
await this.markDialogPending(agentId, undefined, {
|
|
1052
1174
|
runtimePath: true,
|
|
1053
1175
|
...(state.paneId !== undefined ? { expectedPaneId: state.paneId } : {}),
|
|
@@ -1117,15 +1239,16 @@ export class AgentManager {
|
|
|
1117
1239
|
return;
|
|
1118
1240
|
if (generationMismatch(state))
|
|
1119
1241
|
return;
|
|
1120
|
-
let
|
|
1242
|
+
let pane;
|
|
1121
1243
|
try {
|
|
1122
|
-
|
|
1244
|
+
pane = await this.resolveClaimedPane(tmux, agentId);
|
|
1123
1245
|
}
|
|
1124
1246
|
catch {
|
|
1125
1247
|
continue;
|
|
1126
1248
|
}
|
|
1249
|
+
const paneId = pane.paneId;
|
|
1127
1250
|
try {
|
|
1128
|
-
await tmux.waitReplReady(
|
|
1251
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
1129
1252
|
timeoutMs: 1_000,
|
|
1130
1253
|
intervalMs: 200,
|
|
1131
1254
|
scrollback: 0,
|
|
@@ -1143,7 +1266,7 @@ export class AgentManager {
|
|
|
1143
1266
|
let projectIdForEmit = '';
|
|
1144
1267
|
let wrote = false;
|
|
1145
1268
|
const isBootstrapPath = creationToken !== undefined;
|
|
1146
|
-
if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg,
|
|
1269
|
+
if (isBootstrapPath && !(await this.runGreetingHandshake(agentId, cfg, pane))) {
|
|
1147
1270
|
await this.markGreetingFailed(agentId, creationToken);
|
|
1148
1271
|
return;
|
|
1149
1272
|
}
|
|
@@ -1238,7 +1361,8 @@ export class AgentManager {
|
|
|
1238
1361
|
continue;
|
|
1239
1362
|
let stripped;
|
|
1240
1363
|
try {
|
|
1241
|
-
|
|
1364
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, state.paneId);
|
|
1365
|
+
stripped = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
1242
1366
|
}
|
|
1243
1367
|
catch {
|
|
1244
1368
|
continue;
|
|
@@ -1279,29 +1403,9 @@ export class AgentManager {
|
|
|
1279
1403
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
1280
1404
|
});
|
|
1281
1405
|
}
|
|
1282
|
-
//
|
|
1283
|
-
async
|
|
1284
|
-
return this.runUnderSessionLifecycle(agentId, () => this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir));
|
|
1285
|
-
}
|
|
1286
|
-
async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir) {
|
|
1287
|
-
let snapshot;
|
|
1288
|
-
try {
|
|
1289
|
-
snapshot = await tmux.getSessionSnapshot(agentId);
|
|
1290
|
-
}
|
|
1291
|
-
catch (err) {
|
|
1292
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1293
|
-
}
|
|
1294
|
-
if (!snapshot) {
|
|
1295
|
-
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1296
|
-
}
|
|
1406
|
+
// Runs inside the caller's lifecycle section (ensureSessionLocked routed the snapshot here).
|
|
1407
|
+
async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir, snapshot) {
|
|
1297
1408
|
const sessionRef = snapshot.ref;
|
|
1298
|
-
if (snapshot.claim !== agentId) {
|
|
1299
|
-
if (snapshot.claim === null && await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
|
|
1300
|
-
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1301
|
-
}
|
|
1302
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
|
|
1303
|
-
`(got "${snapshot.claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
|
|
1304
|
-
}
|
|
1305
1409
|
this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
|
|
1306
1410
|
try {
|
|
1307
1411
|
await this.setSessionOptions(tmux, agentId, sessionRef, this.runtimePinEntries());
|
|
@@ -1309,24 +1413,25 @@ export class AgentManager {
|
|
|
1309
1413
|
catch (err) {
|
|
1310
1414
|
throw new EnsureSessionError({ createdSession: false, agentId }, `pinning runtime session options failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1311
1415
|
}
|
|
1312
|
-
let
|
|
1416
|
+
let pane;
|
|
1313
1417
|
try {
|
|
1314
|
-
|
|
1418
|
+
pane = await tmux.getSinglePaneByRef(sessionRef, agentId);
|
|
1315
1419
|
}
|
|
1316
1420
|
catch (err) {
|
|
1317
1421
|
throw new EnsureSessionError({ createdSession: false, agentId }, `getSinglePaneId failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1318
1422
|
}
|
|
1423
|
+
const paneId = pane.paneId;
|
|
1319
1424
|
const runtime = agentRuntimeKindFor(agent);
|
|
1320
1425
|
let state;
|
|
1321
1426
|
try {
|
|
1322
|
-
state = await tmux.classifyPaneForAdopt(
|
|
1427
|
+
state = await tmux.classifyPaneForAdopt(pane, runtime);
|
|
1323
1428
|
}
|
|
1324
1429
|
catch (err) {
|
|
1325
1430
|
throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1326
1431
|
}
|
|
1327
1432
|
let currentPath;
|
|
1328
1433
|
try {
|
|
1329
|
-
currentPath = await tmux.getPaneCurrentPath(
|
|
1434
|
+
currentPath = await tmux.getPaneCurrentPath(pane);
|
|
1330
1435
|
}
|
|
1331
1436
|
catch (err) {
|
|
1332
1437
|
throw new EnsureSessionError({ createdSession: false, agentId }, `pane current path probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1341,7 +1446,7 @@ export class AgentManager {
|
|
|
1341
1446
|
if (!pathMatches) {
|
|
1342
1447
|
if (state.kind === 'live-runtime') {
|
|
1343
1448
|
try {
|
|
1344
|
-
await this.waitForReplPromptReady(tmux,
|
|
1449
|
+
await this.waitForReplPromptReady(tmux, pane, agent.runtime, this.cleanComposerWaitMs);
|
|
1345
1450
|
}
|
|
1346
1451
|
catch (err) {
|
|
1347
1452
|
throw new EnsureSessionError({ createdSession: false, agentId }, `Workdir change requires an idle runtime: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1358,7 +1463,7 @@ export class AgentManager {
|
|
|
1358
1463
|
case 'live-runtime': {
|
|
1359
1464
|
let stale;
|
|
1360
1465
|
try {
|
|
1361
|
-
stale = await this.replSkillsStale(tmux, agentId);
|
|
1466
|
+
stale = await this.replSkillsStale(tmux, agentId, sessionRef);
|
|
1362
1467
|
}
|
|
1363
1468
|
catch (err) {
|
|
1364
1469
|
throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1370,11 +1475,12 @@ export class AgentManager {
|
|
|
1370
1475
|
freshRuntime: false,
|
|
1371
1476
|
skillsStale: true,
|
|
1372
1477
|
paneId,
|
|
1478
|
+
pane,
|
|
1373
1479
|
workdir,
|
|
1374
1480
|
sessionRef,
|
|
1375
1481
|
};
|
|
1376
1482
|
}
|
|
1377
|
-
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir, sessionRef };
|
|
1483
|
+
return { ok: true, createdSession: false, freshRuntime: false, paneId, pane, workdir, sessionRef };
|
|
1378
1484
|
}
|
|
1379
1485
|
case 'startup-dialog':
|
|
1380
1486
|
throw new EnsureSessionError({
|
|
@@ -1389,15 +1495,15 @@ export class AgentManager {
|
|
|
1389
1495
|
`must reset the pane manually`);
|
|
1390
1496
|
case 'trust-dialog':
|
|
1391
1497
|
try {
|
|
1392
|
-
await tmux.handleTrustDialog(
|
|
1498
|
+
await tmux.handleTrustDialog(pane, runtime, {
|
|
1393
1499
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
1394
1500
|
});
|
|
1395
|
-
await tmux.waitReplReady(
|
|
1501
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
1396
1502
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1397
1503
|
scrollback: 0,
|
|
1398
1504
|
});
|
|
1399
1505
|
await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
|
|
1400
|
-
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
|
|
1506
|
+
return { ok: true, createdSession: false, freshRuntime: true, paneId, pane, workdir, sessionRef };
|
|
1401
1507
|
}
|
|
1402
1508
|
catch (trustErr) {
|
|
1403
1509
|
if (trustErr instanceof ReplNotReadyError && detectStartupDialog(trustErr.lastScreen, runtime)) {
|
|
@@ -1415,16 +1521,17 @@ export class AgentManager {
|
|
|
1415
1521
|
break;
|
|
1416
1522
|
}
|
|
1417
1523
|
try {
|
|
1418
|
-
await tmux.
|
|
1419
|
-
await tmux.
|
|
1524
|
+
await tmux.sendKeysLiteral(pane, launchCommandIn(sessionDir, agent));
|
|
1525
|
+
await tmux.sendEnter(pane);
|
|
1526
|
+
await tmux.handleTrustDialog(pane, runtime, {
|
|
1420
1527
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
1421
1528
|
});
|
|
1422
|
-
await tmux.waitReplReady(
|
|
1529
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
1423
1530
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1424
1531
|
scrollback: 0,
|
|
1425
1532
|
});
|
|
1426
1533
|
await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
|
|
1427
|
-
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
|
|
1534
|
+
return { ok: true, createdSession: false, freshRuntime: true, paneId, pane, workdir, sessionRef };
|
|
1428
1535
|
}
|
|
1429
1536
|
catch (relErr) {
|
|
1430
1537
|
if (relErr instanceof ReplNotReadyError && detectStartupDialog(relErr.lastScreen, runtime)) {
|
|
@@ -1464,12 +1571,12 @@ export class AgentManager {
|
|
|
1464
1571
|
let outcome;
|
|
1465
1572
|
try {
|
|
1466
1573
|
// claim-still-empty rides the kill itself — a claim racing the probes makes the server refuse.
|
|
1467
|
-
outcome = await tmux.killSessionRef(snapshot.ref, {
|
|
1574
|
+
outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' });
|
|
1468
1575
|
}
|
|
1469
1576
|
catch (err) {
|
|
1470
1577
|
throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (half-created leftover) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1471
1578
|
}
|
|
1472
|
-
if (outcome === 'refused'
|
|
1579
|
+
if (outcome === 'refused') {
|
|
1473
1580
|
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`);
|
|
1474
1581
|
}
|
|
1475
1582
|
if (outcome === 'killed') {
|
|
@@ -1483,13 +1590,13 @@ export class AgentManager {
|
|
|
1483
1590
|
async killAdoptTarget(tmux, agentId, sessionRef, reason) {
|
|
1484
1591
|
let outcome;
|
|
1485
1592
|
try {
|
|
1486
|
-
outcome = await tmux.killSessionRef(sessionRef);
|
|
1593
|
+
outcome = await tmux.killSessionRef(sessionRef, { kind: 'equals', claim: agentId });
|
|
1487
1594
|
}
|
|
1488
1595
|
catch (err) {
|
|
1489
1596
|
throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1490
1597
|
}
|
|
1491
|
-
if (outcome === '
|
|
1492
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux
|
|
1598
|
+
if (outcome === 'refused') {
|
|
1599
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux generation/claim changed while replacing session (${reason}); retry to observe the new state`);
|
|
1493
1600
|
}
|
|
1494
1601
|
if (outcome === 'killed') {
|
|
1495
1602
|
console.warn(`[AgentManager] ${agentId}: killed session ${sessionRef.sessionId} (${reason})`);
|
|
@@ -1498,7 +1605,17 @@ export class AgentManager {
|
|
|
1498
1605
|
console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
|
|
1499
1606
|
}
|
|
1500
1607
|
}
|
|
1501
|
-
|
|
1608
|
+
// 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.
|
|
1609
|
+
bindingInitPredicate(agentId, genAtEntry) {
|
|
1610
|
+
return this.deletionGateOpen(agentId, genAtEntry) && this.getAgentConfig(agentId) !== undefined;
|
|
1611
|
+
}
|
|
1612
|
+
async acquireAgentForTask(agentId, taskId, phase,
|
|
1613
|
+
// 锁代必须由 acquire 原子交出:事后重读 claim/binding 的窗口里并发派发方可以
|
|
1614
|
+
// release + re-acquire 同一 agent/同一 task,读到的会是 successor 的代
|
|
1615
|
+
opts = {}) {
|
|
1616
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
1617
|
+
if (this.isDeletionInFlight(agentId))
|
|
1618
|
+
return false;
|
|
1502
1619
|
const cfg = this.getAgentConfig(agentId);
|
|
1503
1620
|
if (!cfg)
|
|
1504
1621
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
@@ -1522,16 +1639,45 @@ export class AgentManager {
|
|
|
1522
1639
|
const token = reuseLock ? existingToken : await this.lockManager.acquire(agentId, taskId);
|
|
1523
1640
|
if (!token)
|
|
1524
1641
|
return false;
|
|
1642
|
+
const releaseAcquired = async (originalErr) => {
|
|
1643
|
+
if (reuseLock)
|
|
1644
|
+
return;
|
|
1645
|
+
try {
|
|
1646
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, token);
|
|
1647
|
+
}
|
|
1648
|
+
catch (releaseErr) {
|
|
1649
|
+
console.warn(`[AgentManager] acquireAgentForTask(${agentId}, ${taskId}): lock release after ` +
|
|
1650
|
+
`${originalErr === undefined ? 'refused commit' : 'commit failure'} itself failed:`, releaseErr);
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1525
1653
|
const now = new Date().toISOString();
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1654
|
+
let commit;
|
|
1655
|
+
try {
|
|
1656
|
+
commit = await this.agentStore.update(agentId, (existing) => {
|
|
1657
|
+
if (existing === null && !this.bindingInitPredicate(agentId, genAtEntry))
|
|
1658
|
+
return AGENT_STORE_NOOP;
|
|
1659
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
1660
|
+
return AGENT_STORE_NOOP;
|
|
1661
|
+
return {
|
|
1662
|
+
...(existing ?? { id: agentId, projectId: cfg.projectId, updatedAt: now }),
|
|
1663
|
+
id: agentId,
|
|
1664
|
+
projectId: cfg.projectId,
|
|
1665
|
+
taskId,
|
|
1666
|
+
lockToken: token,
|
|
1667
|
+
...(phase === 'code' && !sameTaskReentry ? { bootstrappingTaskId: taskId } : {}),
|
|
1668
|
+
updatedAt: now,
|
|
1669
|
+
};
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
catch (err) {
|
|
1673
|
+
await releaseAcquired(err);
|
|
1674
|
+
throw err;
|
|
1675
|
+
}
|
|
1676
|
+
if (commit !== 'committed') {
|
|
1677
|
+
await releaseAcquired();
|
|
1678
|
+
return false;
|
|
1679
|
+
}
|
|
1680
|
+
opts.onAcquired?.(token);
|
|
1535
1681
|
return true;
|
|
1536
1682
|
}
|
|
1537
1683
|
async releaseAgentForTask(agentId, expectedTaskId, mode, opts = {}) {
|
|
@@ -1618,11 +1764,15 @@ export class AgentManager {
|
|
|
1618
1764
|
...(latest.workdir !== undefined ? { workdir: latest.workdir } : {}),
|
|
1619
1765
|
...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
|
|
1620
1766
|
...(latest.creationToken !== undefined ? { creationToken: latest.creationToken } : {}),
|
|
1767
|
+
// Release voids the open question: strip the watermark, bump the epoch so
|
|
1768
|
+
// any in-flight/queued commit of the old generation fences server-side.
|
|
1769
|
+
needInput: { epoch: (latest.needInput?.epoch ?? 0) + 1 },
|
|
1621
1770
|
};
|
|
1622
1771
|
return cleared;
|
|
1623
1772
|
}
|
|
1624
1773
|
return {
|
|
1625
1774
|
...latest,
|
|
1775
|
+
needInput: { epoch: (latest.needInput?.epoch ?? 0) + 1 },
|
|
1626
1776
|
updatedAt: now,
|
|
1627
1777
|
};
|
|
1628
1778
|
});
|
|
@@ -1630,6 +1780,7 @@ export class AgentManager {
|
|
|
1630
1780
|
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} holds a newer generation than the captured one; keeping the hold`);
|
|
1631
1781
|
return false;
|
|
1632
1782
|
}
|
|
1783
|
+
this.clearNeedInputRetryFor(agentId, expectedTaskId);
|
|
1633
1784
|
return true;
|
|
1634
1785
|
}
|
|
1635
1786
|
let sessionConfirmedAbsent = false;
|
|
@@ -1691,7 +1842,7 @@ export class AgentManager {
|
|
|
1691
1842
|
return hold(runtime.reason);
|
|
1692
1843
|
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1693
1844
|
if (runtime.kind === 'pane') {
|
|
1694
|
-
if (state.paneId !== runtime.paneId) {
|
|
1845
|
+
if (state.paneId !== runtime.pane.paneId) {
|
|
1695
1846
|
await this.agentStore.update(agentId, (latest) => {
|
|
1696
1847
|
if (!latest
|
|
1697
1848
|
|| latest.taskId !== expectedTaskId
|
|
@@ -1699,14 +1850,14 @@ export class AgentManager {
|
|
|
1699
1850
|
|| latest.workdir !== releaseWorkdir) {
|
|
1700
1851
|
return AGENT_STORE_NOOP;
|
|
1701
1852
|
}
|
|
1702
|
-
return { ...latest, paneId: runtime.paneId, updatedAt: new Date().toISOString() };
|
|
1853
|
+
return { ...latest, paneId: runtime.pane.paneId, updatedAt: new Date().toISOString() };
|
|
1703
1854
|
});
|
|
1704
1855
|
const refreshed = await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1705
|
-
if (refreshed.paneId !== runtime.paneId) {
|
|
1856
|
+
if (refreshed.paneId !== runtime.pane.paneId) {
|
|
1706
1857
|
throw new Error(`Agent ${agentId} runtime pane changed during release; operation aborted`);
|
|
1707
1858
|
}
|
|
1708
1859
|
}
|
|
1709
|
-
await this.waitForReplPromptReady(tmux, runtime.
|
|
1860
|
+
await this.waitForReplPromptReady(tmux, runtime.pane, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
|
|
1710
1861
|
}
|
|
1711
1862
|
else {
|
|
1712
1863
|
sessionConfirmedAbsent = true;
|
|
@@ -1784,6 +1935,14 @@ export class AgentManager {
|
|
|
1784
1935
|
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1785
1936
|
}
|
|
1786
1937
|
catch (err) {
|
|
1938
|
+
// REPL 忙碌不是清理失败:pane 完好、绑定未动、非 dev 无分支凭据可留。落
|
|
1939
|
+
// branch-cleanup-pending(不在可恢复白名单)会把常态忙碌变成永久 hold(#558 验收 1),
|
|
1940
|
+
// 故拒绝释放而不落 hold,由调用方按「仍绑定且未 hold」判定重新排队本 pass
|
|
1941
|
+
if (opts.deferWhenBusy && err instanceof ReplNotReadyError && cfg.role !== 'dev') {
|
|
1942
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} REPL is busy; refusing release without a hold ` +
|
|
1943
|
+
`(the pass can be re-queued): ${err.message}`);
|
|
1944
|
+
return false;
|
|
1945
|
+
}
|
|
1787
1946
|
const reason = err instanceof DirtyWorkdirError
|
|
1788
1947
|
? err.message
|
|
1789
1948
|
: `checkout cleanup failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -1816,6 +1975,7 @@ export class AgentManager {
|
|
|
1816
1975
|
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
1817
1976
|
...(!sessionConfirmedAbsent && existing.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
1818
1977
|
...(existing.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
1978
|
+
needInput: { epoch: (existing.needInput?.epoch ?? 0) + 1 },
|
|
1819
1979
|
updatedAt: now,
|
|
1820
1980
|
};
|
|
1821
1981
|
});
|
|
@@ -1823,6 +1983,7 @@ export class AgentManager {
|
|
|
1823
1983
|
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed during release; keeping the hold`);
|
|
1824
1984
|
return false;
|
|
1825
1985
|
}
|
|
1986
|
+
this.clearNeedInputRetryFor(agentId, expectedTaskId);
|
|
1826
1987
|
return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
|
|
1827
1988
|
});
|
|
1828
1989
|
}
|
|
@@ -1909,6 +2070,8 @@ export class AgentManager {
|
|
|
1909
2070
|
if (candidateTasks.length === 0)
|
|
1910
2071
|
continue;
|
|
1911
2072
|
const candidateByBranch = new Map(candidateTasks.map(task => [task.branch, task]));
|
|
2073
|
+
// 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.
|
|
2074
|
+
const genAtEntry = this.deletionGenerationOf(cfg.id);
|
|
1912
2075
|
const state = await this.agentStore.get(cfg.id);
|
|
1913
2076
|
if (!state || !canDispatchWithBinding(state))
|
|
1914
2077
|
continue;
|
|
@@ -1928,11 +2091,15 @@ export class AgentManager {
|
|
|
1928
2091
|
}
|
|
1929
2092
|
if (!refs.some(ref => candidateByBranch.has(ref.slice('refs/heads/'.length))))
|
|
1930
2093
|
continue;
|
|
2094
|
+
if (this.isDeletionInFlight(cfg.id))
|
|
2095
|
+
continue;
|
|
1931
2096
|
const token = await this.lockManager.acquire(cfg.id, owner);
|
|
1932
2097
|
if (!token)
|
|
1933
2098
|
continue;
|
|
1934
2099
|
const cleanupResults = [];
|
|
1935
2100
|
try {
|
|
2101
|
+
if (!this.deletionGateOpen(cfg.id, genAtEntry))
|
|
2102
|
+
continue;
|
|
1936
2103
|
const lockedState = await this.agentStore.get(cfg.id);
|
|
1937
2104
|
if (!lockedState || !canDispatchWithBinding(lockedState))
|
|
1938
2105
|
continue;
|
|
@@ -1944,12 +2111,8 @@ export class AgentManager {
|
|
|
1944
2111
|
continue;
|
|
1945
2112
|
const tmux = new TmuxManager(runner);
|
|
1946
2113
|
if (await tmux.hasSession(cfg.id)) {
|
|
1947
|
-
const
|
|
1948
|
-
|
|
1949
|
-
throw new Error(`tmux session claim mismatch for ${cfg.id}: ${claim ?? '<missing>'}`);
|
|
1950
|
-
}
|
|
1951
|
-
const paneId = lockedState.paneId ?? await tmux.getSinglePaneId(cfg.id);
|
|
1952
|
-
await this.waitForReplPromptReady(tmux, paneId, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
|
|
2114
|
+
const pane = await this.resolveClaimedPane(tmux, cfg.id, lockedState.paneId ?? undefined);
|
|
2115
|
+
await this.waitForReplPromptReady(tmux, pane, agentRuntimeKindFor(cfg), this.cleanComposerWaitMs);
|
|
1953
2116
|
}
|
|
1954
2117
|
if (!(await this.lockManager.isOwner(cfg.id, owner, token)))
|
|
1955
2118
|
continue;
|
|
@@ -2006,48 +2169,469 @@ export class AgentManager {
|
|
|
2006
2169
|
}
|
|
2007
2170
|
}
|
|
2008
2171
|
}
|
|
2009
|
-
|
|
2010
|
-
|
|
2172
|
+
// ---- need-input watermark: epoch-fenced monotonic persistence + retry convergence ----
|
|
2173
|
+
needInputRetry = new Map();
|
|
2174
|
+
needInputRetryInFlight = new Set();
|
|
2175
|
+
// Watermark writes currently in the store chain. A restore bump snapshots these along
|
|
2176
|
+
// with the queue: an answer whose write has not settled yet would otherwise be invisible
|
|
2177
|
+
// to the migration and its error-enqueue would land after the arm already cleared the
|
|
2178
|
+
// queue — deleting the only proof the user answered.
|
|
2179
|
+
needInputWritesInFlight = new Map();
|
|
2180
|
+
needInputWriteSeq = 0;
|
|
2181
|
+
// Serializes the probe→bump→queue-clear section of concurrent arms for one
|
|
2182
|
+
// (agentId, taskId): without it a stale replay probed before a successor's bump can
|
|
2183
|
+
// itself bump afterwards, ghost-fencing the installed successor. All locked steps are
|
|
2184
|
+
// bounded (memory probe + one store update); subscribe stays outside the lock.
|
|
2185
|
+
needInputArmLocks = new Map();
|
|
2186
|
+
needInputRetryTimer = null;
|
|
2187
|
+
needInputRetryKey(agentId, taskId, epoch) {
|
|
2188
|
+
return `${agentId} ${taskId} ${epoch}`;
|
|
2189
|
+
}
|
|
2190
|
+
parseNeedInputRetryKey(key) {
|
|
2191
|
+
const [agentId, taskId, epochRaw] = key.split(' ');
|
|
2192
|
+
if (!agentId || !taskId || epochRaw === undefined)
|
|
2193
|
+
return null;
|
|
2194
|
+
const epoch = Number.parseInt(epochRaw, 10);
|
|
2195
|
+
if (!Number.isFinite(epoch))
|
|
2196
|
+
return null;
|
|
2197
|
+
return { agentId, taskId, epoch };
|
|
2198
|
+
}
|
|
2199
|
+
// Standard commit entry: one agentStore.update, NEVER called from inside another
|
|
2200
|
+
// updater (the store's per-id chain is non-reentrant).
|
|
2201
|
+
// The ledger entry lives until the result is fully handed over — an error is IN the
|
|
2202
|
+
// retry queue before deregistration, in the same synchronous continuation. Splitting
|
|
2203
|
+
// those two across promise jobs would open a window where neither the ledger nor the
|
|
2204
|
+
// queue holds the intent and a concurrent restore snapshot loses the answer.
|
|
2205
|
+
async commitNeedInputWatermark(intent) {
|
|
2206
|
+
const writeId = ++this.needInputWriteSeq;
|
|
2207
|
+
this.needInputWritesInFlight.set(writeId, intent);
|
|
2208
|
+
const key = this.needInputRetryKey(intent.agentId, intent.taskId, intent.epoch);
|
|
2011
2209
|
try {
|
|
2012
|
-
await this.
|
|
2013
|
-
|
|
2210
|
+
const result = await this.writeNeedInputWatermark(intent);
|
|
2211
|
+
if (result === 'error') {
|
|
2212
|
+
this.enqueueNeedInputRetry(key, intent.askSeq, intent.answeredSeq);
|
|
2213
|
+
}
|
|
2214
|
+
else {
|
|
2215
|
+
this.settleNeedInputRetry(key, intent.askSeq, intent.answeredSeq, result);
|
|
2216
|
+
}
|
|
2217
|
+
return result;
|
|
2218
|
+
}
|
|
2219
|
+
catch (err) {
|
|
2220
|
+
this.enqueueNeedInputRetry(key, intent.askSeq, intent.answeredSeq);
|
|
2221
|
+
throw err;
|
|
2222
|
+
}
|
|
2223
|
+
finally {
|
|
2224
|
+
this.needInputWritesInFlight.delete(writeId);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
async writeNeedInputWatermark(intent) {
|
|
2228
|
+
let outcome = 'fenced';
|
|
2229
|
+
try {
|
|
2230
|
+
await this.agentStore.update(intent.agentId, (existing) => {
|
|
2231
|
+
if (!existing || existing.taskId !== intent.taskId) {
|
|
2232
|
+
outcome = 'fenced';
|
|
2014
2233
|
return AGENT_STORE_NOOP;
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
if (
|
|
2234
|
+
}
|
|
2235
|
+
const cur = existing.needInput;
|
|
2236
|
+
if ((cur?.epoch ?? 0) !== intent.epoch) {
|
|
2237
|
+
outcome = 'fenced';
|
|
2018
2238
|
return AGENT_STORE_NOOP;
|
|
2019
|
-
if (pending) {
|
|
2020
|
-
if (existing.needInputAt !== undefined)
|
|
2021
|
-
return AGENT_STORE_NOOP;
|
|
2022
|
-
return { ...existing, needInputAt: now, updatedAt: now };
|
|
2023
2239
|
}
|
|
2024
|
-
|
|
2240
|
+
const askSeq = Math.max(cur?.askSeq ?? 0, intent.askSeq);
|
|
2241
|
+
const answeredSeq = Math.max(cur?.answeredSeq ?? 0, intent.answeredSeq);
|
|
2242
|
+
outcome = 'ok';
|
|
2243
|
+
if (cur && (cur.askSeq ?? 0) === askSeq && (cur.answeredSeq ?? 0) === answeredSeq) {
|
|
2244
|
+
return AGENT_STORE_NOOP;
|
|
2245
|
+
}
|
|
2246
|
+
const now = new Date().toISOString();
|
|
2247
|
+
const at = askSeq > answeredSeq ? (cur?.at ?? now) : undefined;
|
|
2248
|
+
return {
|
|
2249
|
+
...existing,
|
|
2250
|
+
needInput: {
|
|
2251
|
+
epoch: intent.epoch, askSeq, answeredSeq,
|
|
2252
|
+
...(at !== undefined ? { at } : {}),
|
|
2253
|
+
...(cur?.cutoverToken !== undefined ? { cutoverToken: cur.cutoverToken } : {}),
|
|
2254
|
+
},
|
|
2255
|
+
updatedAt: now,
|
|
2256
|
+
};
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
catch (err) {
|
|
2260
|
+
console.warn(`[AgentManager] needInput watermark write failed (agent=${intent.agentId} epoch=${intent.epoch}):`, err);
|
|
2261
|
+
return 'error';
|
|
2262
|
+
}
|
|
2263
|
+
return outcome;
|
|
2264
|
+
}
|
|
2265
|
+
enqueueNeedInputRetry(key, askSeq, answeredSeq) {
|
|
2266
|
+
const cur = this.needInputRetry.get(key);
|
|
2267
|
+
this.needInputRetry.set(key, {
|
|
2268
|
+
askSeq: Math.max(cur?.askSeq ?? 0, askSeq),
|
|
2269
|
+
answeredSeq: Math.max(cur?.answeredSeq ?? 0, answeredSeq),
|
|
2270
|
+
});
|
|
2271
|
+
this.ensureNeedInputRetryTimer();
|
|
2272
|
+
}
|
|
2273
|
+
// ok deletes only when the queued item is still covered by what was written
|
|
2274
|
+
// (a concurrent edge may have raised it mid-flight); fenced is terminal either way.
|
|
2275
|
+
settleNeedInputRetry(key, wroteAskSeq, wroteAnsweredSeq, result) {
|
|
2276
|
+
const cur = this.needInputRetry.get(key);
|
|
2277
|
+
if (!cur)
|
|
2278
|
+
return;
|
|
2279
|
+
if (result === 'fenced' || (cur.askSeq <= wroteAskSeq && cur.answeredSeq <= wroteAnsweredSeq)) {
|
|
2280
|
+
this.needInputRetry.delete(key);
|
|
2281
|
+
this.ensureNeedInputRetryTimer();
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
clearNeedInputRetryFor(agentId, taskId) {
|
|
2285
|
+
for (const key of [...this.needInputRetry.keys()]) {
|
|
2286
|
+
const parsed = this.parseNeedInputRetryKey(key);
|
|
2287
|
+
if (parsed && parsed.agentId === agentId && parsed.taskId === taskId) {
|
|
2288
|
+
this.needInputRetry.delete(key);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
this.ensureNeedInputRetryTimer();
|
|
2292
|
+
}
|
|
2293
|
+
ensureNeedInputRetryTimer() {
|
|
2294
|
+
if (this.needInputRetry.size === 0) {
|
|
2295
|
+
if (this.needInputRetryTimer) {
|
|
2296
|
+
clearInterval(this.needInputRetryTimer);
|
|
2297
|
+
this.needInputRetryTimer = null;
|
|
2298
|
+
}
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
if (this.needInputRetryTimer)
|
|
2302
|
+
return;
|
|
2303
|
+
this.needInputRetryTimer = setInterval(() => {
|
|
2304
|
+
void this.needInputRetryPass();
|
|
2305
|
+
}, this.needInputRetryIntervalMs);
|
|
2306
|
+
this.needInputRetryTimer.unref?.();
|
|
2307
|
+
}
|
|
2308
|
+
async needInputRetryPass() {
|
|
2309
|
+
for (const [key, snapshot] of [...this.needInputRetry.entries()]) {
|
|
2310
|
+
if (this.needInputRetryInFlight.has(key))
|
|
2311
|
+
continue;
|
|
2312
|
+
const parsed = this.parseNeedInputRetryKey(key);
|
|
2313
|
+
if (!parsed) {
|
|
2314
|
+
this.needInputRetry.delete(key);
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
this.needInputRetryInFlight.add(key);
|
|
2318
|
+
try {
|
|
2319
|
+
const result = await this.writeNeedInputWatermark({
|
|
2320
|
+
agentId: parsed.agentId,
|
|
2321
|
+
taskId: parsed.taskId,
|
|
2322
|
+
epoch: parsed.epoch,
|
|
2323
|
+
askSeq: snapshot.askSeq,
|
|
2324
|
+
answeredSeq: snapshot.answeredSeq,
|
|
2325
|
+
});
|
|
2326
|
+
if (result !== 'error') {
|
|
2327
|
+
this.settleNeedInputRetry(key, snapshot.askSeq, snapshot.answeredSeq, result);
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
finally {
|
|
2331
|
+
this.needInputRetryInFlight.delete(key);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
this.ensureNeedInputRetryTimer();
|
|
2335
|
+
}
|
|
2336
|
+
collectSameEpochNeedInput(agentId, taskId, epoch) {
|
|
2337
|
+
let owed = null;
|
|
2338
|
+
const merge = (wm) => {
|
|
2339
|
+
owed = {
|
|
2340
|
+
askSeq: Math.max(owed?.askSeq ?? 0, wm.askSeq),
|
|
2341
|
+
answeredSeq: Math.max(owed?.answeredSeq ?? 0, wm.answeredSeq),
|
|
2342
|
+
};
|
|
2343
|
+
};
|
|
2344
|
+
for (const [key, wm] of this.needInputRetry) {
|
|
2345
|
+
const parsed = this.parseNeedInputRetryKey(key);
|
|
2346
|
+
if (!parsed || parsed.agentId !== agentId || parsed.taskId !== taskId)
|
|
2347
|
+
continue;
|
|
2348
|
+
if (parsed.epoch === epoch)
|
|
2349
|
+
merge(wm);
|
|
2350
|
+
}
|
|
2351
|
+
for (const intent of this.needInputWritesInFlight.values()) {
|
|
2352
|
+
if (intent.agentId !== agentId || intent.taskId !== taskId)
|
|
2353
|
+
continue;
|
|
2354
|
+
if (intent.epoch === epoch)
|
|
2355
|
+
merge(intent);
|
|
2356
|
+
}
|
|
2357
|
+
return owed;
|
|
2358
|
+
}
|
|
2359
|
+
// Only the generation this arm directly succeeds (bumps are +1 and serialized per key).
|
|
2360
|
+
// Anything older was already superseded — a fresh arm in between stripped its question
|
|
2361
|
+
// on purpose, and transplanting it would relight a prompt nobody asked.
|
|
2362
|
+
collectPredecessorNeedInputDelta(agentId, taskId, epoch) {
|
|
2363
|
+
let delta = null;
|
|
2364
|
+
const merge = (wm) => {
|
|
2365
|
+
delta = {
|
|
2366
|
+
askSeq: Math.max(delta?.askSeq ?? 0, wm.askSeq),
|
|
2367
|
+
answeredSeq: Math.max(delta?.answeredSeq ?? 0, wm.answeredSeq),
|
|
2368
|
+
};
|
|
2369
|
+
};
|
|
2370
|
+
for (const [key, wm] of this.needInputRetry) {
|
|
2371
|
+
const parsed = this.parseNeedInputRetryKey(key);
|
|
2372
|
+
if (!parsed || parsed.agentId !== agentId || parsed.taskId !== taskId)
|
|
2373
|
+
continue;
|
|
2374
|
+
if (parsed.epoch === epoch - 1)
|
|
2375
|
+
merge(wm);
|
|
2376
|
+
}
|
|
2377
|
+
for (const intent of this.needInputWritesInFlight.values()) {
|
|
2378
|
+
if (intent.agentId !== agentId || intent.taskId !== taskId)
|
|
2379
|
+
continue;
|
|
2380
|
+
if (intent.epoch === epoch - 1)
|
|
2381
|
+
merge(intent);
|
|
2382
|
+
}
|
|
2383
|
+
return delta;
|
|
2384
|
+
}
|
|
2385
|
+
async withNeedInputArmLock(agentId, taskId, fn) {
|
|
2386
|
+
const key = `${agentId} ${taskId}`;
|
|
2387
|
+
const prev = this.needInputArmLocks.get(key) ?? Promise.resolve();
|
|
2388
|
+
const run = prev.then(fn);
|
|
2389
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
2390
|
+
this.needInputArmLocks.set(key, settled);
|
|
2391
|
+
void settled.finally(() => {
|
|
2392
|
+
if (this.needInputArmLocks.get(key) === settled)
|
|
2393
|
+
this.needInputArmLocks.delete(key);
|
|
2394
|
+
});
|
|
2395
|
+
return run;
|
|
2396
|
+
}
|
|
2397
|
+
// 'restore' must also merge queued retry seqs of the CURRENT persisted epoch: after
|
|
2398
|
+
// session-gone the queue can hold the only proof an answer happened (the entry is
|
|
2399
|
+
// gone, so needInputInherit has nothing to merge), and the arm clears the queue.
|
|
2400
|
+
async bumpNeedInputEpochForArm(agentId, taskId, mode, opts = {}) {
|
|
2401
|
+
// Contributions are collected INSIDE the updater: it runs when the store chain
|
|
2402
|
+
// reaches this bump, so an answer whose commit started while the bump was queued
|
|
2403
|
+
// (registered in the ledger, or already error-queued) is still visible. A snapshot
|
|
2404
|
+
// taken at call time would miss exactly that window.
|
|
2405
|
+
const collectContributions = () => {
|
|
2406
|
+
const queued = new Map();
|
|
2407
|
+
if (mode !== 'restore')
|
|
2408
|
+
return queued;
|
|
2409
|
+
const mergeContribution = (epoch, wm) => {
|
|
2410
|
+
const cur = queued.get(epoch);
|
|
2411
|
+
queued.set(epoch, {
|
|
2412
|
+
askSeq: Math.max(cur?.askSeq ?? 0, wm.askSeq),
|
|
2413
|
+
answeredSeq: Math.max(cur?.answeredSeq ?? 0, wm.answeredSeq),
|
|
2414
|
+
});
|
|
2415
|
+
};
|
|
2416
|
+
for (const [key, qwm] of this.needInputRetry) {
|
|
2417
|
+
const parsed = this.parseNeedInputRetryKey(key);
|
|
2418
|
+
if (!parsed || parsed.agentId !== agentId || parsed.taskId !== taskId)
|
|
2419
|
+
continue;
|
|
2420
|
+
mergeContribution(parsed.epoch, qwm);
|
|
2421
|
+
}
|
|
2422
|
+
for (const intent of this.needInputWritesInFlight.values()) {
|
|
2423
|
+
if (intent.agentId !== agentId || intent.taskId !== taskId)
|
|
2424
|
+
continue;
|
|
2425
|
+
mergeContribution(intent.epoch, intent);
|
|
2426
|
+
}
|
|
2427
|
+
return queued;
|
|
2428
|
+
};
|
|
2429
|
+
let wm = null;
|
|
2430
|
+
let snapshotBlind = false;
|
|
2431
|
+
// Scoped to THIS arm's token: a marker left by another token's replay says nothing
|
|
2432
|
+
// about the pane's ambiguity for us, so it is neither honoured nor carried forward.
|
|
2433
|
+
const keepCutover = (cur) => (opts.paneCutover
|
|
2434
|
+
? opts.armToken
|
|
2435
|
+
: cur !== undefined && cur.cutoverToken === opts.armToken ? cur.cutoverToken : undefined);
|
|
2436
|
+
try {
|
|
2437
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
2438
|
+
if (!existing || existing.taskId !== taskId)
|
|
2025
2439
|
return AGENT_STORE_NOOP;
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2440
|
+
const cur = existing.needInput;
|
|
2441
|
+
const contrib = collectContributions().get(cur?.epoch ?? 0);
|
|
2442
|
+
const epoch = (cur?.epoch ?? 0) + 1;
|
|
2443
|
+
const askSeq = mode === 'restore' ? Math.max(cur?.askSeq ?? 0, contrib?.askSeq ?? 0) : 0;
|
|
2444
|
+
const answeredSeq = mode === 'restore'
|
|
2445
|
+
? Math.max(cur?.answeredSeq ?? 0, contrib?.answeredSeq ?? 0)
|
|
2446
|
+
: 0;
|
|
2447
|
+
wm = { epoch, askSeq, answeredSeq };
|
|
2448
|
+
const cutoverToken = keepCutover(cur);
|
|
2449
|
+
snapshotBlind = cutoverToken !== undefined;
|
|
2450
|
+
const at = askSeq > answeredSeq ? (cur?.at ?? new Date().toISOString()) : undefined;
|
|
2451
|
+
return {
|
|
2452
|
+
...existing,
|
|
2453
|
+
needInput: {
|
|
2454
|
+
epoch, askSeq, answeredSeq,
|
|
2455
|
+
...(at !== undefined ? { at } : {}),
|
|
2456
|
+
...(cutoverToken !== undefined ? { cutoverToken } : {}),
|
|
2457
|
+
},
|
|
2458
|
+
updatedAt: new Date().toISOString(),
|
|
2459
|
+
};
|
|
2028
2460
|
});
|
|
2029
2461
|
}
|
|
2030
2462
|
catch (err) {
|
|
2031
|
-
console.warn(`[AgentManager]
|
|
2463
|
+
console.warn(`[AgentManager] needInput epoch bump failed (agent=${agentId} task=${taskId}):`, err);
|
|
2464
|
+
// Fall back to the persisted watermark for a restore: without it the entry would
|
|
2465
|
+
// start at 0/0 and swallow the answer to a question that is still open in the
|
|
2466
|
+
// store, leaving the badge lit forever. No bump means no generation advance —
|
|
2467
|
+
// the caller must not treat this as a fresh generation.
|
|
2468
|
+
if (mode === 'restore') {
|
|
2469
|
+
try {
|
|
2470
|
+
const cur = await this.agentStore.get(agentId);
|
|
2471
|
+
if (cur?.taskId === taskId && cur.needInput) {
|
|
2472
|
+
// Same-generation intents still owed to the store hold questions the
|
|
2473
|
+
// persisted watermark has not caught up with; merge them in.
|
|
2474
|
+
const owed = this.collectSameEpochNeedInput(agentId, taskId, cur.needInput.epoch);
|
|
2475
|
+
return {
|
|
2476
|
+
wm: {
|
|
2477
|
+
epoch: cur.needInput.epoch,
|
|
2478
|
+
askSeq: Math.max(cur.needInput.askSeq ?? 0, owed?.askSeq ?? 0),
|
|
2479
|
+
answeredSeq: Math.max(cur.needInput.answeredSeq ?? 0, owed?.answeredSeq ?? 0),
|
|
2480
|
+
},
|
|
2481
|
+
bumped: false,
|
|
2482
|
+
snapshotBlind: cur.needInput.cutoverToken === opts.armToken,
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
catch (readErr) {
|
|
2487
|
+
console.warn(`[AgentManager] needInput watermark read-back failed (agent=${agentId}):`, readErr);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
return { wm: null, bumped: false, snapshotBlind: true };
|
|
2032
2491
|
}
|
|
2492
|
+
return { wm, bumped: wm !== null, snapshotBlind: wm === null ? true : snapshotBlind };
|
|
2033
2493
|
}
|
|
2034
|
-
// Single entry for arming phase-signal watches
|
|
2035
|
-
//
|
|
2036
|
-
|
|
2037
|
-
startPhaseSignalWatch(args) {
|
|
2494
|
+
// Single entry for arming phase-signal watches — every path must flow through the
|
|
2495
|
+
// epoch bump, or stale-generation commits could survive the re-arm.
|
|
2496
|
+
async startPhaseSignalWatch(args) {
|
|
2038
2497
|
if (!this.phaseSignalWatcher)
|
|
2039
|
-
return
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2498
|
+
return true;
|
|
2499
|
+
const { needInputMode, needInputPaneCutover: _paneCutover, ...rest } = args;
|
|
2500
|
+
const mode = needInputMode ?? 'restore';
|
|
2501
|
+
const watcher = this.phaseSignalWatcher;
|
|
2502
|
+
// Claim ownership before bumping: a rejected arm must not bump (it would fence the
|
|
2503
|
+
// owner), and the claim keeps this arm visible to later probes while it subscribes.
|
|
2504
|
+
const gateResult = await this.withNeedInputArmLock(args.agentId, args.taskId, async () => {
|
|
2505
|
+
const claim = watcher.claimArm?.({
|
|
2506
|
+
taskId: args.taskId,
|
|
2507
|
+
agentId: args.agentId,
|
|
2508
|
+
token: args.token,
|
|
2509
|
+
...(args.onlyReplaceOwnToken !== undefined ? { onlyReplaceOwnToken: args.onlyReplaceOwnToken } : {}),
|
|
2510
|
+
...(args.replaceScope !== undefined ? { replaceScope: args.replaceScope } : {}),
|
|
2511
|
+
});
|
|
2512
|
+
if (claim === null)
|
|
2513
|
+
return 'rejected';
|
|
2514
|
+
const bump = await this.bumpNeedInputEpochForArm(args.agentId, args.taskId, mode, {
|
|
2515
|
+
armToken: args.token,
|
|
2516
|
+
...(args.needInputPaneCutover ? { paneCutover: true } : {}),
|
|
2517
|
+
});
|
|
2518
|
+
// Only a real generation advance may drop the queue: same-generation intents
|
|
2519
|
+
// (bump failed, watermark read back) are still owed to the store.
|
|
2520
|
+
if (bump.bumped)
|
|
2521
|
+
this.clearNeedInputRetryFor(args.agentId, args.taskId);
|
|
2522
|
+
return { ...bump, claim };
|
|
2045
2523
|
});
|
|
2524
|
+
if (gateResult === 'rejected')
|
|
2525
|
+
return false;
|
|
2526
|
+
const { wm, bumped, snapshotBlind, claim } = gateResult;
|
|
2527
|
+
// Same synchronous section as the bump result: old-generation answers must not slip
|
|
2528
|
+
// between the bump and the handoff (start() migrates the predecessor synchronously).
|
|
2529
|
+
if (wm !== null && bumped && mode === 'restore') {
|
|
2530
|
+
const delta = this.collectPredecessorNeedInputDelta(args.agentId, args.taskId, wm.epoch);
|
|
2531
|
+
if (delta) {
|
|
2532
|
+
void this.commitNeedInputWatermark({
|
|
2533
|
+
agentId: args.agentId,
|
|
2534
|
+
taskId: args.taskId,
|
|
2535
|
+
epoch: wm.epoch,
|
|
2536
|
+
askSeq: Math.max(delta.askSeq, wm.askSeq),
|
|
2537
|
+
answeredSeq: Math.max(delta.answeredSeq, wm.answeredSeq),
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
// wm === null (no binding / store error): arm anyway with badge commits disabled —
|
|
2542
|
+
// the badge is display-only, the signal watch drives task progress.
|
|
2543
|
+
try {
|
|
2544
|
+
return await watcher.start({
|
|
2545
|
+
...rest,
|
|
2546
|
+
...(claim != null ? { armClaimId: claim } : {}),
|
|
2547
|
+
// Inherit is about restore semantics, not about having a generation: a degraded
|
|
2548
|
+
// restore still has to keep the predecessor's pending question in memory.
|
|
2549
|
+
needInputInherit: mode === 'restore',
|
|
2550
|
+
...(snapshotBlind ? { needInputSnapshotBlind: true } : {}),
|
|
2551
|
+
...(wm !== null ? { needInput: wm } : {}),
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
finally {
|
|
2555
|
+
watcher.releaseArm?.(claim);
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
// Web-fallback confirm: settles the newest known question (store ∪ retry queue) as
|
|
2559
|
+
// answered in ONE store update — usable when no watch entry exists (pre-recovery,
|
|
2560
|
+
// arm failure, post-stop). Queue contributions are snapshotted synchronously; the
|
|
2561
|
+
// updater derives everything else from `existing`, so no nested store calls.
|
|
2562
|
+
async confirmNeedInputAnswered(agentId) {
|
|
2563
|
+
const queueAsk = new Map();
|
|
2564
|
+
for (const [key, wm] of this.needInputRetry) {
|
|
2565
|
+
const parsed = this.parseNeedInputRetryKey(key);
|
|
2566
|
+
if (!parsed || parsed.agentId !== agentId)
|
|
2567
|
+
continue;
|
|
2568
|
+
const mapKey = `${parsed.taskId} ${parsed.epoch}`;
|
|
2569
|
+
queueAsk.set(mapKey, Math.max(queueAsk.get(mapKey) ?? 0, wm.askSeq));
|
|
2570
|
+
}
|
|
2571
|
+
for (const intent of this.needInputWritesInFlight.values()) {
|
|
2572
|
+
if (intent.agentId !== agentId)
|
|
2573
|
+
continue;
|
|
2574
|
+
const mapKey = `${intent.taskId} ${intent.epoch}`;
|
|
2575
|
+
queueAsk.set(mapKey, Math.max(queueAsk.get(mapKey) ?? 0, intent.askSeq));
|
|
2576
|
+
}
|
|
2577
|
+
let settled = null;
|
|
2578
|
+
try {
|
|
2579
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
2580
|
+
if (!existing?.taskId)
|
|
2581
|
+
return AGENT_STORE_NOOP;
|
|
2582
|
+
const cur = existing.needInput;
|
|
2583
|
+
const epoch = cur?.epoch ?? 0;
|
|
2584
|
+
const qAsk = queueAsk.get(`${existing.taskId} ${epoch}`) ?? 0;
|
|
2585
|
+
const askSeq = Math.max(cur?.askSeq ?? 0, qAsk);
|
|
2586
|
+
const answeredSeq = Math.max(cur?.answeredSeq ?? 0, askSeq);
|
|
2587
|
+
settled = { taskId: existing.taskId, epoch, askSeq };
|
|
2588
|
+
if (cur && (cur.askSeq ?? 0) === askSeq && (cur.answeredSeq ?? 0) === answeredSeq && cur.at === undefined) {
|
|
2589
|
+
return AGENT_STORE_NOOP;
|
|
2590
|
+
}
|
|
2591
|
+
if (!cur && askSeq === 0)
|
|
2592
|
+
return AGENT_STORE_NOOP;
|
|
2593
|
+
return {
|
|
2594
|
+
...existing,
|
|
2595
|
+
needInput: {
|
|
2596
|
+
epoch, askSeq, answeredSeq,
|
|
2597
|
+
...(cur?.cutoverToken !== undefined ? { cutoverToken: cur.cutoverToken } : {}),
|
|
2598
|
+
},
|
|
2599
|
+
updatedAt: new Date().toISOString(),
|
|
2600
|
+
};
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2603
|
+
catch (err) {
|
|
2604
|
+
console.warn(`[AgentManager] confirmNeedInputAnswered(${agentId}) failed:`, err);
|
|
2605
|
+
if (settled !== null) {
|
|
2606
|
+
const s = settled;
|
|
2607
|
+
this.enqueueNeedInputRetry(this.needInputRetryKey(agentId, s.taskId, s.epoch), s.askSeq, s.askSeq);
|
|
2608
|
+
}
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
if (settled !== null) {
|
|
2612
|
+
const s = settled;
|
|
2613
|
+
this.settleNeedInputRetry(this.needInputRetryKey(agentId, s.taskId, s.epoch), s.askSeq, s.askSeq, 'ok');
|
|
2614
|
+
}
|
|
2046
2615
|
}
|
|
2047
|
-
// The user typed into the agent's terminal — its
|
|
2616
|
+
// The user typed into the agent's terminal — its open question counts as answered.
|
|
2617
|
+
// The store fallback runs only when no watch entry exists (pre-recovery, arm failure,
|
|
2618
|
+
// post-stop): with a live entry it would race the entry's in-flight writes and could
|
|
2619
|
+
// confirm an ask that arrived after this input.
|
|
2048
2620
|
async notifyHumanTerminalInput(agentId) {
|
|
2049
|
-
|
|
2050
|
-
|
|
2621
|
+
// Rearm first: it settles entry state synchronously, so a question printed during
|
|
2622
|
+
// this call cannot be confirmed by mistake. The binding read only decides whether a
|
|
2623
|
+
// watcher of the CURRENT task took the input — a leftover entry of a finished task
|
|
2624
|
+
// must not suppress the store fallback.
|
|
2625
|
+
const handled = await this.phaseSignalWatcher?.rearmNeedInput(agentId);
|
|
2626
|
+
let taskId;
|
|
2627
|
+
try {
|
|
2628
|
+
taskId = (await this.agentStore.get(agentId))?.taskId;
|
|
2629
|
+
}
|
|
2630
|
+
catch (err) {
|
|
2631
|
+
console.warn(`[AgentManager] notifyHumanTerminalInput(${agentId}) binding read failed:`, err);
|
|
2632
|
+
}
|
|
2633
|
+
if (taskId === undefined || !handled?.has(taskId))
|
|
2634
|
+
await this.confirmNeedInputAnswered(agentId);
|
|
2051
2635
|
}
|
|
2052
2636
|
async clearAwaitingHuman(agentId, opts = {}) {
|
|
2053
2637
|
const now = new Date().toISOString();
|
|
@@ -2308,6 +2892,8 @@ export class AgentManager {
|
|
|
2308
2892
|
next.lockToken = existing.lockToken;
|
|
2309
2893
|
if (existing.startedAt !== undefined)
|
|
2310
2894
|
next.startedAt = existing.startedAt;
|
|
2895
|
+
if (existing.needInput !== undefined)
|
|
2896
|
+
next.needInput = existing.needInput;
|
|
2311
2897
|
}
|
|
2312
2898
|
return next;
|
|
2313
2899
|
});
|
|
@@ -2398,10 +2984,18 @@ export class AgentManager {
|
|
|
2398
2984
|
}
|
|
2399
2985
|
if (result.redispatchReviewTaskId) {
|
|
2400
2986
|
try {
|
|
2987
|
+
const reviewTask = await this.taskStore.get(result.redispatchReviewTaskId);
|
|
2988
|
+
const resumeEntry = this.getPendingDispatchRetry(result.redispatchReviewTaskId);
|
|
2989
|
+
const resumeQaPhase = resumeEntry?.kind === 'qa-recheck'
|
|
2990
|
+
&& resumeEntry.signalToken === reviewTask?.signalToken
|
|
2991
|
+
&& resumeEntry.qaPhase !== undefined
|
|
2992
|
+
? resumeEntry.qaPhase
|
|
2993
|
+
: (reviewTask?.reviewRound === 0 ? 'review' : undefined);
|
|
2401
2994
|
await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
|
|
2402
|
-
bumpRound:
|
|
2995
|
+
bumpRound: reviewTask?.reviewRoundPending === true,
|
|
2403
2996
|
fromStatus: ['review'],
|
|
2404
2997
|
...(result.reviewPassToken !== undefined ? { expectSignalToken: result.reviewPassToken } : {}),
|
|
2998
|
+
...(resumeQaPhase !== undefined ? { qaPhase: resumeQaPhase } : {}),
|
|
2405
2999
|
});
|
|
2406
3000
|
}
|
|
2407
3001
|
catch (err) {
|
|
@@ -2496,14 +3090,12 @@ export class AgentManager {
|
|
|
2496
3090
|
...(result.reason ? { reason: result.reason } : {}),
|
|
2497
3091
|
};
|
|
2498
3092
|
}
|
|
2499
|
-
async
|
|
2500
|
-
if (state.paneId)
|
|
2501
|
-
return state.paneId;
|
|
3093
|
+
async resolveClaimedPaneOrNull(state, cfg) {
|
|
2502
3094
|
try {
|
|
2503
|
-
return await new TmuxManager(this.createRunnerFor(cfg))
|
|
3095
|
+
return await this.resolveClaimedPane(new TmuxManager(this.createRunnerFor(cfg)), cfg.id, state.paneId);
|
|
2504
3096
|
}
|
|
2505
3097
|
catch (err) {
|
|
2506
|
-
console.warn(`[AgentManager]
|
|
3098
|
+
console.warn(`[AgentManager] resolveClaimedPane failed for ${cfg.id}:`, err);
|
|
2507
3099
|
return null;
|
|
2508
3100
|
}
|
|
2509
3101
|
}
|
|
@@ -2521,27 +3113,16 @@ export class AgentManager {
|
|
|
2521
3113
|
}
|
|
2522
3114
|
if (!sessionAlive)
|
|
2523
3115
|
return { kind: 'absent' };
|
|
2524
|
-
let claim;
|
|
2525
3116
|
try {
|
|
2526
|
-
|
|
2527
|
-
}
|
|
2528
|
-
catch (err) {
|
|
2529
|
-
return {
|
|
2530
|
-
kind: 'hold',
|
|
2531
|
-
reason: `Runtime session claim probe failed; refusing checkout cleanup: ` +
|
|
2532
|
-
`${err instanceof Error ? err.message : String(err)}`,
|
|
2533
|
-
};
|
|
2534
|
-
}
|
|
2535
|
-
if (claim !== agentId) {
|
|
2536
|
-
return {
|
|
2537
|
-
kind: 'hold',
|
|
2538
|
-
reason: `Runtime session claim mismatch (got "${claim ?? 'null'}"); refusing checkout cleanup`,
|
|
2539
|
-
};
|
|
2540
|
-
}
|
|
2541
|
-
try {
|
|
2542
|
-
return { kind: 'pane', paneId: await tmux.getSinglePaneId(agentId) };
|
|
3117
|
+
return { kind: 'pane', pane: await this.resolveClaimedPane(tmux, agentId) };
|
|
2543
3118
|
}
|
|
2544
3119
|
catch (err) {
|
|
3120
|
+
if (err instanceof PaneGoneError) {
|
|
3121
|
+
return {
|
|
3122
|
+
kind: 'hold',
|
|
3123
|
+
reason: `Runtime session claim probe/mismatch; refusing checkout cleanup: ${err.message}`,
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
2545
3126
|
return {
|
|
2546
3127
|
kind: 'hold',
|
|
2547
3128
|
reason: `Runtime pane probe failed; refusing checkout cleanup: ` +
|
|
@@ -2550,9 +3131,10 @@ export class AgentManager {
|
|
|
2550
3131
|
}
|
|
2551
3132
|
}
|
|
2552
3133
|
async interruptPaneAndWaitReady(state, cfg) {
|
|
2553
|
-
const
|
|
2554
|
-
if (!
|
|
3134
|
+
const pane = await this.resolveClaimedPaneOrNull(state, cfg);
|
|
3135
|
+
if (!pane)
|
|
2555
3136
|
return false;
|
|
3137
|
+
const paneId = pane.paneId;
|
|
2556
3138
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
2557
3139
|
const runtime = agentRuntimeKindFor(cfg);
|
|
2558
3140
|
if (!(await this.acquireCompactGuardWithin(cfg.id, this.cancelInterruptGuardWaitMs))) {
|
|
@@ -2563,43 +3145,44 @@ export class AgentManager {
|
|
|
2563
3145
|
}
|
|
2564
3146
|
try {
|
|
2565
3147
|
try {
|
|
2566
|
-
await tmux.sendKeysToPane(
|
|
3148
|
+
await tmux.sendKeysToPane(pane, 'Escape');
|
|
2567
3149
|
await new Promise(r => setTimeout(r, 200));
|
|
2568
3150
|
}
|
|
2569
3151
|
catch (err) {
|
|
2570
3152
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: send Escape failed for pane ${paneId}:`, err);
|
|
2571
3153
|
return false;
|
|
2572
3154
|
}
|
|
2573
|
-
if (await this.paneReachedReplReady(tmux,
|
|
3155
|
+
if (await this.paneReachedReplReady(tmux, pane, runtime, 10_000))
|
|
2574
3156
|
return true;
|
|
2575
|
-
if (!(await this.paneRunsRuntime(tmux,
|
|
3157
|
+
if (!(await this.paneRunsRuntime(tmux, pane, runtime)))
|
|
2576
3158
|
return false;
|
|
2577
|
-
if (await this.paneHasLiveTurn(tmux,
|
|
3159
|
+
if (await this.paneHasLiveTurn(tmux, pane, runtime)) {
|
|
2578
3160
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: pane ${paneId} still running a turn after ESC; holding`);
|
|
2579
3161
|
return false;
|
|
2580
3162
|
}
|
|
2581
|
-
if (!(await this.paneRunsRuntime(tmux,
|
|
3163
|
+
if (!(await this.paneRunsRuntime(tmux, pane, runtime)))
|
|
2582
3164
|
return false;
|
|
2583
3165
|
try {
|
|
2584
|
-
await tmux.sendKeysToPane(
|
|
3166
|
+
await tmux.sendKeysToPane(pane, 'C-c');
|
|
2585
3167
|
await new Promise(r => setTimeout(r, 200));
|
|
2586
3168
|
}
|
|
2587
3169
|
catch (err) {
|
|
2588
3170
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: send C-c (composer clear) failed for pane ${paneId}:`, err);
|
|
2589
3171
|
return false;
|
|
2590
3172
|
}
|
|
2591
|
-
return this.paneReachedReplReady(tmux,
|
|
3173
|
+
return this.paneReachedReplReady(tmux, pane, runtime, this.cleanComposerWaitMs);
|
|
2592
3174
|
}
|
|
2593
3175
|
finally {
|
|
2594
3176
|
this.compactInFlight.delete(cfg.id);
|
|
2595
3177
|
}
|
|
2596
3178
|
}
|
|
2597
|
-
async paneHasLiveTurn(tmux,
|
|
3179
|
+
async paneHasLiveTurn(tmux, pane, runtime) {
|
|
3180
|
+
const paneId = pane.paneId;
|
|
2598
3181
|
let first;
|
|
2599
3182
|
let firstTitle;
|
|
2600
3183
|
try {
|
|
2601
|
-
first = await tmux.capturePaneById(
|
|
2602
|
-
firstTitle = await tmux.readPaneTitle(
|
|
3184
|
+
first = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
3185
|
+
firstTitle = await tmux.readPaneTitle(pane);
|
|
2603
3186
|
}
|
|
2604
3187
|
catch (err) {
|
|
2605
3188
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: liveness capture failed for pane ${paneId}:`, err);
|
|
@@ -2610,8 +3193,8 @@ export class AgentManager {
|
|
|
2610
3193
|
let frame;
|
|
2611
3194
|
let title;
|
|
2612
3195
|
try {
|
|
2613
|
-
frame = await tmux.capturePaneById(
|
|
2614
|
-
title = await tmux.readPaneTitle(
|
|
3196
|
+
frame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
3197
|
+
title = await tmux.readPaneTitle(pane);
|
|
2615
3198
|
}
|
|
2616
3199
|
catch (err) {
|
|
2617
3200
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: liveness re-capture failed for pane ${paneId}:`, err);
|
|
@@ -2626,10 +3209,11 @@ export class AgentManager {
|
|
|
2626
3209
|
}
|
|
2627
3210
|
return false;
|
|
2628
3211
|
}
|
|
2629
|
-
async paneRunsRuntime(tmux,
|
|
3212
|
+
async paneRunsRuntime(tmux, pane, runtime) {
|
|
3213
|
+
const paneId = pane.paneId;
|
|
2630
3214
|
let proc;
|
|
2631
3215
|
try {
|
|
2632
|
-
proc = await tmux.displayMessage(
|
|
3216
|
+
proc = await tmux.displayMessage(pane, '#{pane_current_command}');
|
|
2633
3217
|
}
|
|
2634
3218
|
catch (err) {
|
|
2635
3219
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: proc-title read failed for pane ${paneId}:`, err);
|
|
@@ -2641,9 +3225,10 @@ export class AgentManager {
|
|
|
2641
3225
|
}
|
|
2642
3226
|
return true;
|
|
2643
3227
|
}
|
|
2644
|
-
async paneReachedReplReady(tmux,
|
|
3228
|
+
async paneReachedReplReady(tmux, pane, runtime, timeoutMs) {
|
|
3229
|
+
const paneId = pane.paneId;
|
|
2645
3230
|
try {
|
|
2646
|
-
await tmux.waitReplReady(
|
|
3231
|
+
await tmux.waitReplReady(pane, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true });
|
|
2647
3232
|
return true;
|
|
2648
3233
|
}
|
|
2649
3234
|
catch (err) {
|
|
@@ -2774,20 +3359,25 @@ export class AgentManager {
|
|
|
2774
3359
|
}
|
|
2775
3360
|
}
|
|
2776
3361
|
}
|
|
2777
|
-
async pollPaneCommandStable(tmux,
|
|
3362
|
+
async pollPaneCommandStable(tmux, pane, opts) {
|
|
2778
3363
|
const deadline = Date.now() + opts.timeoutMs;
|
|
2779
3364
|
const SHELL = /^(?:zsh|bash|sh|fish)$/;
|
|
2780
3365
|
let last = '';
|
|
2781
3366
|
while (Date.now() < deadline) {
|
|
2782
3367
|
await new Promise(r => setTimeout(r, 100));
|
|
2783
|
-
const raw = await tmux.displayMessage(
|
|
3368
|
+
const raw = await tmux.displayMessage(pane, '#{pane_current_command}');
|
|
2784
3369
|
last = raw.trim();
|
|
2785
3370
|
if (opts.expectShell ? SHELL.test(last) : last !== '')
|
|
2786
3371
|
return last;
|
|
2787
3372
|
}
|
|
2788
3373
|
return last;
|
|
2789
3374
|
}
|
|
2790
|
-
async restartReplOnly(agentId) {
|
|
3375
|
+
async restartReplOnly(agentId, opts = {}) {
|
|
3376
|
+
// 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).
|
|
3377
|
+
const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
|
|
3378
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
3379
|
+
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
3380
|
+
}
|
|
2791
3381
|
const cfg = this.getAgentConfig(agentId);
|
|
2792
3382
|
if (!cfg)
|
|
2793
3383
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
@@ -2800,14 +3390,17 @@ export class AgentManager {
|
|
|
2800
3390
|
if (snapshot.claim !== agentId) {
|
|
2801
3391
|
throw new Error(`restart-repl: session claim mismatch (got "${snapshot.claim ?? 'null'}"); refusing to touch foreign session`);
|
|
2802
3392
|
}
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
3393
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
3394
|
+
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
3395
|
+
}
|
|
3396
|
+
const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
|
|
3397
|
+
await tmux.sendKeysToPane(pane, 'C-c');
|
|
3398
|
+
const cmd = await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000 });
|
|
2806
3399
|
const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
|
|
2807
3400
|
const SHELL = /^(?:zsh|bash|sh|fish)$/;
|
|
2808
3401
|
if (RUNTIME.test(cmd)) {
|
|
2809
|
-
await tmux.sendKeysToPane(
|
|
2810
|
-
await this.pollPaneCommandStable(tmux,
|
|
3402
|
+
await tmux.sendKeysToPane(pane, REPL_EXIT_COMMAND[cfg.runtime], 'Enter');
|
|
3403
|
+
await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000, expectShell: true });
|
|
2811
3404
|
}
|
|
2812
3405
|
else if (!SHELL.test(cmd)) {
|
|
2813
3406
|
throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
|
|
@@ -2815,33 +3408,42 @@ export class AgentManager {
|
|
|
2815
3408
|
const project = this.getProjectConfig(cfg.projectId);
|
|
2816
3409
|
if (!project)
|
|
2817
3410
|
throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
|
|
3411
|
+
// 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.
|
|
3412
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
3413
|
+
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
3414
|
+
}
|
|
2818
3415
|
const workdir = (await this.ensureWorkdir(cfg, project, runner)).workdir;
|
|
2819
|
-
const paneWorkdir = await tmux.getPaneCurrentPath(
|
|
3416
|
+
const paneWorkdir = await tmux.getPaneCurrentPath(pane);
|
|
2820
3417
|
if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
|
|
2821
3418
|
throw new Error(`restart-repl: pane Workdir ${paneWorkdir} does not match agent Workdir ${workdir}; ` +
|
|
2822
3419
|
'use retry to rebuild the session safely');
|
|
2823
3420
|
}
|
|
3421
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
3422
|
+
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
3423
|
+
}
|
|
2824
3424
|
await this.provisionRepoSkills(runner, cfg, workdir);
|
|
2825
3425
|
await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
|
|
2826
3426
|
const runtime = agentRuntimeKindFor(cfg);
|
|
2827
3427
|
const relaunch = async () => {
|
|
2828
|
-
await tmux.
|
|
2829
|
-
await tmux.
|
|
3428
|
+
await tmux.sendKeysLiteral(pane, launchCommandIn(workdir, cfg));
|
|
3429
|
+
await tmux.sendEnter(pane);
|
|
3430
|
+
await tmux.handleTrustDialog(pane, runtime, {
|
|
2830
3431
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
2831
3432
|
});
|
|
2832
|
-
await tmux.waitReplReady(
|
|
3433
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
2833
3434
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
2834
3435
|
scrollback: 0,
|
|
2835
3436
|
});
|
|
2836
3437
|
await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
|
|
2837
3438
|
};
|
|
2838
3439
|
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
3440
|
+
// Final writeback is generation-gated too, so a DELETE→recreate reusing %0 can't persist the old incarnation's paneId onto the new agent.
|
|
2839
3441
|
await this.agentStore.update(agentId, (state) => {
|
|
2840
|
-
if (!state)
|
|
3442
|
+
if (!state || !this.deletionGateOpen(agentId, genAtEntry))
|
|
2841
3443
|
return AGENT_STORE_NOOP;
|
|
2842
3444
|
return {
|
|
2843
3445
|
...state,
|
|
2844
|
-
paneId,
|
|
3446
|
+
paneId: pane.paneId,
|
|
2845
3447
|
updatedAt: new Date().toISOString(),
|
|
2846
3448
|
};
|
|
2847
3449
|
});
|
|
@@ -2920,6 +3522,8 @@ export class AgentManager {
|
|
|
2920
3522
|
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
|
|
2921
3523
|
skipSnapshot: true,
|
|
2922
3524
|
onlyReplaceOwnToken: true,
|
|
3525
|
+
needInputMode: 'fresh',
|
|
3526
|
+
needInputPaneCutover: true,
|
|
2923
3527
|
// git 双 watcher 下 fence 只应比较自己的 (taskId, agentId) 条目:task 域会拿
|
|
2924
3528
|
// sibling(reconciliation)的不同 token 否决本应合法的重布防
|
|
2925
3529
|
...(task.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
|
|
@@ -3217,25 +3821,11 @@ export class AgentManager {
|
|
|
3217
3821
|
const failures = [];
|
|
3218
3822
|
for (const id of targets) {
|
|
3219
3823
|
const cfg = this.getAgentConfig(id);
|
|
3220
|
-
if (!cfg)
|
|
3221
|
-
continue;
|
|
3222
|
-
const runner = this.createRunnerFor(cfg);
|
|
3223
|
-
const tmux = new TmuxManager(runner);
|
|
3224
|
-
|
|
3225
|
-
if (boundState?.taskId) {
|
|
3226
|
-
try {
|
|
3227
|
-
const released = await this.releaseAgentForTask(id, boundState.taskId, 'idle', {
|
|
3228
|
-
allowAwaitingHuman: true,
|
|
3229
|
-
});
|
|
3230
|
-
if (!released) {
|
|
3231
|
-
throw new Error(`could not safely release task ${boundState.taskId}`);
|
|
3232
|
-
}
|
|
3233
|
-
}
|
|
3234
|
-
catch (err) {
|
|
3235
|
-
failures.push({ agentId: id, step: 'binding.release', error: err });
|
|
3236
|
-
continue;
|
|
3237
|
-
}
|
|
3238
|
-
}
|
|
3824
|
+
if (!cfg)
|
|
3825
|
+
continue;
|
|
3826
|
+
const runner = this.createRunnerFor(cfg);
|
|
3827
|
+
const tmux = new TmuxManager(runner);
|
|
3828
|
+
// 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.
|
|
3239
3829
|
this.stopRuntimeMenuWatch(id);
|
|
3240
3830
|
if (this.paneStreamerManager) {
|
|
3241
3831
|
try {
|
|
@@ -3247,21 +3837,45 @@ export class AgentManager {
|
|
|
3247
3837
|
}
|
|
3248
3838
|
try {
|
|
3249
3839
|
await this.runUnderSessionLifecycle(id, async () => {
|
|
3250
|
-
const
|
|
3840
|
+
const cleanupOpts = { timeout: DELETE_CLEANUP_TIMEOUT_MS };
|
|
3841
|
+
const snapshot = await tmux.getSessionSnapshot(id, cleanupOpts);
|
|
3251
3842
|
if (!snapshot)
|
|
3252
3843
|
return;
|
|
3253
|
-
if (snapshot.claim
|
|
3254
|
-
|
|
3255
|
-
|
|
3844
|
+
if (snapshot.claim === id) {
|
|
3845
|
+
const outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'equals', claim: id }, cleanupOpts);
|
|
3846
|
+
if (outcome === 'refused') {
|
|
3847
|
+
throw new Error(`tmux generation/claim changed for ${id} before kill; retry DELETE to observe new state`);
|
|
3848
|
+
}
|
|
3849
|
+
if (outcome === 'killed') {
|
|
3850
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
|
|
3851
|
+
}
|
|
3256
3852
|
return;
|
|
3257
3853
|
}
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3854
|
+
if (snapshot.claim === null) {
|
|
3855
|
+
// A nonce-owned unclaimed session is this project's own half-created leftover — reclaim it.
|
|
3856
|
+
let nonceOwned;
|
|
3857
|
+
try {
|
|
3858
|
+
nonceOwned = await tmux.hasCreationNonce(id, cleanupOpts);
|
|
3859
|
+
}
|
|
3860
|
+
catch (err) {
|
|
3861
|
+
throw new Error(`creation-nonce probe failed for ${id}; retry DELETE: ${err instanceof Error ? err.message : String(err)}`);
|
|
3862
|
+
}
|
|
3863
|
+
if (!nonceOwned) {
|
|
3864
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping ${id} ` +
|
|
3865
|
+
`(unclaimed, no baxian creation nonce; foreign session)`);
|
|
3866
|
+
return;
|
|
3867
|
+
}
|
|
3868
|
+
const outcome = await tmux.killSessionRef(snapshot.ref, { kind: 'unclaimed' }, cleanupOpts);
|
|
3869
|
+
if (outcome === 'refused') {
|
|
3870
|
+
throw new Error(`half-created session for ${id} was claimed or its server changed before kill; retry DELETE`);
|
|
3871
|
+
}
|
|
3872
|
+
if (outcome === 'killed') {
|
|
3873
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed half-created leftover ${snapshot.ref.sessionId} for removed agent ${id}`);
|
|
3874
|
+
}
|
|
3875
|
+
return;
|
|
3264
3876
|
}
|
|
3877
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
|
|
3878
|
+
`(claim=${snapshot.claim}; not baxian-managed)`);
|
|
3265
3879
|
});
|
|
3266
3880
|
}
|
|
3267
3881
|
catch (err) {
|
|
@@ -3451,6 +4065,7 @@ export class AgentManager {
|
|
|
3451
4065
|
agentId: task.agentId,
|
|
3452
4066
|
expectedKinds: 'pr-merge-ready',
|
|
3453
4067
|
token: value.token,
|
|
4068
|
+
needInputMode: 'fresh',
|
|
3454
4069
|
});
|
|
3455
4070
|
if (!armed) {
|
|
3456
4071
|
// completion 没有 consumer 就不算安装成功:撤销本次写,durable pending(若有)随原状保留
|
|
@@ -3752,6 +4367,30 @@ export class AgentManager {
|
|
|
3752
4367
|
}
|
|
3753
4368
|
// 配置身份切换与任务创建共栅栏(TOCTOU):blocker 扫描与提交在 withTaskLock 内执行,
|
|
3754
4369
|
// createTask 的「读配置快照+落任务」同经该锁,扫描时不可见的并发新任务不存在。
|
|
4370
|
+
// 启动期按「未引用」跳过 skill 池的插件,被热配置首次引用时同步重扫;仍不可物化即拒绝提交——
|
|
4371
|
+
// 可解析但物化为空的 tool 会让派发要求加载一个不存在的 skill。
|
|
4372
|
+
async ensurePluginSkillPools(next) {
|
|
4373
|
+
if (!this.pluginRegistry)
|
|
4374
|
+
return;
|
|
4375
|
+
for (const project of next.project) {
|
|
4376
|
+
if (projectReviewMode(next, project) !== 'git')
|
|
4377
|
+
continue;
|
|
4378
|
+
const tool = resolveProjectTool(project);
|
|
4379
|
+
if (tool === undefined)
|
|
4380
|
+
continue;
|
|
4381
|
+
const plugin = this.pluginRegistry.resolveTool(tool);
|
|
4382
|
+
// 静默放行会存下一个「无 driver、无平台 skill、重启即 fatal」的配置(启动期同判据)
|
|
4383
|
+
if (plugin === undefined) {
|
|
4384
|
+
throw new Error(`project '${project.id}': no git-driver plugin provides tool '${tool}' — install it under ~/.baxian/plugins/ first`);
|
|
4385
|
+
}
|
|
4386
|
+
if (this.skillRegistry.pluginSkillNames({ pluginTools: [tool] }).length > 0)
|
|
4387
|
+
continue;
|
|
4388
|
+
await this.skillRegistry.scanPluginSkills(tool, join(plugin.pluginPath, 'skills'));
|
|
4389
|
+
if (this.skillRegistry.pluginSkillNames({ pluginTools: [tool] }).length === 0) {
|
|
4390
|
+
throw new Error(`plugin '${plugin.manifest.name}' provides no materializable skills for tool '${tool}'`);
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
}
|
|
3755
4394
|
async guardGitConfigCommit(current, next, scan, commit) {
|
|
3756
4395
|
return this.withTaskLock(async () => {
|
|
3757
4396
|
const blockers = await scan(this, current, next);
|
|
@@ -3801,7 +4440,27 @@ export class AgentManager {
|
|
|
3801
4440
|
if (!['review', 'in_progress', 'fixing'].includes(task.status))
|
|
3802
4441
|
continue;
|
|
3803
4442
|
try {
|
|
3804
|
-
|
|
4443
|
+
// 列表是旧快照:消费谓词按实时值复核(pending 仍在 + 状态允许),并把代际
|
|
4444
|
+
// CAS(expectSignalToken + expectReviewDispatchPending)压进 dispatch 锁内
|
|
4445
|
+
const live = await this.taskStore.get(task.id);
|
|
4446
|
+
if (!live || live.reviewDispatchPending !== true)
|
|
4447
|
+
continue;
|
|
4448
|
+
if (!['review', 'in_progress', 'fixing'].includes(live.status))
|
|
4449
|
+
continue;
|
|
4450
|
+
const entry = this.getPendingDispatchRetry(live.id);
|
|
4451
|
+
if (entry?.kind === 'qa-recheck' && entry.signalToken === live.signalToken) {
|
|
4452
|
+
// 同代 busy pending 在场:QA 已知忙碌,重派只会空转 release/acquire 并重置预算;
|
|
4453
|
+
// 交给对账的观测门在空闲后消费
|
|
4454
|
+
continue;
|
|
4455
|
+
}
|
|
4456
|
+
const qaPhase = live.reviewRound === 0 ? 'review' : undefined;
|
|
4457
|
+
await this.dispatchReviewToQa(live.id, {
|
|
4458
|
+
bumpRound: live.reviewRoundPending === true,
|
|
4459
|
+
...(qaPhase !== undefined ? { qaPhase } : {}),
|
|
4460
|
+
...(live.signalToken !== undefined ? { expectSignalToken: live.signalToken } : {}),
|
|
4461
|
+
expectReviewDispatchPending: true,
|
|
4462
|
+
fromStatus: ['review', 'in_progress', 'fixing'],
|
|
4463
|
+
});
|
|
3805
4464
|
}
|
|
3806
4465
|
catch (err) {
|
|
3807
4466
|
console.warn(`[AgentManager] retryPendingGitReviewDispatches: task=${task.id} still failing:`, err);
|
|
@@ -4153,6 +4812,82 @@ export class AgentManager {
|
|
|
4153
4812
|
return {};
|
|
4154
4813
|
return { platformBinding: { mode: 'git', repoKey: repoIdentityKey(project.repo), tool } };
|
|
4155
4814
|
}
|
|
4815
|
+
// agent 面一律 PATH 解析 tool;server 面 binary/env 不进入该轨。
|
|
4816
|
+
agentGitPreflightContext(projectId) {
|
|
4817
|
+
if (this.effectiveReviewMode(projectId) !== 'git')
|
|
4818
|
+
return undefined;
|
|
4819
|
+
const project = this.getProjectConfig(projectId);
|
|
4820
|
+
if (!project || !this.pluginRegistry)
|
|
4821
|
+
return undefined;
|
|
4822
|
+
const tool = resolveProjectTool(project);
|
|
4823
|
+
if (tool === undefined)
|
|
4824
|
+
return undefined;
|
|
4825
|
+
const plugin = this.pluginRegistry.resolveTool(tool);
|
|
4826
|
+
if (plugin === undefined)
|
|
4827
|
+
return undefined;
|
|
4828
|
+
const renderCtx = buildDriverRunContext(project.repo, tool);
|
|
4829
|
+
return {
|
|
4830
|
+
tool,
|
|
4831
|
+
minToolVersion: plugin.manifest.minToolVersion,
|
|
4832
|
+
steps: plugin.spec.preflight,
|
|
4833
|
+
agentCommands: plugin.spec.agentCommands,
|
|
4834
|
+
renderCtx,
|
|
4835
|
+
driverFor: (exec) => new GitDriver(plugin, renderCtx, exec),
|
|
4836
|
+
};
|
|
4837
|
+
}
|
|
4838
|
+
// cli 字段族每次派发实时渲染,不做创建时快照。渲染前按持久化 binding 校验身份——
|
|
4839
|
+
// 离线漂移的配置会把旧任务的 PR 号/分支/令牌带到新仓库,与平台操作同一守卫。
|
|
4840
|
+
platformCliContextOf(task) {
|
|
4841
|
+
if (task.reviewMode !== 'git')
|
|
4842
|
+
return undefined;
|
|
4843
|
+
this.assertPlatformBinding(task);
|
|
4844
|
+
const project = this.getProjectConfig(task.projectId);
|
|
4845
|
+
if (!project)
|
|
4846
|
+
return undefined;
|
|
4847
|
+
const tool = resolveProjectTool(project);
|
|
4848
|
+
if (tool === undefined)
|
|
4849
|
+
return undefined;
|
|
4850
|
+
const ctx = buildDriverRunContext(project.repo, tool);
|
|
4851
|
+
return {
|
|
4852
|
+
tool,
|
|
4853
|
+
host: ctx.host,
|
|
4854
|
+
repo: ctx.repoPath,
|
|
4855
|
+
repoEncoded: encodeURIComponent(ctx.repoPath),
|
|
4856
|
+
...(project.gitCli?.notes !== undefined ? { notes: project.gitCli.notes } : {}),
|
|
4857
|
+
};
|
|
4858
|
+
}
|
|
4859
|
+
// base 快照一经持久化不可变;无快照保持缺省,由 agent 现场显式查询。
|
|
4860
|
+
async ensureGitBaseSnapshot(task, phase) {
|
|
4861
|
+
if (task.reviewMode !== 'git' || (phase !== 'develop' && phase !== 'code'))
|
|
4862
|
+
return task;
|
|
4863
|
+
if (task.baseBranch !== undefined)
|
|
4864
|
+
return task;
|
|
4865
|
+
// 读默认分支与落快照都以 binding 为前提:失配任务先写入新仓库的 base 会永久污染不可变快照
|
|
4866
|
+
this.assertPlatformBinding(task);
|
|
4867
|
+
const base = this.platformDefaultBranchOf?.(task.projectId);
|
|
4868
|
+
if (base === undefined)
|
|
4869
|
+
return task;
|
|
4870
|
+
return this.withTaskLock(async () => {
|
|
4871
|
+
const fresh = await this.taskStore.get(task.id);
|
|
4872
|
+
if (!fresh)
|
|
4873
|
+
return task;
|
|
4874
|
+
if (fresh.baseBranch !== undefined)
|
|
4875
|
+
return fresh;
|
|
4876
|
+
this.assertPlatformBinding(fresh);
|
|
4877
|
+
const next = { ...fresh, baseBranch: base, updatedAt: new Date().toISOString() };
|
|
4878
|
+
await this.taskStore.set(next);
|
|
4879
|
+
return next;
|
|
4880
|
+
});
|
|
4881
|
+
}
|
|
4882
|
+
async noteReviewConversationRevision(taskId) {
|
|
4883
|
+
await this.withTaskLock(async () => {
|
|
4884
|
+
const task = await this.taskStore.get(taskId);
|
|
4885
|
+
if (!task || TERMINAL_STATUSES.includes(task.status))
|
|
4886
|
+
return;
|
|
4887
|
+
const now = new Date().toISOString();
|
|
4888
|
+
await this.taskStore.set({ ...task, reviewConversationUpdatedAt: now, updatedAt: now });
|
|
4889
|
+
});
|
|
4890
|
+
}
|
|
4156
4891
|
// 指纹为纯身份三元组(spec §4):binary/env/skill 文本 live 解析可演进,身份失配即拒绝
|
|
4157
4892
|
assertPlatformBinding(task) {
|
|
4158
4893
|
const binding = task.platformBinding;
|
|
@@ -4461,16 +5196,21 @@ export class AgentManager {
|
|
|
4461
5196
|
return { task, previousStatus };
|
|
4462
5197
|
});
|
|
4463
5198
|
}
|
|
4464
|
-
|
|
5199
|
+
// 送达即计轮:flag 是唯一入账凭据,与 verdict 公式(round + flag)按 token CAS 互斥消费,
|
|
5200
|
+
// successor 换代或 verdict 先行都自然跳过,恰好一次
|
|
5201
|
+
// 不设终态守卫:只写轮次不写 status(无复活风险),而终态任务的 recheck 派发是受支持的能力,
|
|
5202
|
+
// 其送达的轮次同样要入账
|
|
5203
|
+
async consumeReviewRoundIntent(taskId, expectedToken) {
|
|
4465
5204
|
await this.withTaskLock(async () => {
|
|
4466
|
-
const
|
|
4467
|
-
if (!
|
|
4468
|
-
return;
|
|
4469
|
-
if ((task.reviewRound ?? 0) !== expectedRound)
|
|
5205
|
+
const fresh = await this.taskStore.get(taskId);
|
|
5206
|
+
if (!fresh || fresh.signalToken !== expectedToken || fresh.reviewRoundPending !== true)
|
|
4470
5207
|
return;
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
5208
|
+
await this.taskStore.set({
|
|
5209
|
+
...fresh,
|
|
5210
|
+
reviewRound: fresh.reviewRound + 1,
|
|
5211
|
+
reviewRoundPending: undefined,
|
|
5212
|
+
updatedAt: new Date().toISOString(),
|
|
5213
|
+
});
|
|
4474
5214
|
});
|
|
4475
5215
|
}
|
|
4476
5216
|
createRunnerFor(agent) {
|
|
@@ -4484,7 +5224,10 @@ export class AgentManager {
|
|
|
4484
5224
|
if (this.repoStoreFactory) {
|
|
4485
5225
|
return this.repoStoreFactory(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir);
|
|
4486
5226
|
}
|
|
4487
|
-
|
|
5227
|
+
const cloneViaGh = this.effectiveReviewMode(project.id) === 'git'
|
|
5228
|
+
? resolveProjectTool(project) === 'gh'
|
|
5229
|
+
: undefined;
|
|
5230
|
+
return new RepoStore(runner, project.repo, agent.mode, host, this.repoCache, agent.id, agent.workdir, cloneViaGh);
|
|
4488
5231
|
}
|
|
4489
5232
|
async ensureWorkdir(agent, project, runner) {
|
|
4490
5233
|
const repoStore = this.createRepoStore(agent, project, runner);
|
|
@@ -4500,6 +5243,9 @@ export class AgentManager {
|
|
|
4500
5243
|
return this.repoCache;
|
|
4501
5244
|
}
|
|
4502
5245
|
async rollbackFailedDispatch(taskId, agentId, reason, expectedLockToken) {
|
|
5246
|
+
// Pin the incarnation: task rollback / intervention below await, and a DELETE→same-id recreate in that
|
|
5247
|
+
// window must not let the final state write-back resurrect the deleted agent.
|
|
5248
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
4503
5249
|
const existing = await this.agentStore.get(agentId);
|
|
4504
5250
|
if (existing && existing.taskId !== undefined && existing.taskId !== taskId) {
|
|
4505
5251
|
console.warn(`[AgentManager] rollback: agent ${agentId} taskId mismatch (expected ${taskId}, got ${existing.taskId}); ` +
|
|
@@ -4546,12 +5292,16 @@ export class AgentManager {
|
|
|
4546
5292
|
else {
|
|
4547
5293
|
const now = new Date().toISOString();
|
|
4548
5294
|
await this.agentStore.update(agentId, (latest) => {
|
|
4549
|
-
if
|
|
5295
|
+
// Never resurrect a deleted/recreated incarnation: if the state is gone or the generation moved
|
|
5296
|
+
// since entry, this rollback is stale — clear nothing rather than rebuild from the old snapshot.
|
|
5297
|
+
if (!latest || !this.deletionGateOpen(agentId, genAtEntry))
|
|
5298
|
+
return AGENT_STORE_NOOP;
|
|
5299
|
+
if (latest.taskId === taskId && latest.lockToken !== expectedLockToken)
|
|
4550
5300
|
return AGENT_STORE_NOOP;
|
|
4551
|
-
if (latest
|
|
5301
|
+
if (latest.taskId !== undefined && latest.taskId !== taskId)
|
|
4552
5302
|
return AGENT_STORE_NOOP;
|
|
4553
5303
|
return {
|
|
4554
|
-
...
|
|
5304
|
+
...latest,
|
|
4555
5305
|
id: agentId,
|
|
4556
5306
|
projectId,
|
|
4557
5307
|
taskId: undefined,
|
|
@@ -4642,6 +5392,8 @@ export class AgentManager {
|
|
|
4642
5392
|
};
|
|
4643
5393
|
return this.persistQueuedTask(unassigned, 'unassigned');
|
|
4644
5394
|
}
|
|
5395
|
+
// Capture generation before the first config/group read so a DELETE→same-id recreate in the read→commit window fails the gate.
|
|
5396
|
+
const genAtEntry = this.deletionGenerationOf(input.preferredAgentId);
|
|
4645
5397
|
const target = await this.pickAgent(projectId, input.preferredAgentId);
|
|
4646
5398
|
const targetConfig = this.getAgentConfig(input.preferredAgentId);
|
|
4647
5399
|
const group = this.findAgentGroup(input.preferredAgentId);
|
|
@@ -4653,6 +5405,12 @@ export class AgentManager {
|
|
|
4653
5405
|
const researchFields = research
|
|
4654
5406
|
? { researchAgentId: research.id, phase: 'research' }
|
|
4655
5407
|
: {};
|
|
5408
|
+
// 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.
|
|
5409
|
+
const participantGens = new Map([dev.id, ...(qa ? [qa.id] : []), ...(research ? [research.id] : [])]
|
|
5410
|
+
.map(id => [id, this.deletionGenerationOf(id)]));
|
|
5411
|
+
const participantsFresh = () => this.deletionGenerationOf(input.preferredAgentId) === genAtEntry
|
|
5412
|
+
&& !this.isDeletionInFlight(input.preferredAgentId)
|
|
5413
|
+
&& [...participantGens].every(([id, gen]) => this.deletionGenerationOf(id) === gen && !this.isDeletionInFlight(id));
|
|
4656
5414
|
const queued = {
|
|
4657
5415
|
...taskBase,
|
|
4658
5416
|
agentId: '',
|
|
@@ -4661,12 +5419,83 @@ export class AgentManager {
|
|
|
4661
5419
|
status: 'pending',
|
|
4662
5420
|
...(qa ? { qaAgentId: qa.id } : {}),
|
|
4663
5421
|
};
|
|
5422
|
+
// A DELETE→recreate of the preferred OR any participant after the group snapshot must not persist a
|
|
5423
|
+
// pending task carrying its stale devAgentId/qaAgentId; reject and let the client re-select.
|
|
5424
|
+
const persistQueuedOrReject = (reason) => {
|
|
5425
|
+
if (!participantsFresh()) {
|
|
5426
|
+
throw new ApiError(409, `Agent ${input.preferredAgentId} or a group member is being deleted or recreated during task creation; please retry`);
|
|
5427
|
+
}
|
|
5428
|
+
return this.persistQueuedTask(queued, reason, input.preferredAgentId);
|
|
5429
|
+
};
|
|
4664
5430
|
if (!target) {
|
|
4665
|
-
return
|
|
5431
|
+
return persistQueuedOrReject('preferred_agent_busy');
|
|
5432
|
+
}
|
|
5433
|
+
if (this.isDeletionInFlight(target.id)) {
|
|
5434
|
+
return persistQueuedOrReject('agent_locked');
|
|
4666
5435
|
}
|
|
4667
5436
|
const lockToken = await this.lockManager.acquire(target.id, taskId);
|
|
4668
5437
|
if (!lockToken) {
|
|
4669
|
-
return
|
|
5438
|
+
return persistQueuedOrReject('agent_locked');
|
|
5439
|
+
}
|
|
5440
|
+
// 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.
|
|
5441
|
+
const releaseLock = async () => {
|
|
5442
|
+
try {
|
|
5443
|
+
await this.lockManager.releaseIfOwner(target.id, taskId, lockToken);
|
|
5444
|
+
}
|
|
5445
|
+
catch (releaseErr) {
|
|
5446
|
+
console.warn(`[AgentManager] createTask(${taskId}): lock release after failed assignment also failed:`, releaseErr);
|
|
5447
|
+
}
|
|
5448
|
+
};
|
|
5449
|
+
try {
|
|
5450
|
+
await this.taskStore.set(queued);
|
|
5451
|
+
}
|
|
5452
|
+
catch (err) {
|
|
5453
|
+
await releaseLock();
|
|
5454
|
+
throw err;
|
|
5455
|
+
}
|
|
5456
|
+
let commit;
|
|
5457
|
+
try {
|
|
5458
|
+
commit = await this.agentStore.update(target.id, (existing) => {
|
|
5459
|
+
if (existing === null && !this.bindingInitPredicate(target.id, genAtEntry))
|
|
5460
|
+
return AGENT_STORE_NOOP;
|
|
5461
|
+
if (!this.deletionGateOpen(target.id, genAtEntry))
|
|
5462
|
+
return AGENT_STORE_NOOP;
|
|
5463
|
+
return {
|
|
5464
|
+
id: target.id,
|
|
5465
|
+
projectId,
|
|
5466
|
+
taskId,
|
|
5467
|
+
lockToken,
|
|
5468
|
+
bootstrappingTaskId: taskId,
|
|
5469
|
+
updatedAt: now,
|
|
5470
|
+
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
5471
|
+
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
5472
|
+
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
5473
|
+
// A new task never inherits the previous one's question: void the watermark.
|
|
5474
|
+
needInput: { epoch: (existing?.needInput?.epoch ?? 0) + 1 },
|
|
5475
|
+
};
|
|
5476
|
+
});
|
|
5477
|
+
}
|
|
5478
|
+
catch (err) {
|
|
5479
|
+
await releaseLock();
|
|
5480
|
+
throw err;
|
|
5481
|
+
}
|
|
5482
|
+
if (commit !== 'committed') {
|
|
5483
|
+
await releaseLock();
|
|
5484
|
+
return (await this.taskStore.get(taskId)) ?? queued;
|
|
5485
|
+
}
|
|
5486
|
+
// 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.
|
|
5487
|
+
if (this.isDeletionInFlight(target.id)
|
|
5488
|
+
|| !this.deletionGateOpen(target.id, genAtEntry)
|
|
5489
|
+
|| !participantsFresh()
|
|
5490
|
+
|| !(await this.lockManager.isOwner(target.id, taskId, lockToken))) {
|
|
5491
|
+
await this.agentStore.update(target.id, (existing) => {
|
|
5492
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
|
|
5493
|
+
return AGENT_STORE_NOOP;
|
|
5494
|
+
const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
|
|
5495
|
+
return { ...rest, updatedAt: new Date().toISOString() };
|
|
5496
|
+
});
|
|
5497
|
+
await releaseLock();
|
|
5498
|
+
return (await this.taskStore.get(taskId)) ?? queued;
|
|
4670
5499
|
}
|
|
4671
5500
|
const task = {
|
|
4672
5501
|
...taskBase,
|
|
@@ -4676,18 +5505,24 @@ export class AgentManager {
|
|
|
4676
5505
|
...researchFields,
|
|
4677
5506
|
status: 'in_progress',
|
|
4678
5507
|
};
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
5508
|
+
try {
|
|
5509
|
+
await this.taskStore.set(task);
|
|
5510
|
+
}
|
|
5511
|
+
catch (err) {
|
|
5512
|
+
try {
|
|
5513
|
+
await this.agentStore.update(target.id, (existing) => {
|
|
5514
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
|
|
5515
|
+
return AGENT_STORE_NOOP;
|
|
5516
|
+
const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
|
|
5517
|
+
return { ...rest, updatedAt: new Date().toISOString() };
|
|
5518
|
+
});
|
|
5519
|
+
}
|
|
5520
|
+
catch (rollbackErr) {
|
|
5521
|
+
console.warn(`[AgentManager] createTask(${taskId}): binding rollback after task write failure also failed (stale-binding path will reclaim):`, rollbackErr);
|
|
5522
|
+
}
|
|
5523
|
+
await releaseLock();
|
|
5524
|
+
throw err;
|
|
5525
|
+
}
|
|
4691
5526
|
await this.safeEmit({
|
|
4692
5527
|
id: '',
|
|
4693
5528
|
type: 'task.assigned',
|
|
@@ -4795,6 +5630,9 @@ export class AgentManager {
|
|
|
4795
5630
|
const cfg = this.getAgentConfig(agentId);
|
|
4796
5631
|
if (!cfg)
|
|
4797
5632
|
throw new ApiError(404, `Unknown agent: ${agentId}`);
|
|
5633
|
+
// Pin the incarnation before reading paneId: paneId equality is ABA-blind (a recreate reuses %0), so a
|
|
5634
|
+
// DELETE→same-id recreate during the upload would otherwise paste the stale image into the new session.
|
|
5635
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
4798
5636
|
const state = await this.agentStore.get(agentId);
|
|
4799
5637
|
const paneId = state?.paneId;
|
|
4800
5638
|
if (!paneId)
|
|
@@ -4804,6 +5642,9 @@ export class AgentManager {
|
|
|
4804
5642
|
}
|
|
4805
5643
|
try {
|
|
4806
5644
|
const assertUploadStillValid = async () => {
|
|
5645
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
5646
|
+
throw new ApiError(409, `Agent ${agentId} was deleted or recreated while uploading; image paste aborted`);
|
|
5647
|
+
}
|
|
4807
5648
|
const held = await this.agentStore.get(agentId);
|
|
4808
5649
|
if (!held || held.paneId !== paneId) {
|
|
4809
5650
|
throw new ApiError(409, `Agent ${agentId} session changed while uploading; image paste aborted`);
|
|
@@ -4823,7 +5664,10 @@ export class AgentManager {
|
|
|
4823
5664
|
await writeImageToHost(runner, path, bytes);
|
|
4824
5665
|
await assertUploadStillValid();
|
|
4825
5666
|
const tmux = new TmuxManager(runner);
|
|
4826
|
-
await
|
|
5667
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
|
|
5668
|
+
// 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.
|
|
5669
|
+
await assertUploadStillValid();
|
|
5670
|
+
await tmux.injectPrompt(pane, `${path} `, agentId);
|
|
4827
5671
|
return { path };
|
|
4828
5672
|
}
|
|
4829
5673
|
finally {
|
|
@@ -4855,9 +5699,10 @@ export class AgentManager {
|
|
|
4855
5699
|
}
|
|
4856
5700
|
};
|
|
4857
5701
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
5702
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
|
|
4858
5703
|
const waitReady = async () => {
|
|
4859
5704
|
try {
|
|
4860
|
-
await this.waitForReplPromptReady(tmux,
|
|
5705
|
+
await this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.manualCompactWaitMs);
|
|
4861
5706
|
}
|
|
4862
5707
|
catch (err) {
|
|
4863
5708
|
const detail = err instanceof Error ? err.message : String(err);
|
|
@@ -4866,13 +5711,13 @@ export class AgentManager {
|
|
|
4866
5711
|
};
|
|
4867
5712
|
await waitReady();
|
|
4868
5713
|
await assertSessionUnchanged();
|
|
4869
|
-
await tmux.clearComposerDraft(
|
|
5714
|
+
await tmux.clearComposerDraft(pane);
|
|
4870
5715
|
await waitReady();
|
|
4871
5716
|
await assertSessionUnchanged();
|
|
4872
|
-
await tmux.sendKeysLiteral(
|
|
4873
|
-
await tmux.sendEnter(
|
|
5717
|
+
await tmux.sendKeysLiteral(pane, command);
|
|
5718
|
+
await tmux.sendEnter(pane);
|
|
4874
5719
|
guardHandedOff = true;
|
|
4875
|
-
void this.waitForReplPromptReady(tmux,
|
|
5720
|
+
void this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs)
|
|
4876
5721
|
.catch(err => {
|
|
4877
5722
|
console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) post idle wait failed:`, err);
|
|
4878
5723
|
})
|
|
@@ -4891,25 +5736,98 @@ export class AgentManager {
|
|
|
4891
5736
|
async clearAgent(agentId) {
|
|
4892
5737
|
return this.sendSlashCommand(agentId, '/clear');
|
|
4893
5738
|
}
|
|
4894
|
-
|
|
5739
|
+
registerPendingDispatchRetry(taskId, entry, budgetOverride) {
|
|
5740
|
+
const prev = this.pendingDispatchRetryByTask.get(taskId);
|
|
5741
|
+
// 预算随登记代走:同 kind 但 pass 换代(token/agent 变化)时必须重置 since 与告警标志。
|
|
5742
|
+
// budgetOverride 供对账内部重排延续同一笔欠投递的预算——它只经 startSession 的 armedToken
|
|
5743
|
+
// fence 注入,外部 successor 的登记结构上拿不到
|
|
5744
|
+
const sameGeneration = prev?.kind === entry.kind
|
|
5745
|
+
&& prev.agentId === entry.agentId
|
|
5746
|
+
&& prev.signalToken === entry.signalToken;
|
|
5747
|
+
const since = sameGeneration ? prev.since : (budgetOverride?.since ?? Date.now());
|
|
5748
|
+
const budgetAlerted = sameGeneration
|
|
5749
|
+
? prev.budgetAlerted === true
|
|
5750
|
+
: budgetOverride?.budgetAlerted === true;
|
|
5751
|
+
this.pendingDispatchRetryByTask.set(taskId, {
|
|
5752
|
+
...entry,
|
|
5753
|
+
since,
|
|
5754
|
+
...(budgetAlerted ? { budgetAlerted: true } : {}),
|
|
5755
|
+
});
|
|
5756
|
+
}
|
|
5757
|
+
getPendingDispatchRetry(taskId) {
|
|
5758
|
+
return this.pendingDispatchRetryByTask.get(taskId);
|
|
5759
|
+
}
|
|
5760
|
+
listPendingDispatchRetries() {
|
|
5761
|
+
return this.pendingDispatchRetryByTask;
|
|
5762
|
+
}
|
|
5763
|
+
clearPendingDispatchRetry(taskId) {
|
|
5764
|
+
this.pendingDispatchRetryByTask.delete(taskId);
|
|
5765
|
+
}
|
|
5766
|
+
clearPendingDispatchRetryIfMatches(taskId, expected) {
|
|
5767
|
+
const entry = this.pendingDispatchRetryByTask.get(taskId);
|
|
5768
|
+
if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
|
|
5769
|
+
this.pendingDispatchRetryByTask.delete(taskId);
|
|
5770
|
+
}
|
|
5771
|
+
}
|
|
5772
|
+
markPendingDispatchRetryBudgetAlerted(taskId, expected) {
|
|
5773
|
+
const entry = this.pendingDispatchRetryByTask.get(taskId);
|
|
5774
|
+
if (entry?.agentId === expected.agentId && entry.signalToken === expected.signalToken) {
|
|
5775
|
+
this.pendingDispatchRetryByTask.set(taskId, { ...entry, budgetAlerted: true });
|
|
5776
|
+
}
|
|
5777
|
+
}
|
|
5778
|
+
async queueQaBusyPendingRetry(taskId, agentId, phase, createdSession, err, dispatch) {
|
|
5779
|
+
if (!(err instanceof ReplNotReadyError))
|
|
5780
|
+
return null;
|
|
5781
|
+
if (phase !== 'review' && phase !== 'recheck')
|
|
5782
|
+
return null;
|
|
5783
|
+
// 无本次派发的 pass 令牌就不登记(宁走既有失败路径,不造无 fence 重试);
|
|
5784
|
+
// 令牌漂移说明 pass 已被接管,忙碌属于旧派发,不得登记到 successor 上
|
|
5785
|
+
const passToken = dispatch.passToken;
|
|
5786
|
+
if (!passToken)
|
|
5787
|
+
return null;
|
|
5788
|
+
let current;
|
|
5789
|
+
try {
|
|
5790
|
+
current = await this.taskStore.get(taskId);
|
|
5791
|
+
}
|
|
5792
|
+
catch (readErr) {
|
|
5793
|
+
console.warn(`[AgentManager] startSession[${phase}]: busy on task ${taskId} but pass token could not be verified; ` +
|
|
5794
|
+
`falling back to the non-pending failure path:`, readErr);
|
|
5795
|
+
return null;
|
|
5796
|
+
}
|
|
5797
|
+
if (current?.signalToken !== passToken) {
|
|
5798
|
+
console.warn(`[AgentManager] startSession[${phase}]: busy on task ${taskId} but the review pass was superseded ` +
|
|
5799
|
+
`(expected ${passToken}, now ${current?.signalToken ?? 'gone'}); not queueing a pending retry`);
|
|
5800
|
+
return null;
|
|
5801
|
+
}
|
|
5802
|
+
this.registerPendingDispatchRetry(taskId, {
|
|
5803
|
+
kind: 'qa-recheck',
|
|
5804
|
+
agentId,
|
|
5805
|
+
signalToken: passToken,
|
|
5806
|
+
qaPhase: phase,
|
|
5807
|
+
}, dispatch.pendingBudget);
|
|
5808
|
+
console.warn(`[AgentManager] startSession[${phase}]: QA pane busy for task ${taskId}; ` +
|
|
5809
|
+
`${phase} queued for redispatch when idle (${(err.message.split('\n')[0])})`);
|
|
5810
|
+
return new EnsureSessionError({ createdSession, agentId, handled: true, busyPending: true }, `QA REPL busy; ${phase} dispatch for task ${taskId} queued for redispatch when idle`);
|
|
5811
|
+
}
|
|
5812
|
+
async clearRuntimeForTaskBoundary(tmux, pane, agentId, runtime, revalidate) {
|
|
4895
5813
|
await this.acquireCompactGuard(agentId);
|
|
4896
5814
|
try {
|
|
4897
|
-
await this.waitForReplPromptReady(tmux,
|
|
5815
|
+
await this.waitForReplPromptReady(tmux, pane, runtime, this.cleanComposerWaitMs, { stableIdle: true });
|
|
4898
5816
|
await revalidate();
|
|
4899
|
-
await tmux.clearComposerDraft(
|
|
4900
|
-
const baseline = await tmux.captureSettledSnapshot(
|
|
5817
|
+
await tmux.clearComposerDraft(pane);
|
|
5818
|
+
const baseline = await tmux.captureSettledSnapshot(pane, {
|
|
4901
5819
|
timeoutMs: this.dispatchSettleTimeoutMs,
|
|
4902
5820
|
});
|
|
4903
|
-
const baselineTitle = await tmux.readPaneTitle(
|
|
4904
|
-
await tmux.sendKeysLiteral(
|
|
4905
|
-
await tmux.sendEnter(
|
|
4906
|
-
await tmux.waitSubmitAck(
|
|
5821
|
+
const baselineTitle = await tmux.readPaneTitle(pane);
|
|
5822
|
+
await tmux.sendKeysLiteral(pane, '/clear');
|
|
5823
|
+
await tmux.sendEnter(pane);
|
|
5824
|
+
await tmux.waitSubmitAck(pane, baseline, runtime, {
|
|
4907
5825
|
timeoutMs: this.dispatchAckTimeoutMs,
|
|
4908
5826
|
baselineTitle,
|
|
4909
5827
|
acceptComposerChange: true,
|
|
4910
5828
|
});
|
|
4911
|
-
await this.waitForReplPromptReady(tmux,
|
|
4912
|
-
if (await this.hasRuntimeSlashCommandRejection(tmux,
|
|
5829
|
+
await this.waitForReplPromptReady(tmux, pane, runtime, this.dispatchAckTimeoutMs, { stableIdle: true });
|
|
5830
|
+
if (await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear')) {
|
|
4913
5831
|
throw new Error('Runtime rejected /clear at the task boundary; refusing to reuse prior task context');
|
|
4914
5832
|
}
|
|
4915
5833
|
await revalidate();
|
|
@@ -4986,6 +5904,8 @@ export class AgentManager {
|
|
|
4986
5904
|
error: `Task ${taskId} has no preferredAgentId; agentId is required in request body`,
|
|
4987
5905
|
};
|
|
4988
5906
|
}
|
|
5907
|
+
// Capture generation before the first config/state read so a DELETE→same-id recreate in the read→commit window fails the gate.
|
|
5908
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
4989
5909
|
const cfg = this.getAgentConfig(agentId);
|
|
4990
5910
|
if (!cfg)
|
|
4991
5911
|
return { task: fresh, errorCode: 400, error: `Unknown agent: ${agentId}` };
|
|
@@ -5006,6 +5926,9 @@ export class AgentManager {
|
|
|
5006
5926
|
if (!canDispatchWithBinding(state)) {
|
|
5007
5927
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is busy or awaiting human` };
|
|
5008
5928
|
}
|
|
5929
|
+
if (this.isDeletionInFlight(agentId)) {
|
|
5930
|
+
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
|
|
5931
|
+
}
|
|
5009
5932
|
const lockToken = await this.lockManager.acquire(agentId, taskId);
|
|
5010
5933
|
if (!lockToken) {
|
|
5011
5934
|
return { task: fresh, errorCode: 409, error: `Agent ${agentId} lock acquisition failed` };
|
|
@@ -5024,6 +5947,10 @@ export class AgentManager {
|
|
|
5024
5947
|
? group?.find(agent => agent.role === 'qa')?.id
|
|
5025
5948
|
: fresh.qaAgentId;
|
|
5026
5949
|
const researchId = initiallyUnassigned && cfg.role === 'research' ? cfg.id : fresh.researchAgentId;
|
|
5950
|
+
// Pin every participant's generation (mirror createTask) so a member deleted→recreated before the active write isn't baked in.
|
|
5951
|
+
const participantGens = new Map([devId, ...(qaId ? [qaId] : []), ...(researchId ? [researchId] : [])]
|
|
5952
|
+
.map(pid => [pid, this.deletionGenerationOf(pid)]));
|
|
5953
|
+
const participantsFresh = () => [...participantGens].every(([pid, gen]) => this.deletionGenerationOf(pid) === gen && !this.isDeletionInFlight(pid));
|
|
5027
5954
|
const claimedTask = {
|
|
5028
5955
|
...fresh,
|
|
5029
5956
|
preferredAgentId: agentId,
|
|
@@ -5035,18 +5962,78 @@ export class AgentManager {
|
|
|
5035
5962
|
status: 'in_progress',
|
|
5036
5963
|
updatedAt: now,
|
|
5037
5964
|
};
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5965
|
+
// Binding-first: the fresh task snapshot already exists on disk, so a failure at any later
|
|
5966
|
+
// step leaves at worst a stale binding to a still-unclaimed task — an existing reclaimable state.
|
|
5967
|
+
const releaseLock = async () => {
|
|
5968
|
+
try {
|
|
5969
|
+
await this.lockManager.releaseIfOwner(agentId, taskId, lockToken);
|
|
5970
|
+
}
|
|
5971
|
+
catch (releaseErr) {
|
|
5972
|
+
console.warn(`[AgentManager] claimTask(${taskId}): lock release after failed claim also failed:`, releaseErr);
|
|
5973
|
+
}
|
|
5974
|
+
};
|
|
5975
|
+
let commit;
|
|
5976
|
+
try {
|
|
5977
|
+
commit = await this.agentStore.update(agentId, (existing) => {
|
|
5978
|
+
if (existing === null && !this.bindingInitPredicate(agentId, genAtEntry))
|
|
5979
|
+
return AGENT_STORE_NOOP;
|
|
5980
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
5981
|
+
return AGENT_STORE_NOOP;
|
|
5982
|
+
return {
|
|
5983
|
+
id: agentId,
|
|
5984
|
+
projectId: cfg.projectId,
|
|
5985
|
+
taskId,
|
|
5986
|
+
lockToken,
|
|
5987
|
+
bootstrappingTaskId: taskId,
|
|
5988
|
+
updatedAt: now,
|
|
5989
|
+
...(existing?.paneId !== undefined ? { paneId: existing.paneId } : {}),
|
|
5990
|
+
...(existing?.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
5991
|
+
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
5992
|
+
// A new task never inherits the previous one's question: void the watermark.
|
|
5993
|
+
needInput: { epoch: (existing?.needInput?.epoch ?? 0) + 1 },
|
|
5994
|
+
};
|
|
5995
|
+
});
|
|
5996
|
+
}
|
|
5997
|
+
catch (err) {
|
|
5998
|
+
await releaseLock();
|
|
5999
|
+
throw err;
|
|
6000
|
+
}
|
|
6001
|
+
if (commit !== 'committed') {
|
|
6002
|
+
await releaseLock();
|
|
6003
|
+
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
|
|
6004
|
+
}
|
|
6005
|
+
// Re-verify lock ownership + preferred/participant generations before the active write, else a DELETE after the binding commit orphans it.
|
|
6006
|
+
if (this.isDeletionInFlight(agentId)
|
|
6007
|
+
|| !this.deletionGateOpen(agentId, genAtEntry)
|
|
6008
|
+
|| !participantsFresh()
|
|
6009
|
+
|| !(await this.lockManager.isOwner(agentId, taskId, lockToken))) {
|
|
6010
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
6011
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
|
|
6012
|
+
return AGENT_STORE_NOOP;
|
|
6013
|
+
const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
|
|
6014
|
+
return { ...rest, updatedAt: new Date().toISOString() };
|
|
6015
|
+
});
|
|
6016
|
+
await releaseLock();
|
|
6017
|
+
return { task: fresh, errorCode: 409, error: `Agent ${agentId} is being deleted` };
|
|
6018
|
+
}
|
|
6019
|
+
try {
|
|
6020
|
+
await this.taskStore.set(claimedTask);
|
|
6021
|
+
}
|
|
6022
|
+
catch (err) {
|
|
6023
|
+
try {
|
|
6024
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
6025
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken)
|
|
6026
|
+
return AGENT_STORE_NOOP;
|
|
6027
|
+
const { taskId: _t, lockToken: _l, bootstrappingTaskId: _b, ...rest } = existing;
|
|
6028
|
+
return { ...rest, updatedAt: new Date().toISOString() };
|
|
6029
|
+
});
|
|
6030
|
+
}
|
|
6031
|
+
catch (rollbackErr) {
|
|
6032
|
+
console.warn(`[AgentManager] claimTask(${taskId}): binding rollback after task write failure also failed (stale-binding path will reclaim):`, rollbackErr);
|
|
6033
|
+
}
|
|
6034
|
+
await releaseLock();
|
|
6035
|
+
throw err;
|
|
6036
|
+
}
|
|
5050
6037
|
await this.safeEmit({
|
|
5051
6038
|
id: '',
|
|
5052
6039
|
type: 'task.assigned',
|
|
@@ -5146,6 +6133,8 @@ export class AgentManager {
|
|
|
5146
6133
|
const agent = this.getAgentConfig(agentId);
|
|
5147
6134
|
if (!agent)
|
|
5148
6135
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
6136
|
+
// Pin the incarnation: a DELETE completing during dispatch bumps this, and every gated commit/side-effect below refuses the stale generation.
|
|
6137
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
5149
6138
|
const task = await this.taskStore.get(taskId);
|
|
5150
6139
|
if (!task)
|
|
5151
6140
|
throw new Error(`Unknown task: ${taskId}`);
|
|
@@ -5191,6 +6180,11 @@ export class AgentManager {
|
|
|
5191
6180
|
return false;
|
|
5192
6181
|
}
|
|
5193
6182
|
const assertOwner = async () => {
|
|
6183
|
+
// Also gate on the entry generation: a DELETE→same-id recreate can pass the task/lock check on the
|
|
6184
|
+
// reused binding, so every Workdir/runtime side-effect guarded by assertOwner must fail closed on it.
|
|
6185
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
6186
|
+
throw new Error(`Agent ${agentId} is being deleted or was recreated for task ${taskId}; operation aborted`);
|
|
6187
|
+
}
|
|
5194
6188
|
const stateNow = await this.agentStore.get(agentId);
|
|
5195
6189
|
if (stateNow?.taskId !== taskId || stateNow.lockToken !== lockToken) {
|
|
5196
6190
|
throw new Error(`Agent ${agentId} binding changed for task ${taskId}; operation aborted`);
|
|
@@ -5201,10 +6195,10 @@ export class AgentManager {
|
|
|
5201
6195
|
const dialogFailFromStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
|
|
5202
6196
|
let ensure;
|
|
5203
6197
|
try {
|
|
5204
|
-
ensure = await this.ensureSession(agentId, 'runtime');
|
|
6198
|
+
ensure = await this.ensureSession(agentId, 'runtime', { expectedGeneration: genAtEntry });
|
|
5205
6199
|
}
|
|
5206
6200
|
catch (err) {
|
|
5207
|
-
if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses })) {
|
|
6201
|
+
if (await this.handleDialogPendingFromRuntime(agentId, err, { expectedFromStatuses: dialogFailFromStatuses, expectedGeneration: genAtEntry })) {
|
|
5208
6202
|
throw err;
|
|
5209
6203
|
}
|
|
5210
6204
|
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
@@ -5222,7 +6216,7 @@ export class AgentManager {
|
|
|
5222
6216
|
}
|
|
5223
6217
|
throw err;
|
|
5224
6218
|
}
|
|
5225
|
-
const { paneId, workdir } = ensure;
|
|
6219
|
+
const { paneId, pane, workdir } = ensure;
|
|
5226
6220
|
const runner = this.createRunnerFor(agent);
|
|
5227
6221
|
const tmux = new TmuxManager(runner);
|
|
5228
6222
|
const branchManager = new BranchManager(runner);
|
|
@@ -5230,7 +6224,15 @@ export class AgentManager {
|
|
|
5230
6224
|
if (!lockState || lockState.taskId !== taskId || lockState.lockToken !== lockToken)
|
|
5231
6225
|
return false;
|
|
5232
6226
|
await assertOwner();
|
|
5233
|
-
|
|
6227
|
+
try {
|
|
6228
|
+
await this.waitForReplPromptReady(tmux, pane, agent.runtime, this.cleanComposerWaitMs, { stableIdle: true });
|
|
6229
|
+
}
|
|
6230
|
+
catch (err) {
|
|
6231
|
+
const busyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
|
|
6232
|
+
if (busyPend)
|
|
6233
|
+
throw busyPend;
|
|
6234
|
+
throw err;
|
|
6235
|
+
}
|
|
5234
6236
|
const isServerQaPhase = phase === 'server-review' || phase === 'server-recheck' || phase === 'server-spec-review';
|
|
5235
6237
|
const isServerCodeReviewPhase = phase === 'server-review' || phase === 'server-recheck';
|
|
5236
6238
|
let reviewCheckout;
|
|
@@ -5266,12 +6268,15 @@ export class AgentManager {
|
|
|
5266
6268
|
}
|
|
5267
6269
|
await assertOwner();
|
|
5268
6270
|
if (!ensure.freshRuntime) {
|
|
5269
|
-
await this.clearRuntimeForTaskBoundary(tmux,
|
|
6271
|
+
await this.clearRuntimeForTaskBoundary(tmux, pane, agentId, agent.runtime, assertOwner);
|
|
5270
6272
|
if (ensure.skillsStale)
|
|
5271
6273
|
await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
|
|
5272
6274
|
}
|
|
5273
6275
|
}
|
|
5274
6276
|
catch (err) {
|
|
6277
|
+
const busyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
|
|
6278
|
+
if (busyPend)
|
|
6279
|
+
throw busyPend;
|
|
5275
6280
|
const message = err instanceof Error ? err.message : String(err);
|
|
5276
6281
|
const holdPhase = err instanceof DirtyWorkdirError
|
|
5277
6282
|
? 'dirty-workdir'
|
|
@@ -5311,9 +6316,9 @@ export class AgentManager {
|
|
|
5311
6316
|
await this.agentStore.update(agentId, (stateNow) => {
|
|
5312
6317
|
if (!stateNow || stateNow.taskId !== taskId)
|
|
5313
6318
|
return AGENT_STORE_NOOP;
|
|
5314
|
-
|
|
6319
|
+
// Watermark stays: the arm-time epoch bump owns superseding the old question.
|
|
5315
6320
|
return {
|
|
5316
|
-
...
|
|
6321
|
+
...stateNow,
|
|
5317
6322
|
paneId,
|
|
5318
6323
|
workdir,
|
|
5319
6324
|
updatedAt: new Date().toISOString(),
|
|
@@ -5346,13 +6351,16 @@ export class AgentManager {
|
|
|
5346
6351
|
...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
|
|
5347
6352
|
});
|
|
5348
6353
|
}
|
|
6354
|
+
const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
|
|
6355
|
+
const platformCli = this.platformCliContextOf(taskForPrompt);
|
|
5349
6356
|
prompt = buildPromptInline({
|
|
5350
|
-
task,
|
|
6357
|
+
task: taskForPrompt,
|
|
5351
6358
|
phase,
|
|
5352
6359
|
agent,
|
|
5353
6360
|
workdir: dispatchWorkdir,
|
|
5354
6361
|
skillRegistry: this.skillRegistry,
|
|
5355
6362
|
hasQaPartner,
|
|
6363
|
+
...(platformCli ? { platformCli } : {}),
|
|
5356
6364
|
...(promptSignalToken ? { signalToken: promptSignalToken } : {}),
|
|
5357
6365
|
...(promptSpecRound !== undefined ? { currentSpecRound: promptSpecRound } : {}),
|
|
5358
6366
|
...(imagePaths.length ? { imagePaths } : {}),
|
|
@@ -5416,12 +6424,20 @@ export class AgentManager {
|
|
|
5416
6424
|
let agentMarkedRunning = false;
|
|
5417
6425
|
try {
|
|
5418
6426
|
let cancelHoldWon = false;
|
|
5419
|
-
|
|
6427
|
+
let deletedDuringDispatch = false;
|
|
6428
|
+
const runningCommit = await this.agentStore.update(agentId, (existing) => {
|
|
5420
6429
|
if (isCancelCleanupHold(existing)) {
|
|
5421
6430
|
cancelHoldWon = true;
|
|
5422
6431
|
return AGENT_STORE_NOOP;
|
|
5423
6432
|
}
|
|
6433
|
+
// 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).
|
|
6434
|
+
if (!existing || existing.taskId !== taskId || existing.lockToken !== lockToken
|
|
6435
|
+
|| !this.deletionGateOpen(agentId, genAtEntry)) {
|
|
6436
|
+
deletedDuringDispatch = true;
|
|
6437
|
+
return AGENT_STORE_NOOP;
|
|
6438
|
+
}
|
|
5424
6439
|
return {
|
|
6440
|
+
...existing,
|
|
5425
6441
|
id: agentId,
|
|
5426
6442
|
projectId: agent.projectId,
|
|
5427
6443
|
paneId,
|
|
@@ -5431,7 +6447,6 @@ export class AgentManager {
|
|
|
5431
6447
|
startedAt: now,
|
|
5432
6448
|
bootstrappingTaskId: taskId,
|
|
5433
6449
|
updatedAt: now,
|
|
5434
|
-
...(existing?.creationToken !== undefined ? { creationToken: existing.creationToken } : {}),
|
|
5435
6450
|
};
|
|
5436
6451
|
});
|
|
5437
6452
|
if (cancelHoldWon) {
|
|
@@ -5440,8 +6455,39 @@ export class AgentManager {
|
|
|
5440
6455
|
await cleanupCheckoutOrHold();
|
|
5441
6456
|
return false;
|
|
5442
6457
|
}
|
|
6458
|
+
if (deletedDuringDispatch || runningCommit !== 'committed') {
|
|
6459
|
+
console.warn(`[AgentManager] startSession[${phase}]: agent ${agentId} binding vanished or was deleted/recreated ` +
|
|
6460
|
+
`during dispatch; not marking running (no orphan state)`);
|
|
6461
|
+
return false;
|
|
6462
|
+
}
|
|
5443
6463
|
agentMarkedRunning = true;
|
|
5444
|
-
|
|
6464
|
+
// armed pass 的 fence 延伸到最终 paste:checkout//clear/artifacts 的 await 窗口里 pass 可被
|
|
6465
|
+
// push/其他 dispatcher 轮换,旧 prompt 不得注入 successor;guard 在 composer/paste/Enter
|
|
6466
|
+
// 的 task-mutation 队列槽内复核(与轮换串行),漂移即放弃投递。
|
|
6467
|
+
// cancel 只写终态不轮换 token,故 fence 须同时复核任务仍处本相位的可投递状态
|
|
6468
|
+
const passDeliverableStatuses = opts.dialogFailFromStatuses ?? PHASE_EXPECTED_STATUS[phase] ?? [...ACTIVE_TASK_STATUSES];
|
|
6469
|
+
const passStillArmed = opts.dispatchPassToken !== undefined
|
|
6470
|
+
? async () => {
|
|
6471
|
+
const freshTask = await this.taskStore.get(taskId);
|
|
6472
|
+
if (!freshTask || freshTask.signalToken !== opts.dispatchPassToken)
|
|
6473
|
+
return false;
|
|
6474
|
+
return passDeliverableStatuses.includes(freshTask.status);
|
|
6475
|
+
}
|
|
6476
|
+
: undefined;
|
|
6477
|
+
if (passStillArmed && !(await passStillArmed())) {
|
|
6478
|
+
console.warn(`[AgentManager] startSession[${phase}]: review pass superseded before injection for task ${taskId}; aborting paste`);
|
|
6479
|
+
return false;
|
|
6480
|
+
}
|
|
6481
|
+
const injectResult = await this.injectAndAwaitAck(tmux, pane, prompt, agentId, agent.runtime, passStillArmed);
|
|
6482
|
+
if (injectResult.aborted === true) {
|
|
6483
|
+
console.warn(`[AgentManager] startSession[${phase}]: injection fenced off (pass superseded) for task ${taskId}`);
|
|
6484
|
+
return false;
|
|
6485
|
+
}
|
|
6486
|
+
// prompt 已确认投递:只 CAS 清理本次派发那一代的登记;successor 在窗口内登记的新 pending 不受影响,
|
|
6487
|
+
// 且清理先于可失败的审计事件——事件写失败不能把已投递的 prompt 重新变回 pending
|
|
6488
|
+
if (opts.dispatchPassToken !== undefined) {
|
|
6489
|
+
this.clearPendingDispatchRetryIfMatches(taskId, { agentId, signalToken: opts.dispatchPassToken });
|
|
6490
|
+
}
|
|
5445
6491
|
try {
|
|
5446
6492
|
await this.clearBootstrapMarker(agentId, taskId);
|
|
5447
6493
|
}
|
|
@@ -5465,6 +6511,11 @@ export class AgentManager {
|
|
|
5465
6511
|
return true;
|
|
5466
6512
|
}
|
|
5467
6513
|
catch (err) {
|
|
6514
|
+
// 注入阶段的忙碌(预注入活回合检查)仍在任何按键之前:登记 pending 并保持 checkout/绑定原样,
|
|
6515
|
+
// 在途回合还在使用该工作树,park/release 会拽走它
|
|
6516
|
+
const injectBusyPend = await this.queueQaBusyPendingRetry(taskId, agentId, phase, ensure.createdSession, err, { passToken: opts.dispatchPassToken, pendingBudget: opts.dispatchPendingBudget });
|
|
6517
|
+
if (injectBusyPend)
|
|
6518
|
+
throw injectBusyPend;
|
|
5468
6519
|
const isAckUnknown = err instanceof DispatchTerminalError && err.reason === 'ack_unknown';
|
|
5469
6520
|
if (!isAckUnknown) {
|
|
5470
6521
|
await cleanupCheckoutOrHold();
|
|
@@ -5507,7 +6558,7 @@ export class AgentManager {
|
|
|
5507
6558
|
throw err;
|
|
5508
6559
|
}
|
|
5509
6560
|
}
|
|
5510
|
-
async injectAndAwaitAck(tmux,
|
|
6561
|
+
async injectAndAwaitAck(tmux, pane, prompt, agentId, runtime, guardBeforePaste) {
|
|
5511
6562
|
const before = await this.agentStore.get(agentId);
|
|
5512
6563
|
const beforeLockToken = before?.taskId
|
|
5513
6564
|
? await this.resolveTaskLockToken(before, before.taskId)
|
|
@@ -5559,21 +6610,22 @@ export class AgentManager {
|
|
|
5559
6610
|
if (guardBeforePaste && !(await guardBeforePaste())) {
|
|
5560
6611
|
return { acked: false, composerDelivered: false, aborted: true };
|
|
5561
6612
|
}
|
|
5562
|
-
return await this.injectAndAwaitAckSteps(tmux,
|
|
6613
|
+
return await this.injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste);
|
|
5563
6614
|
}
|
|
5564
6615
|
finally {
|
|
5565
6616
|
this.compactInFlight.delete(agentId);
|
|
5566
6617
|
}
|
|
5567
6618
|
}
|
|
5568
|
-
async injectAndAwaitAckSteps(tmux,
|
|
6619
|
+
async injectAndAwaitAckSteps(tmux, pane, prompt, agentId, runtime, revalidate, guardBeforePaste) {
|
|
6620
|
+
const paneId = pane.paneId;
|
|
5569
6621
|
// 静态忙信号(文本忙样式/working title)可能是残稿或 stale title 冒充,一律由帧活性仲裁;ready 视图覆盖 stale title。
|
|
5570
|
-
const preTitle = await tmux.readPaneTitle(
|
|
5571
|
-
const preFrame = await tmux.capturePaneById(
|
|
6622
|
+
const preTitle = await tmux.readPaneTitle(pane);
|
|
6623
|
+
const preFrame = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
5572
6624
|
const staticBusy = hasOscTitleWorking(preTitle)
|
|
5573
6625
|
? (runtimeBusyCheck(preFrame, runtime) || !hasRuntimeReadyView(preFrame, runtime))
|
|
5574
6626
|
: runtimeBusyCheck(preFrame, runtime);
|
|
5575
|
-
if (staticBusy && await this.paneHasLiveTurn(tmux,
|
|
5576
|
-
throw new
|
|
6627
|
+
if (staticBusy && await this.paneHasLiveTurn(tmux, pane, runtime)) {
|
|
6628
|
+
throw new ReplNotReadyError(paneId, runtime, preFrame, `pre-inject busy check: pane ${paneId} is still running a turn; dispatch aborted`);
|
|
5577
6629
|
}
|
|
5578
6630
|
await revalidate?.();
|
|
5579
6631
|
if (guardBeforePaste) {
|
|
@@ -5588,7 +6640,7 @@ export class AgentManager {
|
|
|
5588
6640
|
pasted = await this.withTaskLock(async () => {
|
|
5589
6641
|
if (!(await guardBeforePaste()))
|
|
5590
6642
|
return false;
|
|
5591
|
-
await tmux.clearComposerDraft(
|
|
6643
|
+
await tmux.clearComposerDraft(pane);
|
|
5592
6644
|
await tmux.pasteStagedBuffer(paneId, staged.buf);
|
|
5593
6645
|
return true;
|
|
5594
6646
|
});
|
|
@@ -5605,7 +6657,7 @@ export class AgentManager {
|
|
|
5605
6657
|
}
|
|
5606
6658
|
if (bufferConsumed) {
|
|
5607
6659
|
try {
|
|
5608
|
-
await tmux.clearComposerDraft(
|
|
6660
|
+
await tmux.clearComposerDraft(pane);
|
|
5609
6661
|
}
|
|
5610
6662
|
catch (clearErr) {
|
|
5611
6663
|
console.warn(`[AgentManager] composer scrub after unknown paste outcome failed for pane ${paneId}:`, clearErr);
|
|
@@ -5624,26 +6676,26 @@ export class AgentManager {
|
|
|
5624
6676
|
}
|
|
5625
6677
|
}
|
|
5626
6678
|
else {
|
|
5627
|
-
await tmux.clearComposerDraft(
|
|
6679
|
+
await tmux.clearComposerDraft(pane);
|
|
5628
6680
|
await revalidate?.();
|
|
5629
|
-
await tmux.injectPrompt(
|
|
6681
|
+
await tmux.injectPrompt(pane, prompt, agentId);
|
|
5630
6682
|
}
|
|
5631
6683
|
let baseline;
|
|
5632
6684
|
let baselineTitle = '';
|
|
5633
6685
|
try {
|
|
5634
|
-
baseline = await tmux.captureSettledSnapshot(
|
|
5635
|
-
baselineTitle = await tmux.readPaneTitle(
|
|
6686
|
+
baseline = await tmux.captureSettledSnapshot(pane, { timeoutMs: this.dispatchSettleTimeoutMs });
|
|
6687
|
+
baselineTitle = await tmux.readPaneTitle(pane);
|
|
5636
6688
|
if (guardBeforePaste) {
|
|
5637
6689
|
// The Enter is the actual submission; it takes the same fenced queue slot.
|
|
5638
6690
|
const submitted = await this.withTaskLock(async () => {
|
|
5639
6691
|
if (!(await guardBeforePaste()))
|
|
5640
6692
|
return false;
|
|
5641
|
-
await tmux.sendEnter(
|
|
6693
|
+
await tmux.sendEnter(pane);
|
|
5642
6694
|
return true;
|
|
5643
6695
|
});
|
|
5644
6696
|
if (!submitted) {
|
|
5645
6697
|
try {
|
|
5646
|
-
await tmux.clearComposerDraft(
|
|
6698
|
+
await tmux.clearComposerDraft(pane);
|
|
5647
6699
|
}
|
|
5648
6700
|
catch (err) {
|
|
5649
6701
|
// Fail closed: a submit-ready stale prompt could not be confirmed gone.
|
|
@@ -5654,18 +6706,18 @@ export class AgentManager {
|
|
|
5654
6706
|
}
|
|
5655
6707
|
}
|
|
5656
6708
|
else {
|
|
5657
|
-
await tmux.sendEnter(
|
|
6709
|
+
await tmux.sendEnter(pane);
|
|
5658
6710
|
}
|
|
5659
6711
|
}
|
|
5660
6712
|
catch (preAckErr) {
|
|
5661
|
-
if (await this.clearComposerForReuse(tmux,
|
|
6713
|
+
if (await this.clearComposerForReuse(tmux, pane, agentId))
|
|
5662
6714
|
throw preAckErr;
|
|
5663
6715
|
const message = preAckErr instanceof Error ? preAckErr.message : String(preAckErr);
|
|
5664
6716
|
throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on live pane ${paneId}: ${message}`);
|
|
5665
6717
|
}
|
|
5666
6718
|
let staleDuringResend = false;
|
|
5667
6719
|
try {
|
|
5668
|
-
await tmux.waitSubmitAck(
|
|
6720
|
+
await tmux.waitSubmitAck(pane, baseline, runtime, {
|
|
5669
6721
|
timeoutMs: this.dispatchAckTimeoutMs,
|
|
5670
6722
|
baselineTitle,
|
|
5671
6723
|
resend: guardBeforePaste
|
|
@@ -5674,13 +6726,13 @@ export class AgentManager {
|
|
|
5674
6726
|
const resent = await this.withTaskLock(async () => {
|
|
5675
6727
|
if (!(await guardBeforePaste()))
|
|
5676
6728
|
return false;
|
|
5677
|
-
await tmux.sendEnter(
|
|
6729
|
+
await tmux.sendEnter(pane);
|
|
5678
6730
|
return true;
|
|
5679
6731
|
});
|
|
5680
6732
|
if (!resent)
|
|
5681
6733
|
staleDuringResend = true;
|
|
5682
6734
|
}
|
|
5683
|
-
: () => tmux.sendEnter(
|
|
6735
|
+
: () => tmux.sendEnter(pane),
|
|
5684
6736
|
resendIntervalMs: this.dispatchAckResendIntervalMs,
|
|
5685
6737
|
});
|
|
5686
6738
|
return { acked: true, composerDelivered: true };
|
|
@@ -5692,7 +6744,7 @@ export class AgentManager {
|
|
|
5692
6744
|
}
|
|
5693
6745
|
if (staleDuringResend) {
|
|
5694
6746
|
try {
|
|
5695
|
-
await tmux.clearComposerDraft(
|
|
6747
|
+
await tmux.clearComposerDraft(pane);
|
|
5696
6748
|
}
|
|
5697
6749
|
catch (scrubErr) {
|
|
5698
6750
|
const scrubMessage = scrubErr instanceof Error ? scrubErr.message : String(scrubErr);
|
|
@@ -5714,9 +6766,10 @@ export class AgentManager {
|
|
|
5714
6766
|
return { acked: false, composerDelivered };
|
|
5715
6767
|
}
|
|
5716
6768
|
}
|
|
5717
|
-
async clearComposerForReuse(tmux,
|
|
6769
|
+
async clearComposerForReuse(tmux, pane, agentId) {
|
|
6770
|
+
const paneId = pane.paneId;
|
|
5718
6771
|
try {
|
|
5719
|
-
await tmux.clearComposerDraft(
|
|
6772
|
+
await tmux.clearComposerDraft(pane);
|
|
5720
6773
|
return true;
|
|
5721
6774
|
}
|
|
5722
6775
|
catch (err) {
|
|
@@ -5793,7 +6846,7 @@ export class AgentManager {
|
|
|
5793
6846
|
}
|
|
5794
6847
|
throw err;
|
|
5795
6848
|
}
|
|
5796
|
-
const { paneId } = ensure;
|
|
6849
|
+
const { paneId, pane } = ensure;
|
|
5797
6850
|
const runner = this.createRunnerFor(agent);
|
|
5798
6851
|
const tmux = new TmuxManager(runner);
|
|
5799
6852
|
const stateAfterEnsure = await this.agentStore.get(agentId);
|
|
@@ -5877,13 +6930,16 @@ export class AgentManager {
|
|
|
5877
6930
|
...(opts.serverPriorResponse ? { serverPriorResponse: opts.serverPriorResponse } : {}),
|
|
5878
6931
|
});
|
|
5879
6932
|
}
|
|
6933
|
+
const taskForPrompt = await this.ensureGitBaseSnapshot(task, phase);
|
|
6934
|
+
const platformCli = this.platformCliContextOf(taskForPrompt);
|
|
5880
6935
|
prompt = buildPromptInline({
|
|
5881
|
-
task,
|
|
6936
|
+
task: taskForPrompt,
|
|
5882
6937
|
phase,
|
|
5883
6938
|
agent,
|
|
5884
6939
|
workdir: verifiedWorkdir,
|
|
5885
6940
|
skillRegistry: this.skillRegistry,
|
|
5886
6941
|
hasQaPartner: task.qaAgentId !== undefined,
|
|
6942
|
+
...(platformCli ? { platformCli } : {}),
|
|
5887
6943
|
...(signalToken ? { signalToken } : {}),
|
|
5888
6944
|
...(useIncrementalNudge
|
|
5889
6945
|
? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
|
|
@@ -5969,10 +7025,11 @@ export class AgentManager {
|
|
|
5969
7025
|
|| latest.workdir !== verifiedWorkdir) {
|
|
5970
7026
|
return AGENT_STORE_NOOP;
|
|
5971
7027
|
}
|
|
5972
|
-
// The continuation prompt supersedes
|
|
5973
|
-
|
|
7028
|
+
// The continuation prompt supersedes the previous question, but the arm-time
|
|
7029
|
+
// epoch bump owns that transition — stripping here would race an arm that
|
|
7030
|
+
// already re-established the watermark (post-approve arms before injecting).
|
|
5974
7031
|
return {
|
|
5975
|
-
...
|
|
7032
|
+
...latest,
|
|
5976
7033
|
paneId,
|
|
5977
7034
|
workdir: verifiedWorkdir,
|
|
5978
7035
|
lockToken,
|
|
@@ -5996,7 +7053,7 @@ export class AgentManager {
|
|
|
5996
7053
|
console.warn(`[AgentManager] continueSession[${phase}]: pre-inject guard rejected the paste for task ${taskId}; skipping`);
|
|
5997
7054
|
return false;
|
|
5998
7055
|
}
|
|
5999
|
-
const delivery = await this.injectAndAwaitAck(tmux,
|
|
7056
|
+
const delivery = await this.injectAndAwaitAck(tmux, pane, prompt, agentId, agent.runtime, guardBeforeInject);
|
|
6000
7057
|
if (delivery.aborted) {
|
|
6001
7058
|
console.warn(`[AgentManager] continueSession[${phase}]: pre-paste guard rejected the inject for task ${taskId}; skipping`);
|
|
6002
7059
|
return false;
|
|
@@ -6188,7 +7245,7 @@ export class AgentManager {
|
|
|
6188
7245
|
continue;
|
|
6189
7246
|
if (state.creationToken && !state.taskId) {
|
|
6190
7247
|
const ct = state.creationToken;
|
|
6191
|
-
const pane = result.
|
|
7248
|
+
const pane = result.pane;
|
|
6192
7249
|
const cfg = agentConfig;
|
|
6193
7250
|
const agentId = state.id;
|
|
6194
7251
|
void (async () => {
|
|
@@ -6197,7 +7254,7 @@ export class AgentManager {
|
|
|
6197
7254
|
if (!latest || latest.creationToken !== ct)
|
|
6198
7255
|
return AGENT_STORE_NOOP;
|
|
6199
7256
|
const { creationToken: _ct, status: _s, awaitingPhase: _ap, awaitingReason: _ar, awaitingSince: _as, ...ready } = latest;
|
|
6200
|
-
return { ...ready, paneId: pane, updatedAt: new Date().toISOString() };
|
|
7257
|
+
return { ...ready, paneId: pane.paneId, updatedAt: new Date().toISOString() };
|
|
6201
7258
|
});
|
|
6202
7259
|
}
|
|
6203
7260
|
else {
|
|
@@ -6275,6 +7332,7 @@ export class AgentManager {
|
|
|
6275
7332
|
...(latest.taskId !== undefined ? { taskId: latest.taskId } : {}),
|
|
6276
7333
|
...(latest.lockToken !== undefined ? { lockToken: latest.lockToken } : {}),
|
|
6277
7334
|
...(latest.startedAt !== undefined ? { startedAt: latest.startedAt } : {}),
|
|
7335
|
+
...(latest.needInput !== undefined ? { needInput: latest.needInput } : {}),
|
|
6278
7336
|
};
|
|
6279
7337
|
if (!preserveHeld)
|
|
6280
7338
|
return withBinding;
|
|
@@ -6615,19 +7673,22 @@ export class AgentManager {
|
|
|
6615
7673
|
async dispatchReviewToQa(taskId, opts = {}) {
|
|
6616
7674
|
const claim = await this.withTaskLock(async () => {
|
|
6617
7675
|
if (this.manualReviewInFlight.has(taskId)) {
|
|
6618
|
-
throw new ApiError(409, `Manual review already in progress for task ${taskId}
|
|
7676
|
+
throw new ApiError(409, `Manual review already in progress for task ${taskId}`, 'dispatch-in-flight');
|
|
6619
7677
|
}
|
|
6620
7678
|
if (this.markCompleteInFlight.has(taskId)) {
|
|
6621
|
-
throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly
|
|
7679
|
+
throw new ApiError(409, `Task ${taskId} is being completed (merge in progress); try again shortly`, 'dispatch-in-flight');
|
|
6622
7680
|
}
|
|
6623
7681
|
const task = await this.taskStore.get(taskId);
|
|
6624
7682
|
if (!task)
|
|
6625
7683
|
throw new ApiError(404, `Task ${taskId} not found`);
|
|
6626
7684
|
if (opts.fromStatus && !opts.fromStatus.includes(task.status)) {
|
|
6627
|
-
throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}
|
|
7685
|
+
throw new ApiError(409, `Task ${taskId} status is ${task.status}; review dispatch requires ${opts.fromStatus.join('/')}`, 'dispatch-superseded');
|
|
6628
7686
|
}
|
|
6629
7687
|
if (opts.expectSignalToken !== undefined && task.signalToken !== opts.expectSignalToken) {
|
|
6630
|
-
throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting
|
|
7688
|
+
throw new ApiError(409, `Task ${taskId} review pass changed during redispatch (signalToken rotated); aborting`, 'dispatch-superseded');
|
|
7689
|
+
}
|
|
7690
|
+
if (opts.expectReviewDispatchPending === true && task.reviewDispatchPending !== true) {
|
|
7691
|
+
throw new ApiError(409, `Task ${taskId} review dispatch pending was already consumed; aborting stale sweep`, 'dispatch-superseded');
|
|
6631
7692
|
}
|
|
6632
7693
|
if (task.reviewMode === 'server' || isSpecStagePhase(task.phase)) {
|
|
6633
7694
|
if (!MANUAL_SERVER_REVIEW_STATUSES.includes(task.status)) {
|
|
@@ -6668,17 +7729,33 @@ export class AgentManager {
|
|
|
6668
7729
|
throw new ApiError(409, `Task ${taskId} QA participant ${qaId} is unavailable`);
|
|
6669
7730
|
}
|
|
6670
7731
|
this.manualReviewInFlight.add(taskId);
|
|
6671
|
-
return {
|
|
7732
|
+
return {
|
|
7733
|
+
mode: 'github',
|
|
7734
|
+
qaId,
|
|
7735
|
+
devAgentId: task.agentId,
|
|
7736
|
+
taskStatusAtClaim: task.status,
|
|
7737
|
+
signalTokenAtClaim: task.signalToken,
|
|
7738
|
+
reviewRoundAtClaim: task.reviewRound,
|
|
7739
|
+
};
|
|
6672
7740
|
});
|
|
6673
7741
|
if (claim.mode === 'server') {
|
|
6674
7742
|
return this.runManualServerReview(taskId, claim);
|
|
6675
7743
|
}
|
|
6676
7744
|
try {
|
|
6677
|
-
const { qaId, devAgentId, taskStatusAtClaim } = claim;
|
|
7745
|
+
const { qaId, devAgentId, taskStatusAtClaim, signalTokenAtClaim, reviewRoundAtClaim } = claim;
|
|
6678
7746
|
const isTerminal = TERMINAL_STATUSES.includes(taskStatusAtClaim);
|
|
6679
|
-
|
|
6680
|
-
|
|
6681
|
-
|
|
7747
|
+
// 相位恢复归标准入口自己(手工 review API 与各补派方一视同仁):同代 pending 登记的
|
|
7748
|
+
// 原始相位最权威;重启丢登记时以持久化 round=0 兜底——轮次送达才入账,故 0 即首评从未送达
|
|
7749
|
+
const pendingEntry = this.getPendingDispatchRetry(taskId);
|
|
7750
|
+
const queuedPhase = pendingEntry?.kind === 'qa-recheck'
|
|
7751
|
+
&& pendingEntry.signalToken === signalTokenAtClaim
|
|
7752
|
+
? pendingEntry.qaPhase
|
|
7753
|
+
: undefined;
|
|
7754
|
+
const qaPhase = opts.qaPhase
|
|
7755
|
+
?? queuedPhase
|
|
7756
|
+
?? (taskStatusAtClaim === 'pending' || taskStatusAtClaim === 'in_progress' || reviewRoundAtClaim === 0
|
|
7757
|
+
? 'review'
|
|
7758
|
+
: 'recheck');
|
|
6682
7759
|
const prevQa = await this.agentStore.get(qaId);
|
|
6683
7760
|
const resumingHeldDispatch = prevQa?.taskId === taskId && prevQa.status === 'awaiting_human';
|
|
6684
7761
|
if (prevQa?.taskId === taskId) {
|
|
@@ -6703,12 +7780,13 @@ export class AgentManager {
|
|
|
6703
7780
|
throw new ApiError(409, `QA agent ${qaId} could not be released from task ${taskId}${holdNote}; review dispatch aborted`);
|
|
6704
7781
|
}
|
|
6705
7782
|
}
|
|
6706
|
-
|
|
7783
|
+
let acquiredLockToken;
|
|
7784
|
+
const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase, {
|
|
7785
|
+
onAcquired: (lockToken) => { acquiredLockToken = lockToken; },
|
|
7786
|
+
});
|
|
6707
7787
|
if (!acquired) {
|
|
6708
7788
|
throw new ApiError(409, `QA agent ${qaId} is busy or unavailable`);
|
|
6709
7789
|
}
|
|
6710
|
-
const acquiredClaim = await this.lockManager.claimOf(qaId);
|
|
6711
|
-
const acquiredLockToken = acquiredClaim?.taskId === taskId ? acquiredClaim.token : undefined;
|
|
6712
7790
|
const releaseOwnAcquire = acquiredLockToken !== undefined
|
|
6713
7791
|
? { expectedLockToken: acquiredLockToken }
|
|
6714
7792
|
: {};
|
|
@@ -6735,18 +7813,21 @@ export class AgentManager {
|
|
|
6735
7813
|
signalToken: pre?.signalToken,
|
|
6736
7814
|
reviewHeadAnchorSha: pre?.reviewHeadAnchorSha,
|
|
6737
7815
|
reviewDispatchedAt: pre?.reviewDispatchedAt,
|
|
7816
|
+
reviewRoundPending: pre?.reviewRoundPending,
|
|
6738
7817
|
passToken: pre?.passToken,
|
|
6739
7818
|
failToken: pre?.failToken,
|
|
6740
7819
|
};
|
|
6741
7820
|
const isTerminalAtClaim = TERMINAL_STATUSES.includes(taskStatusAtClaim);
|
|
6742
|
-
// 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass
|
|
6743
|
-
|
|
7821
|
+
// 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮。
|
|
7822
|
+
// 显式 bumpRound:true 是补派方读到 reviewRoundPending 后的权威意图(该轮从未计数),越过恢复期抑制
|
|
7823
|
+
const bumpRound = opts.bumpRound === true
|
|
7824
|
+
|| (opts.bumpRound !== false && !(resumingHeldDispatch && taskStatusAtClaim === 'review'));
|
|
6744
7825
|
// 回滚在锁内判定 pass 是否已被并发接管并返回;仅未被接管时才释放本次 acquire 的 QA,
|
|
6745
7826
|
// 否则会误清接管方 re-acquire 的同一 QA 绑定(同 task 同 agent,绕过 release 的 mismatch 保护)
|
|
6746
7827
|
const abortDispatch = async (dispatchToken) => {
|
|
6747
|
-
const takenOver = await this.rollbackDispatchReviewPhase1(taskId, taskStatusAtClaim, isTerminalAtClaim, snapshot,
|
|
7828
|
+
const takenOver = await this.rollbackDispatchReviewPhase1(taskId, taskStatusAtClaim, isTerminalAtClaim, snapshot, dispatchToken);
|
|
6748
7829
|
if (!takenOver)
|
|
6749
|
-
await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => undefined);
|
|
7830
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
|
|
6750
7831
|
if (devParked)
|
|
6751
7832
|
await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
|
|
6752
7833
|
};
|
|
@@ -6775,7 +7856,7 @@ export class AgentManager {
|
|
|
6775
7856
|
if (isGitDispatch && reviewAnchor === undefined) {
|
|
6776
7857
|
// 无锚评审轮不可裁决(verdictEligible 要求 anchorSha 且 pane verdict 已退役):
|
|
6777
7858
|
// 派出去只会卡死在 review,fail loud 让调用方看到确定性原因或重试瞬态失败
|
|
6778
|
-
const released = await this.releaseAgentForTask(qaId, taskId, 'idle').catch(() => false);
|
|
7859
|
+
const released = await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => false);
|
|
6779
7860
|
if (!released) {
|
|
6780
7861
|
// 预检失败叠加释放失败:QA 仍绑定会让下一次派发报 busy,必须显式暴露
|
|
6781
7862
|
const blockedTask = await this.taskStore.get(taskId);
|
|
@@ -6798,6 +7879,9 @@ export class AgentManager {
|
|
|
6798
7879
|
reviewHeadAnchorSha: reviewAnchor,
|
|
6799
7880
|
reviewDispatchedAt: new Date().toISOString(),
|
|
6800
7881
|
signalToken: createSignalToken(),
|
|
7882
|
+
// 未计轮 intent 与进入 review 同一原子写:两写之间崩溃会留下「有派发可重放、却无轮次可补计」
|
|
7883
|
+
// 的状态,恢复后首评仍停在旧轮
|
|
7884
|
+
...(bumpRound ? { reviewRoundPending: true } : {}),
|
|
6801
7885
|
// pair/pending 与新轮锚点同一原子写可见(spec §7):两写之间崩溃不得留下无 pending 的新轮
|
|
6802
7886
|
...(isGitDispatch ? { ...this.mintReviewTokenPair(), reviewDispatchPending: true } : {}),
|
|
6803
7887
|
});
|
|
@@ -6806,7 +7890,7 @@ export class AgentManager {
|
|
|
6806
7890
|
await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
|
|
6807
7891
|
if (devParked)
|
|
6808
7892
|
await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
|
|
6809
|
-
throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review
|
|
7893
|
+
throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review`, 'dispatch-superseded');
|
|
6810
7894
|
}
|
|
6811
7895
|
transitionToken = preDispatched.task.signalToken;
|
|
6812
7896
|
}
|
|
@@ -6827,7 +7911,9 @@ export class AgentManager {
|
|
|
6827
7911
|
}
|
|
6828
7912
|
await this.taskStore.set({
|
|
6829
7913
|
...fresh,
|
|
6830
|
-
|
|
7914
|
+
// 计轮送达时才落账:claim 段只持久化「未计轮」intent(与 push 直派链路同构),
|
|
7915
|
+
// busy/hold/崩溃等一切未送达出口都保留 intent,任何补派方(含重启后)恰好补计一次
|
|
7916
|
+
...(bumpRound ? { reviewRoundPending: true } : {}),
|
|
6831
7917
|
signalToken: armedToken,
|
|
6832
7918
|
...(fresh.reviewMode === 'git' ? { reviewDispatchPending: true } : {}),
|
|
6833
7919
|
updatedAt: new Date().toISOString(),
|
|
@@ -6837,10 +7923,14 @@ export class AgentManager {
|
|
|
6837
7923
|
if (!claim2.ok) {
|
|
6838
7924
|
// 未写入 armedToken:task 仍持 transitionToken,或已被接管方 rotate/漂移 → 用 transitionToken 判 takeover
|
|
6839
7925
|
await abortDispatch(transitionToken);
|
|
6840
|
-
throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})
|
|
7926
|
+
throw new ApiError(409, `Task ${taskId} left its review pass during dispatch (${claim2.reason})`, 'dispatch-superseded');
|
|
6841
7927
|
}
|
|
7928
|
+
opts.onPassArmed?.(armedToken);
|
|
6842
7929
|
const dispatchTaskAfterClaim = await this.taskStore.get(taskId);
|
|
6843
|
-
const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken,
|
|
7930
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, qaId, ['pr-approved', 'pr-changes-requested'], armedToken, {
|
|
7931
|
+
needInputMode: 'fresh',
|
|
7932
|
+
...(dispatchTaskAfterClaim?.reviewMode === 'git' ? { replaceScope: 'agent' } : {}),
|
|
7933
|
+
});
|
|
6844
7934
|
if (armed && dispatchTaskAfterClaim?.reviewMode === 'git') {
|
|
6845
7935
|
await this.rearmGitReconciliationWatcher(taskId);
|
|
6846
7936
|
}
|
|
@@ -6853,7 +7943,7 @@ export class AgentManager {
|
|
|
6853
7943
|
const preStart = await this.taskStore.get(taskId);
|
|
6854
7944
|
if (!preStart || !opts.fromStatus.includes(preStart.status) || preStart.signalToken !== armedToken) {
|
|
6855
7945
|
await abortDispatch(armedToken);
|
|
6856
|
-
throw new ApiError(409, `Task ${taskId} left ${opts.fromStatus.join('/')} during dispatch (now ${preStart?.status ?? 'gone'})
|
|
7946
|
+
throw new ApiError(409, `Task ${taskId} left ${opts.fromStatus.join('/')} during dispatch (now ${preStart?.status ?? 'gone'})`, 'dispatch-superseded');
|
|
6857
7947
|
}
|
|
6858
7948
|
}
|
|
6859
7949
|
let started = false;
|
|
@@ -6861,6 +7951,8 @@ export class AgentManager {
|
|
|
6861
7951
|
started = await this.startSession(taskId, qaId, qaPhase, {
|
|
6862
7952
|
bypassTaskStatusGate: true,
|
|
6863
7953
|
dialogFailFromStatuses: [isTerminalAtClaim ? taskStatusAtClaim : 'review'],
|
|
7954
|
+
dispatchPassToken: armedToken,
|
|
7955
|
+
...(opts.pendingBudget !== undefined ? { dispatchPendingBudget: opts.pendingBudget } : {}),
|
|
6864
7956
|
});
|
|
6865
7957
|
}
|
|
6866
7958
|
catch (err) {
|
|
@@ -6874,6 +7966,13 @@ export class AgentManager {
|
|
|
6874
7966
|
}
|
|
6875
7967
|
throw err;
|
|
6876
7968
|
}
|
|
7969
|
+
if (err instanceof EnsureSessionError && err.partial.busyPending) {
|
|
7970
|
+
// 遇忙不算失败:pass 保持 armed、QA 保持绑定、未计轮 intent 原样在库(送达时才消费),
|
|
7971
|
+
// 由对账按 pending 登记补派
|
|
7972
|
+
console.warn(`[AgentManager] dispatchReviewToQa: QA busy for ${taskId}; dispatch queued (${err.message})`);
|
|
7973
|
+
const pending = await this.taskStore.get(taskId);
|
|
7974
|
+
return pending;
|
|
7975
|
+
}
|
|
6877
7976
|
if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
6878
7977
|
await abortDispatch(armedToken);
|
|
6879
7978
|
}
|
|
@@ -6883,6 +7982,7 @@ export class AgentManager {
|
|
|
6883
7982
|
await abortDispatch(armedToken);
|
|
6884
7983
|
throw new ApiError(500, `Failed to start QA review session for ${taskId}`);
|
|
6885
7984
|
}
|
|
7985
|
+
await this.consumeReviewRoundIntent(taskId, armedToken);
|
|
6886
7986
|
await this.clearReviewDispatchPending(taskId, armedToken);
|
|
6887
7987
|
const final = await this.taskStore.get(taskId);
|
|
6888
7988
|
return final;
|
|
@@ -7023,7 +8123,7 @@ export class AgentManager {
|
|
|
7023
8123
|
// 返回 qaTakenOverInReview:仍在 review 但 token 已换代 = 另一 dispatcher 已 release+acquire 同一 QA
|
|
7024
8124
|
// (同 task 同 agent,releaseAgentForTask 的 mismatch 保护不设防),此时调用方不得再 release QA。
|
|
7025
8125
|
// 判定在回滚同一锁段内基于 fresh 完成,是那一刻的权威结论——不受本函数随后恢复 snapshot token 的影响。
|
|
7026
|
-
async rollbackDispatchReviewPhase1(taskId, originalStatus, isTerminalAtClaim, snapshot,
|
|
8126
|
+
async rollbackDispatchReviewPhase1(taskId, originalStatus, isTerminalAtClaim, snapshot, dispatchToken) {
|
|
7027
8127
|
let qaTakenOverInReview = false;
|
|
7028
8128
|
await this.withTaskLock(async () => {
|
|
7029
8129
|
const fresh = await this.taskStore.get(taskId);
|
|
@@ -7033,27 +8133,25 @@ export class AgentManager {
|
|
|
7033
8133
|
if (!isTerminalAtClaim && TERMINAL_STATUSES.includes(fresh.status))
|
|
7034
8134
|
return;
|
|
7035
8135
|
// transition 后的中间态恒为 review 且持有本次 dispatch 的 token;status 漂移(如 REQUEST_CHANGES→fixing)
|
|
7036
|
-
// 或 review 内 token 换代(push recheck 接管)都表示 pass
|
|
8136
|
+
// 或 review 内 token 换代(push recheck 接管)都表示 pass 已归并发推进方所有。
|
|
8137
|
+
// 计轮送达时才落账,claim 段无轮次残留可撤——drift 后不覆盖接管方的任何写入
|
|
7037
8138
|
const passTakenOver = dispatchToken !== undefined && fresh.signalToken !== dispatchToken;
|
|
7038
8139
|
qaTakenOverInReview = !isTerminalAtClaim && fresh.status === 'review' && passTakenOver;
|
|
7039
8140
|
const drifted = !isTerminalAtClaim && (fresh.status !== 'review' || passTakenOver);
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
const
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
:
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
updatedAt: new Date().toISOString(),
|
|
7055
|
-
};
|
|
7056
|
-
if (!isTerminalAtClaim && !drifted) {
|
|
8141
|
+
if (drifted)
|
|
8142
|
+
return;
|
|
8143
|
+
const next = {
|
|
8144
|
+
...fresh,
|
|
8145
|
+
qaAgentId: snapshot.qaAgentId,
|
|
8146
|
+
signalToken: snapshot.signalToken,
|
|
8147
|
+
reviewDispatchedAt: snapshot.reviewDispatchedAt,
|
|
8148
|
+
reviewRoundPending: snapshot.reviewRoundPending,
|
|
8149
|
+
// 本代新铸的 pair 从未交付 QA:不随快照恢复会让旧 QA 的在途 verdict 被拒、新 pair 无人知
|
|
8150
|
+
passToken: snapshot.passToken,
|
|
8151
|
+
failToken: snapshot.failToken,
|
|
8152
|
+
updatedAt: new Date().toISOString(),
|
|
8153
|
+
};
|
|
8154
|
+
if (!isTerminalAtClaim) {
|
|
7057
8155
|
next.status = originalStatus;
|
|
7058
8156
|
next.reviewHeadAnchorSha = snapshot.reviewHeadAnchorSha;
|
|
7059
8157
|
}
|
|
@@ -7451,6 +8549,9 @@ export class AgentManager {
|
|
|
7451
8549
|
const agent = this.getAgentConfig(agentId);
|
|
7452
8550
|
if (!agent)
|
|
7453
8551
|
return;
|
|
8552
|
+
// Pin the incarnation before the first state/paneId read: paneId equality is ABA-blind (a recreated
|
|
8553
|
+
// agent reuses %0), so a DELETE→same-id recreate would otherwise let post-merge /clear hit a new session.
|
|
8554
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
7454
8555
|
const state = await this.agentStore.get(agentId);
|
|
7455
8556
|
if (!state)
|
|
7456
8557
|
return;
|
|
@@ -7465,12 +8566,16 @@ export class AgentManager {
|
|
|
7465
8566
|
return;
|
|
7466
8567
|
}
|
|
7467
8568
|
if (!state.taskId) {
|
|
8569
|
+
if (this.isDeletionInFlight(agentId))
|
|
8570
|
+
return;
|
|
7468
8571
|
const lockToken = await this.lockManager.acquire(agentId, ctx.taskId);
|
|
7469
8572
|
if (!lockToken)
|
|
7470
8573
|
return;
|
|
7471
|
-
await this.agentStore.update(agentId, (existing) => {
|
|
8574
|
+
const commit = await this.agentStore.update(agentId, (existing) => {
|
|
7472
8575
|
if (!existing)
|
|
7473
8576
|
return AGENT_STORE_NOOP;
|
|
8577
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
8578
|
+
return AGENT_STORE_NOOP;
|
|
7474
8579
|
if (existing.taskId && existing.taskId !== ctx.taskId)
|
|
7475
8580
|
return AGENT_STORE_NOOP;
|
|
7476
8581
|
if (existing.creationToken)
|
|
@@ -7485,17 +8590,35 @@ export class AgentManager {
|
|
|
7485
8590
|
lockToken,
|
|
7486
8591
|
updatedAt: new Date().toISOString(),
|
|
7487
8592
|
};
|
|
8593
|
+
}).catch(async (err) => {
|
|
8594
|
+
await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken).catch((releaseErr) => {
|
|
8595
|
+
console.warn(`[AgentManager] postMergeCleanup(${agentId}): lock release after commit failure also failed:`, releaseErr);
|
|
8596
|
+
});
|
|
8597
|
+
throw err;
|
|
7488
8598
|
});
|
|
8599
|
+
if (commit !== 'committed') {
|
|
8600
|
+
await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
|
|
8601
|
+
return;
|
|
8602
|
+
}
|
|
7489
8603
|
const fresh = await this.agentStore.get(agentId);
|
|
7490
8604
|
if (fresh?.taskId !== ctx.taskId || fresh.lockToken !== lockToken) {
|
|
7491
8605
|
await this.lockManager.releaseIfOwner(agentId, ctx.taskId, lockToken);
|
|
7492
8606
|
return;
|
|
7493
8607
|
}
|
|
7494
8608
|
}
|
|
8609
|
+
// A DELETE→recreate (gen bump) before this pane work must skip it, or /clear lands on the new incarnation's reused pane.
|
|
8610
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
8611
|
+
return;
|
|
7495
8612
|
const runner = this.createRunnerFor(agent);
|
|
7496
8613
|
const tmux = new TmuxManager(runner);
|
|
7497
8614
|
const runtime = agentRuntimeKindFor(agent);
|
|
7498
|
-
|
|
8615
|
+
const expectedPaneId = state.paneId;
|
|
8616
|
+
void (async () => {
|
|
8617
|
+
if (!this.deletionGateOpen(agentId, genAtEntry))
|
|
8618
|
+
return;
|
|
8619
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, expectedPaneId);
|
|
8620
|
+
await this.runPostMergeCompaction(tmux, pane, agentId, ctx.taskId, runtime);
|
|
8621
|
+
})().catch(err => console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) failed:`, err));
|
|
7499
8622
|
}
|
|
7500
8623
|
async releasePostMergeAgent(agentId, taskId) {
|
|
7501
8624
|
const state = await this.agentStore.get(agentId);
|
|
@@ -7508,10 +8631,10 @@ export class AgentManager {
|
|
|
7508
8631
|
console.warn(`[AgentManager] releasePostMergeAgent: releaseAgentForTask(${agentId}, ${taskId}) failed:`, err);
|
|
7509
8632
|
}
|
|
7510
8633
|
}
|
|
7511
|
-
async runPostMergeCompaction(tmux,
|
|
8634
|
+
async runPostMergeCompaction(tmux, pane, agentId, originalTaskId, runtime) {
|
|
7512
8635
|
await this.acquireCompactGuard(agentId);
|
|
7513
8636
|
try {
|
|
7514
|
-
await this.runPostMergeCompactionSteps(tmux,
|
|
8637
|
+
await this.runPostMergeCompactionSteps(tmux, pane, agentId, originalTaskId, runtime);
|
|
7515
8638
|
}
|
|
7516
8639
|
finally {
|
|
7517
8640
|
this.compactInFlight.delete(agentId);
|
|
@@ -7537,7 +8660,8 @@ export class AgentManager {
|
|
|
7537
8660
|
this.compactInFlight.add(agentId);
|
|
7538
8661
|
return true;
|
|
7539
8662
|
}
|
|
7540
|
-
async runPostMergeCompactionSteps(tmux,
|
|
8663
|
+
async runPostMergeCompactionSteps(tmux, pane, agentId, originalTaskId, runtime) {
|
|
8664
|
+
const paneId = pane.paneId;
|
|
7541
8665
|
const initial = await this.agentStore.get(agentId);
|
|
7542
8666
|
if (!initial || initial.taskId !== originalTaskId || initial.paneId !== paneId)
|
|
7543
8667
|
return;
|
|
@@ -7557,13 +8681,13 @@ export class AgentManager {
|
|
|
7557
8681
|
let cleared = false;
|
|
7558
8682
|
let clearError;
|
|
7559
8683
|
try {
|
|
7560
|
-
cleared = await this.sendPostMergeSlashCommand(tmux,
|
|
8684
|
+
cleared = await this.sendPostMergeSlashCommand(tmux, pane, agentId, runtime, bindingStillOurs);
|
|
7561
8685
|
}
|
|
7562
8686
|
catch (err) {
|
|
7563
8687
|
clearError = err;
|
|
7564
8688
|
}
|
|
7565
8689
|
if (!cleared) {
|
|
7566
|
-
if (await this.recoverPostMergeExitedRuntime(tmux,
|
|
8690
|
+
if (await this.recoverPostMergeExitedRuntime(tmux, pane, agentId, originalTaskId, lockToken, runtime)) {
|
|
7567
8691
|
cleared = true;
|
|
7568
8692
|
}
|
|
7569
8693
|
else if (!await bindingStillOurs()) {
|
|
@@ -7572,7 +8696,7 @@ export class AgentManager {
|
|
|
7572
8696
|
else if (clearError instanceof Error
|
|
7573
8697
|
&& clearError.message.includes('runtime rejected /clear')) {
|
|
7574
8698
|
try {
|
|
7575
|
-
await this.waitForReplPromptReady(tmux,
|
|
8699
|
+
await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
|
|
7576
8700
|
}
|
|
7577
8701
|
catch (err) {
|
|
7578
8702
|
clearError = err;
|
|
@@ -7590,10 +8714,11 @@ export class AgentManager {
|
|
|
7590
8714
|
}
|
|
7591
8715
|
await this.releasePostMergeAgent(agentId, originalTaskId);
|
|
7592
8716
|
}
|
|
7593
|
-
async recoverPostMergeExitedRuntime(tmux,
|
|
8717
|
+
async recoverPostMergeExitedRuntime(tmux, pane, agentId, taskId, lockToken, runtime) {
|
|
8718
|
+
const paneId = pane.paneId;
|
|
7594
8719
|
let paneExists = true;
|
|
7595
8720
|
try {
|
|
7596
|
-
if (hasReplProcTitle(await tmux.displayMessage(
|
|
8721
|
+
if (hasReplProcTitle(await tmux.displayMessage(pane, '#{pane_current_command}'), runtime))
|
|
7597
8722
|
return false;
|
|
7598
8723
|
}
|
|
7599
8724
|
catch (err) {
|
|
@@ -7612,7 +8737,7 @@ export class AgentManager {
|
|
|
7612
8737
|
return false;
|
|
7613
8738
|
try {
|
|
7614
8739
|
if (paneExists)
|
|
7615
|
-
await tmux.sendKeysToPane(
|
|
8740
|
+
await tmux.sendKeysToPane(pane, 'C-c');
|
|
7616
8741
|
const ensure = await this.ensureSession(agentId, 'runtime');
|
|
7617
8742
|
if (!ensure.freshRuntime)
|
|
7618
8743
|
return false;
|
|
@@ -7641,22 +8766,22 @@ export class AgentManager {
|
|
|
7641
8766
|
return false;
|
|
7642
8767
|
}
|
|
7643
8768
|
}
|
|
7644
|
-
async sendPostMergeSlashCommand(tmux,
|
|
8769
|
+
async sendPostMergeSlashCommand(tmux, pane, agentId, runtime, bindingStillOurs) {
|
|
7645
8770
|
let rejection;
|
|
7646
8771
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
7647
8772
|
if (!await bindingStillOurs())
|
|
7648
8773
|
return false;
|
|
7649
|
-
await tmux.clearComposerDraft(
|
|
7650
|
-
await this.waitForReplPromptReady(tmux,
|
|
8774
|
+
await tmux.clearComposerDraft(pane);
|
|
8775
|
+
await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
|
|
7651
8776
|
if (!await bindingStillOurs())
|
|
7652
8777
|
return false;
|
|
7653
|
-
await tmux.sendKeysLiteral(
|
|
7654
|
-
await tmux.sendEnter(
|
|
8778
|
+
await tmux.sendKeysLiteral(pane, '/clear');
|
|
8779
|
+
await tmux.sendEnter(pane);
|
|
7655
8780
|
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
7656
|
-
await this.waitForReplPromptReady(tmux,
|
|
8781
|
+
await this.waitForReplPromptReady(tmux, pane, runtime, this.compactIdleWaitMs);
|
|
7657
8782
|
if (!await bindingStillOurs())
|
|
7658
8783
|
return false;
|
|
7659
|
-
if (!await this.hasRuntimeSlashCommandRejection(tmux,
|
|
8784
|
+
if (!await this.hasRuntimeSlashCommandRejection(tmux, pane, '/clear'))
|
|
7660
8785
|
return true;
|
|
7661
8786
|
rejection = new Error('runtime rejected /clear because a task is still in progress');
|
|
7662
8787
|
if (attempt < 2) {
|
|
@@ -7666,29 +8791,33 @@ export class AgentManager {
|
|
|
7666
8791
|
}
|
|
7667
8792
|
throw rejection ?? new Error('runtime rejected /clear');
|
|
7668
8793
|
}
|
|
7669
|
-
async hasRuntimeSlashCommandRejection(tmux,
|
|
7670
|
-
const cap = await tmux.capturePaneById(
|
|
8794
|
+
async hasRuntimeSlashCommandRejection(tmux, pane, command) {
|
|
8795
|
+
const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
7671
8796
|
return this.runtimeSlashCommandRejectedPattern(command).test(cap);
|
|
7672
8797
|
}
|
|
7673
8798
|
runtimeSlashCommandRejectedPattern(command) {
|
|
7674
8799
|
return new RegExp(`["'“”‘’]?${command}["'“”‘’]?\\s+is disabled while a task is in progress\\.`, 'gi');
|
|
7675
8800
|
}
|
|
7676
|
-
async waitForReplPromptReady(tmux,
|
|
8801
|
+
async waitForReplPromptReady(tmux, pane, runtime, timeoutMs, opts = {}) {
|
|
8802
|
+
if (opts.stableIdle) {
|
|
8803
|
+
return this.waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs);
|
|
8804
|
+
}
|
|
8805
|
+
const paneId = pane.paneId;
|
|
7677
8806
|
const deadline = Date.now() + timeoutMs;
|
|
7678
|
-
await tmux.waitReplReady(
|
|
8807
|
+
await tmux.waitReplReady(pane, runtime, {
|
|
7679
8808
|
timeoutMs,
|
|
7680
8809
|
intervalMs: this.compactIdlePollMs,
|
|
7681
8810
|
titleIdleFastPath: true,
|
|
7682
8811
|
});
|
|
7683
8812
|
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
7684
8813
|
while (true) {
|
|
7685
|
-
const current = await tmux.displayMessage(
|
|
8814
|
+
const current = await tmux.displayMessage(pane, '#{pane_current_command}');
|
|
7686
8815
|
if (!hasReplProcTitle(current, runtime)) {
|
|
7687
8816
|
throw new Error(`waitForReplPromptReady: pane ${paneId} pane_current_command=${current.trim()} (not runtime, REPL may have exited)`);
|
|
7688
8817
|
}
|
|
7689
|
-
const cap = await tmux.capturePaneById(
|
|
8818
|
+
const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
7690
8819
|
const ready = hasRuntimeReadyView(cap, runtime)
|
|
7691
|
-
|| (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(
|
|
8820
|
+
|| (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(pane), runtime));
|
|
7692
8821
|
if (detectRuntimeMenu(cap) || (!ready && detectStartupDialog(cap, runtime))) {
|
|
7693
8822
|
throw new Error(`waitForReplPromptReady: pane ${paneId} shows menu/dialog, not a ready REPL prompt`);
|
|
7694
8823
|
}
|
|
@@ -7699,11 +8828,41 @@ export class AgentManager {
|
|
|
7699
8828
|
return;
|
|
7700
8829
|
}
|
|
7701
8830
|
if (Date.now() >= deadline) {
|
|
7702
|
-
|
|
8831
|
+
// 与 stableIdle 分支同一类型:忙碌超时是「常态忙」而非故障,调用方(release/派发)
|
|
8832
|
+
// 据此延后重排;菜单/对话框/REPL 退出等仍是普通 Error,不得被当成可延后
|
|
8833
|
+
throw new ReplNotReadyError(paneId, runtime, cap, `stayed busy past ${timeoutMs}ms`);
|
|
7703
8834
|
}
|
|
7704
8835
|
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
7705
8836
|
}
|
|
7706
8837
|
}
|
|
8838
|
+
// 注入前置门:codex 忙碌回合的工具间隙会闪现单帧 idle footer(#558 事故放大器),
|
|
8839
|
+
// 连续 K 帧稳定就绪才放行,任何忙碌/不确定帧清零计数;超时按忙碌分类抛 ReplNotReadyError。
|
|
8840
|
+
async waitForReplPromptStableIdle(tmux, pane, runtime, timeoutMs) {
|
|
8841
|
+
const paneId = pane.paneId;
|
|
8842
|
+
const samples = Math.max(1, this.readyStableSamples);
|
|
8843
|
+
const spacing = this.readyStableSpacingMs;
|
|
8844
|
+
const deadline = Date.now() + timeoutMs + (samples - 1) * spacing;
|
|
8845
|
+
let streak = 0;
|
|
8846
|
+
while (true) {
|
|
8847
|
+
const current = await tmux.displayMessage(pane, '#{pane_current_command}');
|
|
8848
|
+
if (!hasReplProcTitle(current, runtime)) {
|
|
8849
|
+
throw new Error(`waitForReplPromptReady: pane ${paneId} pane_current_command=${current.trim()} (not runtime, REPL may have exited)`);
|
|
8850
|
+
}
|
|
8851
|
+
const cap = await tmux.capturePaneById(pane, { ansi: false, scrollback: 0 });
|
|
8852
|
+
const ready = hasRuntimeReadyView(cap, runtime)
|
|
8853
|
+
|| (screenAllowsTitleIdle(cap, runtime) && hasOscTitleIdle(await tmux.readPaneTitle(pane), runtime));
|
|
8854
|
+
if (detectRuntimeMenu(cap) || (!ready && detectStartupDialog(cap, runtime))) {
|
|
8855
|
+
throw new Error(`waitForReplPromptReady: pane ${paneId} shows menu/dialog, not a ready REPL prompt`);
|
|
8856
|
+
}
|
|
8857
|
+
streak = ready && !runtimeBusyCheck(cap, runtime) ? streak + 1 : 0;
|
|
8858
|
+
if (streak >= samples)
|
|
8859
|
+
return;
|
|
8860
|
+
if (Date.now() >= deadline) {
|
|
8861
|
+
throw new ReplNotReadyError(paneId, runtime, cap, `stable idle not confirmed within ${timeoutMs}ms (+${(samples - 1) * spacing}ms stability window)`);
|
|
8862
|
+
}
|
|
8863
|
+
await new Promise(r => setTimeout(r, spacing));
|
|
8864
|
+
}
|
|
8865
|
+
}
|
|
7707
8866
|
stopPhaseSignalWatcher(taskId) {
|
|
7708
8867
|
this.phaseSignalWatcher?.stop(taskId);
|
|
7709
8868
|
}
|
|
@@ -7733,6 +8892,8 @@ export class AgentManager {
|
|
|
7733
8892
|
...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
|
|
7734
8893
|
...(opts.replaceScope ? { replaceScope: opts.replaceScope } : {}),
|
|
7735
8894
|
...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
|
|
8895
|
+
...(opts.needInputMode ? { needInputMode: opts.needInputMode } : {}),
|
|
8896
|
+
...(opts.needInputPaneCutover ? { needInputPaneCutover: true } : {}),
|
|
7736
8897
|
});
|
|
7737
8898
|
}
|
|
7738
8899
|
catch (err) {
|
|
@@ -7743,6 +8904,10 @@ export class AgentManager {
|
|
|
7743
8904
|
async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
|
|
7744
8905
|
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
|
|
7745
8906
|
skipSnapshot,
|
|
8907
|
+
// Every post-dispatch arm follows a freshly injected prompt: the previous
|
|
8908
|
+
// question is superseded, its watermark must not carry over (fix/publish
|
|
8909
|
+
// continuations included).
|
|
8910
|
+
needInputMode: 'fresh',
|
|
7746
8911
|
...(onReadFile ? { onReadFile } : {}),
|
|
7747
8912
|
});
|
|
7748
8913
|
if (!armed)
|
|
@@ -7783,7 +8948,10 @@ export class AgentManager {
|
|
|
7783
8948
|
// pair 的重铸已随「进入 review 的 transition」单写落盘(spec §7 与新轮可见性原子);
|
|
7784
8949
|
// 这里只轮换 signal token 并布防
|
|
7785
8950
|
await this.updateTask(taskId, { signalToken: newToken });
|
|
7786
|
-
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken,
|
|
8951
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, newToken, {
|
|
8952
|
+
needInputMode: 'fresh',
|
|
8953
|
+
...(isGit ? { replaceScope: 'agent' } : {}),
|
|
8954
|
+
});
|
|
7787
8955
|
if (armed && isGit && kinds.includes('pr-approved'))
|
|
7788
8956
|
await this.rearmGitReconciliationWatcher(taskId);
|
|
7789
8957
|
return { token: newToken, armed };
|
|
@@ -7805,6 +8973,7 @@ export class AgentManager {
|
|
|
7805
8973
|
signalToken: restore.signalToken,
|
|
7806
8974
|
reviewHeadAnchorSha: restore.reviewHeadAnchorSha,
|
|
7807
8975
|
reviewDispatchedAt: restore.reviewDispatchedAt,
|
|
8976
|
+
reviewRoundPending: restore.reviewRoundPending,
|
|
7808
8977
|
...(restore.restorePair === true
|
|
7809
8978
|
? { passToken: restore.passToken, failToken: restore.failToken }
|
|
7810
8979
|
: {}),
|
|
@@ -8192,7 +9361,7 @@ export class AgentManager {
|
|
|
8192
9361
|
signalToken,
|
|
8193
9362
|
specDocuments,
|
|
8194
9363
|
...(currentSpecRound !== undefined ? { currentSpecRound } : {}),
|
|
8195
|
-
armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken),
|
|
9364
|
+
armBeforeInject: () => this.setupPhaseSignalWatcher(task.id, devAgentId, expectedKind, signalToken, { needInputMode: 'fresh' }),
|
|
8196
9365
|
};
|
|
8197
9366
|
return this.isResearchHandoff(task, devAgentId)
|
|
8198
9367
|
? this.startSession(task.id, devAgentId, 'code', {
|
|
@@ -8456,6 +9625,7 @@ export class AgentManager {
|
|
|
8456
9625
|
dispatchedCheckoutMode = ctx.serverReviewCheckout;
|
|
8457
9626
|
const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
|
|
8458
9627
|
return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, {
|
|
9628
|
+
needInputMode: 'fresh',
|
|
8459
9629
|
...(allowReadFile
|
|
8460
9630
|
? { onReadFile: (req) => { void this.handleReadFileRequest(taskId, qaId, req); } }
|
|
8461
9631
|
: {}),
|
|
@@ -8677,7 +9847,7 @@ export class AgentManager {
|
|
|
8677
9847
|
// Callers without a baseSha (publish) run long after the review diff was
|
|
8678
9848
|
// read; refresh origin/HEAD first or upstream commits merged since then
|
|
8679
9849
|
// would linger in base..HEAD and flag tasks that leak nothing.
|
|
8680
|
-
const fetch = await execNetwork(runner, `${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
|
|
9850
|
+
const fetch = await execNetwork(runner, `${canonicalSelfGuard(workdir)} && ${GIT_NET_ENV} git -C ${shellQuote(workdir)} fetch origin --quiet`);
|
|
8681
9851
|
if (fetch.exitCode !== 0) {
|
|
8682
9852
|
throw new Error(`lineage fetch failed in ${workdir}: ${fetch.stderr.trim()}`);
|
|
8683
9853
|
}
|
|
@@ -8808,6 +9978,8 @@ export class AgentManager {
|
|
|
8808
9978
|
const cfg = this.getAgentConfig(agentId);
|
|
8809
9979
|
if (!cfg)
|
|
8810
9980
|
throw new Error(`injectTextToAgent: unknown agent ${agentId}`);
|
|
9981
|
+
// Pin the incarnation before reading state so a DELETE→recreate reusing %0 can't route the text to the new session.
|
|
9982
|
+
const genAtEntry = this.deletionGenerationOf(agentId);
|
|
8811
9983
|
await this.acquireCompactGuard(agentId);
|
|
8812
9984
|
try {
|
|
8813
9985
|
const state = await this.agentStore.get(agentId);
|
|
@@ -8842,8 +10014,18 @@ export class AgentManager {
|
|
|
8842
10014
|
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
8843
10015
|
}
|
|
8844
10016
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
8845
|
-
await
|
|
8846
|
-
|
|
10017
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
|
|
10018
|
+
// resolveClaimedPane resolved by the CURRENT same-name session; re-verify incarnation + binding before paste (same as the image path).
|
|
10019
|
+
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
10020
|
+
throw new Error(`injectTextToAgent: agent ${agentId} was deleted or recreated before paste`);
|
|
10021
|
+
}
|
|
10022
|
+
const afterResolve = await this.agentStore.get(agentId);
|
|
10023
|
+
if (!afterResolve || afterResolve.paneId !== paneId || afterResolve.taskId !== taskId
|
|
10024
|
+
|| (taskId !== undefined && afterResolve.lockToken !== lockToken)) {
|
|
10025
|
+
throw new Error(`injectTextToAgent: agent ${agentId} binding changed before paste`);
|
|
10026
|
+
}
|
|
10027
|
+
await tmux.injectPrompt(pane, text, agentId);
|
|
10028
|
+
await tmux.sendEnter(pane);
|
|
8847
10029
|
}
|
|
8848
10030
|
finally {
|
|
8849
10031
|
this.compactInFlight.delete(agentId);
|
|
@@ -9000,14 +10182,17 @@ export class AgentManager {
|
|
|
9000
10182
|
}
|
|
9001
10183
|
startCompactionThenRelease(agentId, state, taskId) {
|
|
9002
10184
|
// 人工介入/重建中的 pane 不发 /clear;confirm 语义要求解绑,落回直接释放
|
|
9003
|
-
|
|
10185
|
+
const expectedPaneId = state.paneId;
|
|
10186
|
+
if (!expectedPaneId || state.status === 'awaiting_human' || state.creationToken)
|
|
9004
10187
|
return false;
|
|
9005
10188
|
const cfg = this.getAgentConfig(agentId);
|
|
9006
10189
|
if (!cfg)
|
|
9007
10190
|
return false;
|
|
9008
10191
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
9009
|
-
void
|
|
9010
|
-
|
|
10192
|
+
void (async () => {
|
|
10193
|
+
const pane = await this.resolveClaimedPane(tmux, agentId, expectedPaneId);
|
|
10194
|
+
await this.runPostMergeCompaction(tmux, pane, agentId, taskId, agentRuntimeKindFor(cfg));
|
|
10195
|
+
})().catch(err => console.warn(`[AgentManager] releaseTaskAgents compaction(${agentId}) failed:`, err));
|
|
9011
10196
|
return true;
|
|
9012
10197
|
}
|
|
9013
10198
|
repoMergeQueue = new Map();
|
|
@@ -9023,13 +10208,15 @@ export class AgentManager {
|
|
|
9023
10208
|
const runner = this.createRunnerFor(dev);
|
|
9024
10209
|
const prev = this.repoMergeQueue.get(task.projectId) ?? Promise.resolve();
|
|
9025
10210
|
const run = prev.then(async () => {
|
|
10211
|
+
// 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.
|
|
10212
|
+
const guard = `${canonicalSelfGuard(workdir)} && `;
|
|
9026
10213
|
const cd = `cd ${shellQuote(workdir)} && `;
|
|
9027
10214
|
const db = await runner.exec(`${cd}git symbolic-ref --short refs/remotes/origin/HEAD`);
|
|
9028
10215
|
const defaultBranch = db.stdout.trim().replace(/^origin\//, '');
|
|
9029
10216
|
if (db.exitCode !== 0 || defaultBranch === '') {
|
|
9030
10217
|
throw new Error(`ffMergeBranch: cannot resolve default branch: ${db.stderr.trim() || 'empty origin/HEAD'}`);
|
|
9031
10218
|
}
|
|
9032
|
-
const fetch = await execNetwork(runner, `${cd}${GIT_NET_ENV} git fetch origin`);
|
|
10219
|
+
const fetch = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git fetch origin`);
|
|
9033
10220
|
if (fetch.exitCode !== 0) {
|
|
9034
10221
|
throw new Error(`ffMergeBranch [git fetch] failed: ${fetch.stderr.trim()}`);
|
|
9035
10222
|
}
|
|
@@ -9043,7 +10230,7 @@ export class AgentManager {
|
|
|
9043
10230
|
else {
|
|
9044
10231
|
throw new Error(`ffMergeBranch: no reviewed head recorded for task ${task.id}; cannot safely merge`);
|
|
9045
10232
|
}
|
|
9046
|
-
const push = await execNetwork(runner, `${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
|
|
10233
|
+
const push = await execNetwork(runner, `${guard}${cd}${GIT_NET_ENV} git push origin ${shellQuote(`origin/${branch}`)}:${shellQuote(defaultBranch)}`);
|
|
9047
10234
|
if (push.exitCode !== 0) {
|
|
9048
10235
|
throw new Error(`ffMergeBranch [push] failed: ${push.stderr.trim() || push.stdout.trim()}`);
|
|
9049
10236
|
}
|