baxian 1.2.56 → 1.2.58
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 +6 -1
- package/dist/agent/branch.d.ts.map +1 -1
- package/dist/agent/branch.js +45 -9
- package/dist/agent/branch.js.map +1 -1
- package/dist/agent/manager.d.ts +84 -7
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +1484 -207
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/net-exec.d.ts +1 -0
- package/dist/agent/net-exec.d.ts.map +1 -1
- package/dist/agent/net-exec.js +6 -0
- package/dist/agent/net-exec.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +2 -0
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +28 -1
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/repo-store.d.ts +15 -1
- package/dist/agent/repo-store.d.ts.map +1 -1
- package/dist/agent/repo-store.js +206 -22
- 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 +15 -18
- package/dist/agent/review-transport.js.map +1 -1
- package/dist/agent/tmux.d.ts +29 -3
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +260 -16
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +20 -14
- package/dist/api/projects.js.map +1 -1
- package/dist/config/normalizer.d.ts.map +1 -1
- package/dist/config/normalizer.js +1 -0
- package/dist/config/normalizer.js.map +1 -1
- package/dist/config/validator.d.ts +3 -1
- package/dist/config/validator.d.ts.map +1 -1
- package/dist/config/validator.js +104 -7
- package/dist/config/validator.js.map +1 -1
- package/dist/event/handlers.d.ts.map +1 -1
- package/dist/event/handlers.js +157 -47
- package/dist/event/handlers.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/lifecycle/restart-sentinel.d.ts.map +1 -1
- package/dist/lifecycle/restart-sentinel.js +10 -3
- package/dist/lifecycle/restart-sentinel.js.map +1 -1
- package/dist/platform/body-digest.d.ts +4 -0
- package/dist/platform/body-digest.d.ts.map +1 -0
- package/dist/platform/body-digest.js +13 -0
- package/dist/platform/body-digest.js.map +1 -0
- package/dist/platform/command-renderer.d.ts +22 -0
- package/dist/platform/command-renderer.d.ts.map +1 -0
- package/dist/platform/command-renderer.js +82 -0
- package/dist/platform/command-renderer.js.map +1 -0
- package/dist/platform/comment-cursor.d.ts +36 -0
- package/dist/platform/comment-cursor.d.ts.map +1 -0
- package/dist/platform/comment-cursor.js +235 -0
- package/dist/platform/comment-cursor.js.map +1 -0
- package/dist/platform/driver-spec.d.ts +7 -0
- package/dist/platform/driver-spec.d.ts.map +1 -0
- package/dist/platform/driver-spec.js +475 -0
- package/dist/platform/driver-spec.js.map +1 -0
- package/dist/platform/field-mapper.d.ts +7 -0
- package/dist/platform/field-mapper.d.ts.map +1 -0
- package/dist/platform/field-mapper.js +117 -0
- package/dist/platform/field-mapper.js.map +1 -0
- package/dist/platform/git-driver.d.ts +55 -0
- package/dist/platform/git-driver.d.ts.map +1 -0
- package/dist/platform/git-driver.js +171 -0
- package/dist/platform/git-driver.js.map +1 -0
- package/dist/platform/manifest.d.ts +12 -0
- package/dist/platform/manifest.d.ts.map +1 -0
- package/dist/platform/manifest.js +57 -0
- package/dist/platform/manifest.js.map +1 -0
- package/dist/platform/markers.d.ts +46 -0
- package/dist/platform/markers.d.ts.map +1 -0
- package/dist/platform/markers.js +121 -0
- package/dist/platform/markers.js.map +1 -0
- package/dist/platform/platform-poller.d.ts +67 -0
- package/dist/platform/platform-poller.d.ts.map +1 -0
- package/dist/platform/platform-poller.js +632 -0
- package/dist/platform/platform-poller.js.map +1 -0
- package/dist/platform/plugin-registry.d.ts +31 -0
- package/dist/platform/plugin-registry.d.ts.map +1 -0
- package/dist/platform/plugin-registry.js +238 -0
- package/dist/platform/plugin-registry.js.map +1 -0
- package/dist/platform/plugin-roots.d.ts +3 -0
- package/dist/platform/plugin-roots.d.ts.map +1 -0
- package/dist/platform/plugin-roots.js +11 -0
- package/dist/platform/plugin-roots.js.map +1 -0
- package/dist/platform/plugins/github/baxian-plugin.json +8 -0
- package/dist/platform/plugins/github/driver.json +134 -0
- package/dist/platform/plugins/github/skills/baxian-cli-gh/SKILL.md +99 -0
- package/dist/platform/response-parser.d.ts +6 -0
- package/dist/platform/response-parser.d.ts.map +1 -0
- package/dist/platform/response-parser.js +92 -0
- package/dist/platform/response-parser.js.map +1 -0
- package/dist/platform/row-schema.d.ts +12 -0
- package/dist/platform/row-schema.d.ts.map +1 -0
- package/dist/platform/row-schema.js +127 -0
- package/dist/platform/row-schema.js.map +1 -0
- package/dist/platform/startup.d.ts +16 -0
- package/dist/platform/startup.d.ts.map +1 -0
- package/dist/platform/startup.js +52 -0
- package/dist/platform/startup.js.map +1 -0
- package/dist/platform/types.d.ts +56 -0
- package/dist/platform/types.d.ts.map +1 -0
- package/dist/platform/types.js +32 -0
- package/dist/platform/types.js.map +1 -0
- package/dist/platform/verdict-engine.d.ts +54 -0
- package/dist/platform/verdict-engine.d.ts.map +1 -0
- package/dist/platform/verdict-engine.js +125 -0
- package/dist/platform/verdict-engine.js.map +1 -0
- package/dist/shared/constants.d.ts +2 -0
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +3 -0
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/git-url.d.ts +8 -0
- package/dist/shared/git-url.d.ts.map +1 -1
- package/dist/shared/git-url.js +38 -0
- package/dist/shared/git-url.js.map +1 -1
- package/dist/shared/types.d.ts +17 -1
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/state/agent-store.js +2 -0
- package/dist/state/agent-store.js.map +1 -1
- package/dist/state/error-record-store.d.ts.map +1 -1
- package/dist/state/error-record-store.js +12 -3
- package/dist/state/error-record-store.js.map +1 -1
- package/dist/state/process-lock.d.ts.map +1 -1
- package/dist/state/process-lock.js +11 -2
- package/dist/state/process-lock.js.map +1 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +20 -2
- package/dist/state/task-store.js.map +1 -1
- package/dist/web/assets/index-DskVWe7e.js +13 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-3Ypk8Gfo.js +0 -13
package/dist/agent/manager.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { join, normalize } from 'node:path';
|
|
3
4
|
import { tmpdir } from 'node:os';
|
|
4
5
|
import { createSignalToken } from './phase-signal.js';
|
|
@@ -13,7 +14,7 @@ import { findForeignTaskTip } from './lineage.js';
|
|
|
13
14
|
import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
|
|
14
15
|
import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, hasOscTitleWorking, hasOscTitleIdle, screenAllowsTitleIdle, } from './tmux.js';
|
|
15
16
|
import { BranchManager, DirtyWorkdirError, ReviewHeadMismatchError } from './branch.js';
|
|
16
|
-
import { RepoStore, createRepoStoreCache } from './repo-store.js';
|
|
17
|
+
import { RepoStore, createRepoStoreCache, moveFileIntoPlace, stageFileGuarded } from './repo-store.js';
|
|
17
18
|
import { PhaseSignalWatcher } from './phase-signal-watcher.js';
|
|
18
19
|
import { ReviewTransport, resolveServerPayloads } from './review-transport.js';
|
|
19
20
|
import { buildPromptInline, buildGreetingPrompt, PromptSizeError, RequiredSkillsMissingError, MAX_PROMPT_BYTES_ROUTE_LIMIT, } from './prompt.js';
|
|
@@ -85,6 +86,10 @@ export function buildLaunchCommand(agent) {
|
|
|
85
86
|
}
|
|
86
87
|
return segments.join(' ');
|
|
87
88
|
}
|
|
89
|
+
// cd re-resolves by pathname: a reused pane shell may sit on a deleted-and-recreated cwd inode.
|
|
90
|
+
export function launchCommandIn(dir, agent) {
|
|
91
|
+
return `cd ${shellQuote(dir)} && ${buildLaunchCommand(agent)}`;
|
|
92
|
+
}
|
|
88
93
|
function agentRuntimeKindFor(agent) {
|
|
89
94
|
return agent.runtime;
|
|
90
95
|
}
|
|
@@ -142,6 +147,16 @@ const RUNTIME_LIVENESS_SAMPLES = 3;
|
|
|
142
147
|
export function canDispatchWithBinding(binding) {
|
|
143
148
|
return !binding?.taskId && !binding?.creationToken && binding?.status !== 'awaiting_human';
|
|
144
149
|
}
|
|
150
|
+
// 派发期 hold:prompt 从未注入、pane 无在途回合,重派安全;ack_unknown 一类不在此列
|
|
151
|
+
export const RECOVERABLE_QA_DISPATCH_HOLD_PHASES = new Set([
|
|
152
|
+
'checkout-preparation-failed',
|
|
153
|
+
'dirty-workdir',
|
|
154
|
+
]);
|
|
155
|
+
export function isRecoverableQaDispatchHold(binding) {
|
|
156
|
+
return binding?.status === 'awaiting_human'
|
|
157
|
+
&& binding.awaitingPhase != null
|
|
158
|
+
&& RECOVERABLE_QA_DISPATCH_HOLD_PHASES.has(binding.awaitingPhase);
|
|
159
|
+
}
|
|
145
160
|
const TURN_COMPLETED_AWAITING_PHASES = new Set();
|
|
146
161
|
const CANCEL_CLEANUP_HOLD_PHASES = new Set(['cancel-clearing', 'cancel-interrupt-failed']);
|
|
147
162
|
function isCancelCleanupHold(binding) {
|
|
@@ -158,12 +173,24 @@ function cancelPhaseDowngrades(prev, next) {
|
|
|
158
173
|
return cancelPhaseRank(next) < cancelPhaseRank(prev);
|
|
159
174
|
}
|
|
160
175
|
const REGREET_REQUIRED_HOLD_PHASES = new Set(['greeting_failed']);
|
|
176
|
+
const CHECKOUT_HOLD_PHASES = new Set([
|
|
177
|
+
'dirty-workdir',
|
|
178
|
+
'checkout-preparation-failed',
|
|
179
|
+
'branch-cleanup-pending',
|
|
180
|
+
]);
|
|
161
181
|
function shouldReleaseHeldBinding(state, boundTask) {
|
|
162
182
|
if (state.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(state.awaitingPhase))
|
|
163
183
|
return false;
|
|
164
184
|
const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
|
|
165
185
|
const turnCompleted = state.awaitingPhase != null && TURN_COMPLETED_AWAITING_PHASES.has(state.awaitingPhase);
|
|
166
|
-
|
|
186
|
+
// 任务已退回 verdict 门禁且当前持有者不是本 agent:这次绑定是转码失败的残留
|
|
187
|
+
// (fresh-acquire 释放被脏树挡下),Resume 时补完释放,否则 dev 被永久占住
|
|
188
|
+
const staleCodeHandoff = state.awaitingPhase != null
|
|
189
|
+
&& CHECKOUT_HOLD_PHASES.has(state.awaitingPhase)
|
|
190
|
+
&& !!boundTask
|
|
191
|
+
&& (boundTask.status === 'spec-ready' || boundTask.status === 'max_rounds')
|
|
192
|
+
&& boundTask.agentId !== state.id;
|
|
193
|
+
return !boundTask || taskIsTerminal || turnCompleted || staleCodeHandoff;
|
|
167
194
|
}
|
|
168
195
|
export class AgentManager {
|
|
169
196
|
config;
|
|
@@ -449,8 +476,11 @@ export class AgentManager {
|
|
|
449
476
|
`if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
|
|
450
477
|
}
|
|
451
478
|
if (mode === 'create' && alive) {
|
|
452
|
-
|
|
453
|
-
|
|
479
|
+
const reclaimed = await this.runUnderSessionLifecycle(agentId, () => this.reclaimHalfCreatedSession(tmux, agentId));
|
|
480
|
+
if (!reclaimed) {
|
|
481
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
|
|
482
|
+
`it creates itself — kill it manually or pick a different agent id`);
|
|
483
|
+
}
|
|
454
484
|
}
|
|
455
485
|
if (alive && (mode === 'runtime' || mode === 'recover')) {
|
|
456
486
|
return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
|
|
@@ -470,8 +500,77 @@ export class AgentManager {
|
|
|
470
500
|
// before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
|
|
471
501
|
await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
|
|
472
502
|
await this.ensureSkillDirSafe(runner, workdir, subdir);
|
|
473
|
-
await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, path, content), destRoot);
|
|
503
|
+
await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot);
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
// A rollback only kills if no adoption bumped the generation since its create.
|
|
507
|
+
sessionLifecycleChain = new Map();
|
|
508
|
+
adoptGeneration = new Map();
|
|
509
|
+
runUnderSessionLifecycle(agentId, fn) {
|
|
510
|
+
const prev = this.sessionLifecycleChain.get(agentId) ?? Promise.resolve();
|
|
511
|
+
const cur = prev.then(fn, fn);
|
|
512
|
+
const settled = cur.then(() => undefined, () => undefined);
|
|
513
|
+
this.sessionLifecycleChain.set(agentId, settled);
|
|
514
|
+
void settled.finally(() => {
|
|
515
|
+
if (this.sessionLifecycleChain.get(agentId) === settled)
|
|
516
|
+
this.sessionLifecycleChain.delete(agentId);
|
|
474
517
|
});
|
|
518
|
+
return cur;
|
|
519
|
+
}
|
|
520
|
+
// Inside the lifecycle lock, a generation bump since create means handoff — stand down.
|
|
521
|
+
async rollbackCreatedSession(agentId, partial, reason, opts = {}) {
|
|
522
|
+
await this.runUnderSessionLifecycle(agentId, async () => {
|
|
523
|
+
try {
|
|
524
|
+
const ref = partial.sessionRef;
|
|
525
|
+
if (!ref) {
|
|
526
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: no session ref recorded`);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if ((this.adoptGeneration.get(agentId) ?? 0) !== (partial.genAtCreate ?? 0)) {
|
|
530
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: session adopted since create — leaving it to its successor`);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (opts.expectCreationToken !== undefined) {
|
|
534
|
+
let fresh;
|
|
535
|
+
try {
|
|
536
|
+
fresh = await this.agentStore.get(agentId);
|
|
537
|
+
}
|
|
538
|
+
catch (storeErr) {
|
|
539
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent store read failed — session left in place:`, storeErr);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (!fresh) {
|
|
543
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent record gone`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (fresh.creationToken !== opts.expectCreationToken) {
|
|
547
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: creationToken rotated — leaving the session to its successor`);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const cfg = this.getAgentConfig(agentId);
|
|
552
|
+
if (!cfg) {
|
|
553
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent no longer in config`);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const runner = this.createRunnerFor(cfg);
|
|
557
|
+
const outcome = await new TmuxManager(runner).killSessionRef(ref);
|
|
558
|
+
if (outcome === 'stale-server') {
|
|
559
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux server generation changed — leaving session ${ref.sessionId} untouched`);
|
|
560
|
+
}
|
|
561
|
+
else if (outcome === 'killed') {
|
|
562
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}): killed created session ${ref.sessionId}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (cleanupErr) {
|
|
566
|
+
console.warn(`[AgentManager] created-session rollback (${reason}) failed for ${agentId}:`, cleanupErr);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
async rollbackEnsureSessionFailure(agentId, err, reason) {
|
|
571
|
+
if (!(err instanceof EnsureSessionError) || !err.partial.createdSession)
|
|
572
|
+
return;
|
|
573
|
+
await this.rollbackCreatedSession(agentId, err.partial, reason);
|
|
475
574
|
}
|
|
476
575
|
skillDirChain = new Map();
|
|
477
576
|
skillDirLockKey(agent, workdir) {
|
|
@@ -491,19 +590,21 @@ export class AgentManager {
|
|
|
491
590
|
});
|
|
492
591
|
return run;
|
|
493
592
|
}
|
|
494
|
-
async atomicWriteFile(runner, finalPath, content) {
|
|
495
|
-
const tmp = `${finalPath}.baxian-tmp`;
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
593
|
+
async atomicWriteFile(runner, workdir, finalPath, content) {
|
|
594
|
+
const tmp = `${finalPath}.baxian-tmp-${randomBytes(6).toString('hex')}`;
|
|
595
|
+
try {
|
|
596
|
+
await stageFileGuarded(runner, workdir, tmp, content);
|
|
597
|
+
await moveFileIntoPlace(runner, tmp, finalPath, { guardRoot: workdir });
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
throw new Error(`atomic skill write failed (${finalPath}): ${err instanceof Error ? err.message : String(err)}`);
|
|
501
601
|
}
|
|
502
602
|
}
|
|
503
603
|
async ensureSkillDirSafe(runner, workdir, subdir) {
|
|
504
604
|
const top = subdir.split('/')[0];
|
|
505
605
|
const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
|
|
506
|
-
const inner = `
|
|
606
|
+
const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
|
|
607
|
+
`cd ${shellQuote(workdir)} && ` +
|
|
507
608
|
`for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
|
|
508
609
|
`printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
|
|
509
610
|
`exit 3; fi; done && ` +
|
|
@@ -516,10 +617,12 @@ export class AgentManager {
|
|
|
516
617
|
throw new Error(`failed to prepare a symlink-safe ${subdir} in ${workdir}: ${res.stderr || 'unknown error'}`);
|
|
517
618
|
}
|
|
518
619
|
}
|
|
519
|
-
async tagSessionSkillsVersion(tmux, agentId) {
|
|
620
|
+
async tagSessionSkillsVersion(tmux, agentId, ref) {
|
|
520
621
|
if (this.skillRegistry.names().length === 0)
|
|
521
622
|
return;
|
|
522
|
-
await
|
|
623
|
+
await this.setSessionOptions(tmux, agentId, ref, [
|
|
624
|
+
['@baxian-skills-version', this.skillRegistry.contentHash()],
|
|
625
|
+
]);
|
|
523
626
|
}
|
|
524
627
|
async replSkillsStale(tmux, agentId) {
|
|
525
628
|
if (this.skillRegistry.names().length === 0)
|
|
@@ -528,7 +631,8 @@ export class AgentManager {
|
|
|
528
631
|
return tagged !== this.skillRegistry.contentHash();
|
|
529
632
|
}
|
|
530
633
|
async excludeInjectedSkills(runner, workdir, subdir) {
|
|
531
|
-
const inner = `
|
|
634
|
+
const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
|
|
635
|
+
`cd ${shellQuote(workdir)} && ` +
|
|
532
636
|
`if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
|
|
533
637
|
`tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
|
|
534
638
|
`if [ -n "$tracked" ]; then printf 'BX_SKILLS_TRACKED %s\\n' "$tracked" | head -1; exit 0; fi; ` +
|
|
@@ -551,35 +655,46 @@ export class AgentManager {
|
|
|
551
655
|
}
|
|
552
656
|
throw new Error(`cannot make injected skills invisible to Git in ${workdir}: ${verdict || 'no probe output'}`);
|
|
553
657
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
658
|
+
runtimePinEntries() {
|
|
659
|
+
return [
|
|
660
|
+
['prefix', 'C-b'],
|
|
661
|
+
['prefix2', 'None'],
|
|
662
|
+
['mouse', 'on'],
|
|
663
|
+
];
|
|
558
664
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
665
|
+
// A name (or even a bare $id) can rebind to a same-name successor mid-flight;
|
|
666
|
+
// every option write goes through the identity-checked by-ref batch.
|
|
667
|
+
async setSessionOptions(tmux, agentId, ref, entries) {
|
|
668
|
+
const outcome = await tmux.setSessionOptionsIfAlive(ref, entries);
|
|
669
|
+
if (outcome === 'gone') {
|
|
670
|
+
throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
|
|
671
|
+
}
|
|
562
672
|
}
|
|
563
673
|
async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
|
|
564
674
|
return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
|
|
565
675
|
}
|
|
566
676
|
async buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir) {
|
|
567
677
|
let createdSession = false;
|
|
678
|
+
let createdRef;
|
|
679
|
+
const genAtCreate = this.adoptGeneration.get(agentId) ?? 0;
|
|
568
680
|
const runtime = agentRuntimeKindFor(agent);
|
|
569
681
|
try {
|
|
570
|
-
await tmux.createSession(agentId, sessionDir);
|
|
682
|
+
createdRef = await tmux.createSession(agentId, sessionDir);
|
|
571
683
|
createdSession = true;
|
|
572
|
-
await
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
684
|
+
await this.setSessionOptions(tmux, agentId, createdRef, [
|
|
685
|
+
['@baxian-agent-id', agentId],
|
|
686
|
+
['@baxian-runtime', agent.runtime],
|
|
687
|
+
[WORKDIR_SESSION_OPTION, sessionDir],
|
|
688
|
+
['allow-passthrough', 'on'],
|
|
689
|
+
['set-titles', 'on'],
|
|
690
|
+
['window-size', 'latest'],
|
|
691
|
+
...this.runtimePinEntries(),
|
|
692
|
+
['status-right', ''],
|
|
693
|
+
]);
|
|
579
694
|
await tmux.setServerOption('extended-keys', 'on');
|
|
580
695
|
await tmux.appendServerOptionIfMissing('terminal-features', 'xterm*:extkeys');
|
|
581
|
-
const paneId = await tmux.
|
|
582
|
-
await tmux.sendKeysToPane(paneId, `${
|
|
696
|
+
const paneId = await tmux.getSinglePaneIdByRef(createdRef);
|
|
697
|
+
await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
|
|
583
698
|
await tmux.handleTrustDialog(paneId, runtime, {
|
|
584
699
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
585
700
|
});
|
|
@@ -587,11 +702,13 @@ export class AgentManager {
|
|
|
587
702
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
588
703
|
scrollback: 0,
|
|
589
704
|
});
|
|
590
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
591
|
-
return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir };
|
|
705
|
+
await this.tagSessionSkillsVersion(tmux, agentId, createdRef);
|
|
706
|
+
return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir, sessionRef: createdRef };
|
|
592
707
|
}
|
|
593
708
|
catch (err) {
|
|
594
|
-
const partial = { createdSession, agentId };
|
|
709
|
+
const partial = { createdSession, agentId, genAtCreate };
|
|
710
|
+
if (createdRef)
|
|
711
|
+
partial.sessionRef = createdRef;
|
|
595
712
|
if (err instanceof ReplNotReadyError) {
|
|
596
713
|
partial.lastScreen = err.lastScreen;
|
|
597
714
|
if (createdSession && detectStartupDialog(err.lastScreen, runtime)) {
|
|
@@ -607,22 +724,12 @@ export class AgentManager {
|
|
|
607
724
|
console.warn(`[bootstrap] ${agentId} not in config at bootstrap start — aborting`);
|
|
608
725
|
return;
|
|
609
726
|
}
|
|
610
|
-
const tryKillOrphanSession = async (reason) => {
|
|
611
|
-
try {
|
|
612
|
-
const runner = this.createRunnerFor(cfgAtStart);
|
|
613
|
-
await new TmuxManager(runner).killSession(agentId);
|
|
614
|
-
}
|
|
615
|
-
catch (cleanupErr) {
|
|
616
|
-
console.warn(`[bootstrap] orphan killSession (${reason}) failed for ${agentId}:`, cleanupErr);
|
|
617
|
-
}
|
|
618
|
-
};
|
|
619
727
|
try {
|
|
620
728
|
const result = await this.ensureSession(agentId, 'create');
|
|
621
729
|
if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.paneId))) {
|
|
622
730
|
const current = await this.agentStore.get(agentId);
|
|
623
731
|
if (current && current.creationToken !== creationToken) {
|
|
624
|
-
console.warn(`[bootstrap] ${agentId} creationToken changed during greeting —
|
|
625
|
-
await tryKillOrphanSession('greeting-failure token mismatch');
|
|
732
|
+
console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — leaving the session to its successor`);
|
|
626
733
|
return;
|
|
627
734
|
}
|
|
628
735
|
await this.markGreetingFailed(agentId, creationToken);
|
|
@@ -643,8 +750,7 @@ export class AgentManager {
|
|
|
643
750
|
};
|
|
644
751
|
});
|
|
645
752
|
if (!resolvedExisting) {
|
|
646
|
-
console.warn(`[bootstrap] ${agentId} creationToken mismatch on success —
|
|
647
|
-
await tryKillOrphanSession('post-success token mismatch');
|
|
753
|
+
console.warn(`[bootstrap] ${agentId} creationToken mismatch on success — leaving the session to its successor`);
|
|
648
754
|
return;
|
|
649
755
|
}
|
|
650
756
|
await this.safeEmit({
|
|
@@ -662,8 +768,7 @@ export class AgentManager {
|
|
|
662
768
|
if (err.partial.createdSession) {
|
|
663
769
|
const fresh = await this.agentStore.get(agentId);
|
|
664
770
|
if (!fresh || fresh.creationToken !== creationToken) {
|
|
665
|
-
console.warn(`[bootstrap] ${agentId} dialog-path token mismatch —
|
|
666
|
-
await tryKillOrphanSession('dialog-path token mismatch');
|
|
771
|
+
console.warn(`[bootstrap] ${agentId} dialog-path token mismatch — leaving the session to its successor`);
|
|
667
772
|
return;
|
|
668
773
|
}
|
|
669
774
|
}
|
|
@@ -674,7 +779,9 @@ export class AgentManager {
|
|
|
674
779
|
return;
|
|
675
780
|
}
|
|
676
781
|
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
677
|
-
await
|
|
782
|
+
await this.rollbackCreatedSession(agentId, err.partial, 'hard-failure rollback', {
|
|
783
|
+
expectCreationToken: creationToken,
|
|
784
|
+
});
|
|
678
785
|
}
|
|
679
786
|
const message = err instanceof Error ? err.message : String(err);
|
|
680
787
|
await this.markBootstrapFailed(agentId, creationToken, message);
|
|
@@ -1143,27 +1250,39 @@ export class AgentManager {
|
|
|
1143
1250
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
1144
1251
|
});
|
|
1145
1252
|
}
|
|
1253
|
+
// Claim snapshot and adoption declaration share one critical section with rollbacks.
|
|
1146
1254
|
async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
|
|
1147
|
-
|
|
1255
|
+
return this.runUnderSessionLifecycle(agentId, () => this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir));
|
|
1256
|
+
}
|
|
1257
|
+
async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir) {
|
|
1258
|
+
let snapshot;
|
|
1148
1259
|
try {
|
|
1149
|
-
|
|
1260
|
+
snapshot = await tmux.getSessionSnapshot(agentId);
|
|
1150
1261
|
}
|
|
1151
1262
|
catch (err) {
|
|
1152
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `
|
|
1263
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1153
1264
|
}
|
|
1154
|
-
if (
|
|
1265
|
+
if (!snapshot) {
|
|
1266
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1267
|
+
}
|
|
1268
|
+
const sessionRef = snapshot.ref;
|
|
1269
|
+
if (snapshot.claim !== agentId) {
|
|
1270
|
+
if (snapshot.claim === null && await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
|
|
1271
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1272
|
+
}
|
|
1155
1273
|
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" exists but @baxian-agent-id session claim mismatch ` +
|
|
1156
|
-
`(got "${claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
|
|
1274
|
+
`(got "${snapshot.claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
|
|
1157
1275
|
}
|
|
1276
|
+
this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
|
|
1158
1277
|
try {
|
|
1159
|
-
await this.
|
|
1278
|
+
await this.setSessionOptions(tmux, agentId, sessionRef, this.runtimePinEntries());
|
|
1160
1279
|
}
|
|
1161
1280
|
catch (err) {
|
|
1162
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `
|
|
1281
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `pinning runtime session options failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1163
1282
|
}
|
|
1164
1283
|
let paneId;
|
|
1165
1284
|
try {
|
|
1166
|
-
paneId = await tmux.
|
|
1285
|
+
paneId = await tmux.getSinglePaneIdByRef(sessionRef);
|
|
1167
1286
|
}
|
|
1168
1287
|
catch (err) {
|
|
1169
1288
|
throw new EnsureSessionError({ createdSession: false, agentId }, `getSinglePaneId failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1202,10 +1321,10 @@ export class AgentManager {
|
|
|
1202
1321
|
else if (state.kind !== 'shell') {
|
|
1203
1322
|
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux pane path ${currentPath} differs from Workdir ${sessionDir}, but runtime is not safely idle`);
|
|
1204
1323
|
}
|
|
1205
|
-
await
|
|
1324
|
+
await this.killAdoptTarget(tmux, agentId, sessionRef, 'workdir change');
|
|
1206
1325
|
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1207
1326
|
}
|
|
1208
|
-
await
|
|
1327
|
+
await this.setSessionOptions(tmux, agentId, sessionRef, [[WORKDIR_SESSION_OPTION, sessionDir]]);
|
|
1209
1328
|
switch (state.kind) {
|
|
1210
1329
|
case 'live-runtime': {
|
|
1211
1330
|
let stale;
|
|
@@ -1223,9 +1342,10 @@ export class AgentManager {
|
|
|
1223
1342
|
skillsStale: true,
|
|
1224
1343
|
paneId,
|
|
1225
1344
|
workdir,
|
|
1345
|
+
sessionRef,
|
|
1226
1346
|
};
|
|
1227
1347
|
}
|
|
1228
|
-
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
|
|
1348
|
+
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir, sessionRef };
|
|
1229
1349
|
}
|
|
1230
1350
|
case 'startup-dialog':
|
|
1231
1351
|
throw new EnsureSessionError({
|
|
@@ -1247,8 +1367,8 @@ export class AgentManager {
|
|
|
1247
1367
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1248
1368
|
scrollback: 0,
|
|
1249
1369
|
});
|
|
1250
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1251
|
-
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
|
|
1370
|
+
await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
|
|
1371
|
+
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
|
|
1252
1372
|
}
|
|
1253
1373
|
catch (trustErr) {
|
|
1254
1374
|
if (trustErr instanceof ReplNotReadyError && detectStartupDialog(trustErr.lastScreen, runtime)) {
|
|
@@ -1266,7 +1386,7 @@ export class AgentManager {
|
|
|
1266
1386
|
break;
|
|
1267
1387
|
}
|
|
1268
1388
|
try {
|
|
1269
|
-
await tmux.sendKeysToPane(paneId, `${
|
|
1389
|
+
await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
|
|
1270
1390
|
await tmux.handleTrustDialog(paneId, runtime, {
|
|
1271
1391
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
1272
1392
|
});
|
|
@@ -1274,8 +1394,8 @@ export class AgentManager {
|
|
|
1274
1394
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1275
1395
|
scrollback: 0,
|
|
1276
1396
|
});
|
|
1277
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1278
|
-
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
|
|
1397
|
+
await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
|
|
1398
|
+
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
|
|
1279
1399
|
}
|
|
1280
1400
|
catch (relErr) {
|
|
1281
1401
|
if (relErr instanceof ReplNotReadyError && detectStartupDialog(relErr.lastScreen, runtime)) {
|
|
@@ -1289,6 +1409,66 @@ export class AgentManager {
|
|
|
1289
1409
|
throw new EnsureSessionError({ createdSession: false, agentId }, `REPL relaunch failed: ${relErr instanceof Error ? relErr.message : String(relErr)}`);
|
|
1290
1410
|
}
|
|
1291
1411
|
}
|
|
1412
|
+
// Frees a bootstrap blocked on its predecessor's half-created session (nonce
|
|
1413
|
+
// env present but claim never written); anything else is left alone.
|
|
1414
|
+
async reclaimHalfCreatedSession(tmux, agentId, knownSnapshot) {
|
|
1415
|
+
let snapshot;
|
|
1416
|
+
try {
|
|
1417
|
+
snapshot = knownSnapshot ?? await tmux.getSessionSnapshot(agentId);
|
|
1418
|
+
}
|
|
1419
|
+
catch (err) {
|
|
1420
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1421
|
+
}
|
|
1422
|
+
if (!snapshot)
|
|
1423
|
+
return true;
|
|
1424
|
+
if (snapshot.claim !== null)
|
|
1425
|
+
return false;
|
|
1426
|
+
let halfCreated;
|
|
1427
|
+
try {
|
|
1428
|
+
halfCreated = await tmux.hasCreationNonce(agentId);
|
|
1429
|
+
}
|
|
1430
|
+
catch (err) {
|
|
1431
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `creation-nonce probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1432
|
+
}
|
|
1433
|
+
if (!halfCreated)
|
|
1434
|
+
return false;
|
|
1435
|
+
let outcome;
|
|
1436
|
+
try {
|
|
1437
|
+
// claim-still-empty rides the kill itself — a claim racing the probes makes the server refuse.
|
|
1438
|
+
outcome = await tmux.killSessionRef(snapshot.ref, { onlyIfUnclaimed: true });
|
|
1439
|
+
}
|
|
1440
|
+
catch (err) {
|
|
1441
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (half-created leftover) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1442
|
+
}
|
|
1443
|
+
if (outcome === 'refused' || outcome === 'stale-server') {
|
|
1444
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `session ${snapshot.ref.sessionId} was claimed (or its server changed) while reclaiming the half-created leftover — retry to observe the new state`);
|
|
1445
|
+
}
|
|
1446
|
+
if (outcome === 'killed') {
|
|
1447
|
+
console.warn(`[AgentManager] ${agentId}: killed half-created leftover session ${snapshot.ref.sessionId} (creation nonce present, claim never written)`);
|
|
1448
|
+
}
|
|
1449
|
+
else {
|
|
1450
|
+
console.warn(`[AgentManager] ${agentId}: half-created leftover session ${snapshot.ref.sessionId} already gone`);
|
|
1451
|
+
}
|
|
1452
|
+
return true;
|
|
1453
|
+
}
|
|
1454
|
+
async killAdoptTarget(tmux, agentId, sessionRef, reason) {
|
|
1455
|
+
let outcome;
|
|
1456
|
+
try {
|
|
1457
|
+
outcome = await tmux.killSessionRef(sessionRef);
|
|
1458
|
+
}
|
|
1459
|
+
catch (err) {
|
|
1460
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1461
|
+
}
|
|
1462
|
+
if (outcome === 'stale-server') {
|
|
1463
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux server generation changed while replacing session (${reason}); retry to observe the new state`);
|
|
1464
|
+
}
|
|
1465
|
+
if (outcome === 'killed') {
|
|
1466
|
+
console.warn(`[AgentManager] ${agentId}: killed session ${sessionRef.sessionId} (${reason})`);
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1292
1472
|
async acquireAgentForTask(agentId, taskId, phase) {
|
|
1293
1473
|
const cfg = this.getAgentConfig(agentId);
|
|
1294
1474
|
if (!cfg)
|
|
@@ -1335,17 +1515,43 @@ export class AgentManager {
|
|
|
1335
1515
|
`(expected ${expectedTaskId}, got ${state.taskId}); skipping`);
|
|
1336
1516
|
return false;
|
|
1337
1517
|
}
|
|
1518
|
+
if (opts.expectedLockToken !== undefined && state.lockToken !== opts.expectedLockToken) {
|
|
1519
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} lock generation changed ` +
|
|
1520
|
+
`(binding re-acquired by a successor); skipping release`);
|
|
1521
|
+
return false;
|
|
1522
|
+
}
|
|
1338
1523
|
if (isCancelCleanupHold(state) && !opts.fromCancelCleanup) {
|
|
1339
1524
|
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} ${state.awaitingPhase} (cancel-cleanup hold); refusing auto-release`);
|
|
1340
1525
|
return false;
|
|
1341
1526
|
}
|
|
1342
1527
|
const boundTask = await this.taskStore.get(expectedTaskId);
|
|
1528
|
+
if (opts.expectedTask
|
|
1529
|
+
&& boundTask
|
|
1530
|
+
&& (boundTask.status !== opts.expectedTask.status
|
|
1531
|
+
|| boundTask.phase !== opts.expectedTask.phase
|
|
1532
|
+
|| boundTask.signalToken !== opts.expectedTask.signalToken)) {
|
|
1533
|
+
console.warn(`[AgentManager] releaseAgentForTask: task ${expectedTaskId} moved past the captured pass; skipping ${mode} transition`);
|
|
1534
|
+
return false;
|
|
1535
|
+
}
|
|
1343
1536
|
if (state.status === 'awaiting_human' && !opts.allowAwaitingHuman) {
|
|
1344
1537
|
if (!shouldReleaseHeldBinding(state, boundTask)) {
|
|
1345
1538
|
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} is awaiting_human (${state.awaitingPhase}); refusing to release`);
|
|
1346
1539
|
return false;
|
|
1347
1540
|
}
|
|
1348
1541
|
}
|
|
1542
|
+
// expectedHold 是 hold 世代快照的严格 CAS(全 undefined 表示“期望无 hold”):
|
|
1543
|
+
// hold 消失(被并发 Resume 清掉)或换代同样意味着接管,不得继续释放
|
|
1544
|
+
const matchesExpectedHold = (binding) => {
|
|
1545
|
+
const held = binding.status === 'awaiting_human';
|
|
1546
|
+
return (held ? binding.awaitingPhase : undefined) === opts.expectedHold.phase
|
|
1547
|
+
&& (held ? binding.awaitingSince : undefined) === opts.expectedHold.since
|
|
1548
|
+
&& (held ? binding.awaitingNonce : undefined) === opts.expectedHold.nonce;
|
|
1549
|
+
};
|
|
1550
|
+
// 世代门前置:hold 换代时任何 pane/workdir 副作用都不得发生(末尾 CAS 只兜最后窗口)
|
|
1551
|
+
if (opts.expectedHold && !matchesExpectedHold(state)) {
|
|
1552
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed or cleared before release; refusing`);
|
|
1553
|
+
return false;
|
|
1554
|
+
}
|
|
1349
1555
|
if (mode === 'waiting' && (!boundTask || !ACTIVE_TASK_STATUSES.has(boundTask.status))) {
|
|
1350
1556
|
console.warn(`[AgentManager] releaseAgentForTask: task ${expectedTaskId} is not active; skipping waiting transition`);
|
|
1351
1557
|
return false;
|
|
@@ -1360,10 +1566,19 @@ export class AgentManager {
|
|
|
1360
1566
|
}
|
|
1361
1567
|
const now = new Date().toISOString();
|
|
1362
1568
|
if (mode === 'waiting') {
|
|
1569
|
+
let preservedNewerHold = false;
|
|
1363
1570
|
await this.agentStore.update(agentId, (latest) => {
|
|
1364
1571
|
if (!latest)
|
|
1365
1572
|
return AGENT_STORE_NOOP;
|
|
1366
1573
|
if (opts.clearAwaitingHuman && latest.status === 'awaiting_human') {
|
|
1574
|
+
// The hold-generation CAS lives inside the atomic update: a hold written after any pre-read survives.
|
|
1575
|
+
if (opts.expectedHold
|
|
1576
|
+
&& (latest.awaitingPhase !== opts.expectedHold.phase
|
|
1577
|
+
|| latest.awaitingSince !== opts.expectedHold.since
|
|
1578
|
+
|| latest.awaitingNonce !== opts.expectedHold.nonce)) {
|
|
1579
|
+
preservedNewerHold = true;
|
|
1580
|
+
return AGENT_STORE_NOOP;
|
|
1581
|
+
}
|
|
1367
1582
|
const cleared = {
|
|
1368
1583
|
id: latest.id,
|
|
1369
1584
|
projectId: latest.projectId,
|
|
@@ -1382,6 +1597,10 @@ export class AgentManager {
|
|
|
1382
1597
|
updatedAt: now,
|
|
1383
1598
|
};
|
|
1384
1599
|
});
|
|
1600
|
+
if (preservedNewerHold) {
|
|
1601
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} holds a newer generation than the captured one; keeping the hold`);
|
|
1602
|
+
return false;
|
|
1603
|
+
}
|
|
1385
1604
|
return true;
|
|
1386
1605
|
}
|
|
1387
1606
|
let sessionConfirmedAbsent = false;
|
|
@@ -1389,6 +1608,7 @@ export class AgentManager {
|
|
|
1389
1608
|
const releaseWorkdir = state.workdir;
|
|
1390
1609
|
const runner = this.createRunnerFor(cfg);
|
|
1391
1610
|
const tmux = new TmuxManager(runner);
|
|
1611
|
+
const holdMatchesExpected = (binding) => !opts.expectedHold || matchesExpectedHold(binding);
|
|
1392
1612
|
const hold = async (reason) => {
|
|
1393
1613
|
const latest = await this.agentStore.get(agentId);
|
|
1394
1614
|
if (!latest
|
|
@@ -1397,18 +1617,27 @@ export class AgentManager {
|
|
|
1397
1617
|
|| !await this.lockManager.isOwner(agentId, expectedTaskId, lockToken)) {
|
|
1398
1618
|
return false;
|
|
1399
1619
|
}
|
|
1620
|
+
// 释放中被写入的新 hold(如 ack_unknown)比 cleanup 失败信息更关键,不得覆盖
|
|
1621
|
+
if (!holdMatchesExpected(latest)) {
|
|
1622
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed during release; not overwriting with branch-cleanup-pending`);
|
|
1623
|
+
return false;
|
|
1624
|
+
}
|
|
1400
1625
|
const pendingAt = new Date().toISOString();
|
|
1401
1626
|
if (boundTask && cfg.role === 'dev') {
|
|
1402
1627
|
boundTask.branchCleanupPending = { agentId, reason, updatedAt: pendingAt };
|
|
1403
1628
|
boundTask.updatedAt = pendingAt;
|
|
1404
1629
|
await this.taskStore.set(boundTask);
|
|
1405
1630
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1631
|
+
let wrote = false;
|
|
1632
|
+
await this.agentStore.update(agentId, (latest2) => {
|
|
1633
|
+
if (!latest2 || latest2.taskId !== expectedTaskId || latest2.lockToken !== lockToken) {
|
|
1408
1634
|
return AGENT_STORE_NOOP;
|
|
1409
1635
|
}
|
|
1636
|
+
if (!holdMatchesExpected(latest2))
|
|
1637
|
+
return AGENT_STORE_NOOP;
|
|
1638
|
+
wrote = true;
|
|
1410
1639
|
return {
|
|
1411
|
-
...
|
|
1640
|
+
...latest2,
|
|
1412
1641
|
status: 'awaiting_human',
|
|
1413
1642
|
awaitingPhase: 'branch-cleanup-pending',
|
|
1414
1643
|
awaitingReason: reason,
|
|
@@ -1416,6 +1645,10 @@ export class AgentManager {
|
|
|
1416
1645
|
updatedAt: pendingAt,
|
|
1417
1646
|
};
|
|
1418
1647
|
});
|
|
1648
|
+
if (!wrote) {
|
|
1649
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} branch-cleanup-pending hold not written (binding or hold changed)`);
|
|
1650
|
+
return false;
|
|
1651
|
+
}
|
|
1419
1652
|
await this.emitIntervention(state.projectId, agentId, expectedTaskId, {
|
|
1420
1653
|
phase: 'branch-cleanup-pending',
|
|
1421
1654
|
reason,
|
|
@@ -1450,6 +1683,14 @@ export class AgentManager {
|
|
|
1450
1683
|
sessionConfirmedAbsent = true;
|
|
1451
1684
|
}
|
|
1452
1685
|
await this.assertTaskGeneration(agentId, expectedTaskId, lockToken, releaseWorkdir);
|
|
1686
|
+
// 探测/等待期间 markAwaitingHuman 不受本锁串行;进入 checkout 变更前按当前盘面重验世代
|
|
1687
|
+
if (opts.expectedHold) {
|
|
1688
|
+
const beforeBranchOps = await this.agentStore.get(agentId);
|
|
1689
|
+
if (!beforeBranchOps || !matchesExpectedHold(beforeBranchOps)) {
|
|
1690
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed before checkout cleanup; aborting release`);
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1453
1694
|
if (cfg.role === 'dev' && boundTask) {
|
|
1454
1695
|
const branches = new BranchManager(runner);
|
|
1455
1696
|
const cleanup = await branches.cleanupTaskBranch(releaseWorkdir, {
|
|
@@ -1493,6 +1734,13 @@ export class AgentManager {
|
|
|
1493
1734
|
delete boundTask.branchCleanupSkipped;
|
|
1494
1735
|
cleanupStateChanged = true;
|
|
1495
1736
|
}
|
|
1737
|
+
if (cleanup.remoteTipSha) {
|
|
1738
|
+
boundTask.branchLocalCleaned = {
|
|
1739
|
+
remoteTipSha: cleanup.remoteTipSha,
|
|
1740
|
+
updatedAt: new Date().toISOString(),
|
|
1741
|
+
};
|
|
1742
|
+
cleanupStateChanged = true;
|
|
1743
|
+
}
|
|
1496
1744
|
}
|
|
1497
1745
|
if (cleanupStateChanged) {
|
|
1498
1746
|
boundTask.updatedAt = new Date().toISOString();
|
|
@@ -1519,6 +1767,7 @@ export class AgentManager {
|
|
|
1519
1767
|
else {
|
|
1520
1768
|
await this.assertTaskLockOwner(agentId, expectedTaskId, lockToken);
|
|
1521
1769
|
}
|
|
1770
|
+
let holdGenerationChanged = false;
|
|
1522
1771
|
await this.agentStore.update(agentId, (existing) => {
|
|
1523
1772
|
if (!existing)
|
|
1524
1773
|
return AGENT_STORE_NOOP;
|
|
@@ -1527,6 +1776,11 @@ export class AgentManager {
|
|
|
1527
1776
|
|| (state.workdir !== undefined && existing.workdir !== state.workdir)) {
|
|
1528
1777
|
return AGENT_STORE_NOOP;
|
|
1529
1778
|
}
|
|
1779
|
+
// 世代 CAS 在原子写入内判定:预读后被重写或清除的 hold 都不得被这次释放清掉
|
|
1780
|
+
if (opts.expectedHold && !matchesExpectedHold(existing)) {
|
|
1781
|
+
holdGenerationChanged = true;
|
|
1782
|
+
return AGENT_STORE_NOOP;
|
|
1783
|
+
}
|
|
1530
1784
|
return {
|
|
1531
1785
|
id: existing.id,
|
|
1532
1786
|
projectId: existing.projectId,
|
|
@@ -1536,9 +1790,25 @@ export class AgentManager {
|
|
|
1536
1790
|
updatedAt: now,
|
|
1537
1791
|
};
|
|
1538
1792
|
});
|
|
1793
|
+
if (holdGenerationChanged) {
|
|
1794
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} hold generation changed during release; keeping the hold`);
|
|
1795
|
+
return false;
|
|
1796
|
+
}
|
|
1539
1797
|
return this.lockManager.releaseIfOwner(agentId, expectedTaskId, lockToken);
|
|
1540
1798
|
});
|
|
1541
1799
|
}
|
|
1800
|
+
async clearBranchLocalCleaned(taskId) {
|
|
1801
|
+
await this.withTaskLock(async () => {
|
|
1802
|
+
const fresh = await this.taskStore.get(taskId);
|
|
1803
|
+
if (!fresh?.branchLocalCleaned)
|
|
1804
|
+
return;
|
|
1805
|
+
delete fresh.branchLocalCleaned;
|
|
1806
|
+
fresh.updatedAt = new Date().toISOString();
|
|
1807
|
+
await this.taskStore.set(fresh);
|
|
1808
|
+
}).catch(err => {
|
|
1809
|
+
console.warn(`[AgentManager] clearing branchLocalCleaned(${taskId}) failed:`, err);
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1542
1812
|
async releaseAgentIfBound(agentId, expectedTaskId, opts) {
|
|
1543
1813
|
const state = await this.agentStore.get(agentId);
|
|
1544
1814
|
if (state?.taskId !== expectedTaskId)
|
|
@@ -1750,14 +2020,20 @@ export class AgentManager {
|
|
|
1750
2020
|
this.phaseSignalWatcher?.rearmNeedInput(agentId);
|
|
1751
2021
|
await this.setAgentNeedInput(agentId, false);
|
|
1752
2022
|
}
|
|
1753
|
-
async clearAwaitingHuman(agentId) {
|
|
2023
|
+
async clearAwaitingHuman(agentId, opts = {}) {
|
|
1754
2024
|
const now = new Date().toISOString();
|
|
1755
2025
|
let cleared = false;
|
|
1756
2026
|
await this.agentStore.update(agentId, (latest) => {
|
|
1757
2027
|
if (!latest || latest.status !== 'awaiting_human')
|
|
1758
2028
|
return AGENT_STORE_NOOP;
|
|
2029
|
+
if (opts.expectedHold
|
|
2030
|
+
&& (latest.awaitingPhase !== opts.expectedHold.phase
|
|
2031
|
+
|| latest.awaitingSince !== opts.expectedHold.since
|
|
2032
|
+
|| latest.awaitingNonce !== opts.expectedHold.nonce)) {
|
|
2033
|
+
return AGENT_STORE_NOOP;
|
|
2034
|
+
}
|
|
1759
2035
|
cleared = true;
|
|
1760
|
-
const { status: _status, awaitingPhase: _awaitingPhase, awaitingReason: _awaitingReason, awaitingSince: _awaitingSince, ...readyState } = latest;
|
|
2036
|
+
const { status: _status, awaitingPhase: _awaitingPhase, awaitingReason: _awaitingReason, awaitingSince: _awaitingSince, awaitingNonce: _awaitingNonce, ...readyState } = latest;
|
|
1761
2037
|
return {
|
|
1762
2038
|
...readyState,
|
|
1763
2039
|
updatedAt: now,
|
|
@@ -1773,6 +2049,16 @@ export class AgentManager {
|
|
|
1773
2049
|
await this.agentStore.update(agentId, (existing) => {
|
|
1774
2050
|
if (!existing)
|
|
1775
2051
|
return AGENT_STORE_NOOP;
|
|
2052
|
+
if (opts.expectedHold) {
|
|
2053
|
+
const heldPhase = existing.status === 'awaiting_human' ? existing.awaitingPhase : undefined;
|
|
2054
|
+
const heldSince = existing.status === 'awaiting_human' ? existing.awaitingSince : undefined;
|
|
2055
|
+
const heldNonce = existing.status === 'awaiting_human' ? existing.awaitingNonce : undefined;
|
|
2056
|
+
if (heldPhase !== opts.expectedHold.phase
|
|
2057
|
+
|| heldSince !== opts.expectedHold.since
|
|
2058
|
+
|| heldNonce !== opts.expectedHold.nonce) {
|
|
2059
|
+
return AGENT_STORE_NOOP;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
1776
2062
|
if (opts.expectedCreationToken !== undefined) {
|
|
1777
2063
|
const expected = opts.expectedCreationToken;
|
|
1778
2064
|
const actual = existing.creationToken ?? null;
|
|
@@ -1785,6 +2071,10 @@ export class AgentManager {
|
|
|
1785
2071
|
if (actualTask !== expectedTask)
|
|
1786
2072
|
return AGENT_STORE_NOOP;
|
|
1787
2073
|
}
|
|
2074
|
+
// 锁代 CAS:绑定被 successor re-acquire 后不得把工作中的 agent 挂回旧 hold
|
|
2075
|
+
if (opts.expectedLockToken !== undefined && existing.lockToken !== opts.expectedLockToken) {
|
|
2076
|
+
return AGENT_STORE_NOOP;
|
|
2077
|
+
}
|
|
1788
2078
|
if (isCancelCleanupHold(existing)) {
|
|
1789
2079
|
if (!CANCEL_CLEANUP_HOLD_PHASES.has(phase))
|
|
1790
2080
|
return AGENT_STORE_NOOP;
|
|
@@ -1801,15 +2091,17 @@ export class AgentManager {
|
|
|
1801
2091
|
awaitingPhase: phase,
|
|
1802
2092
|
awaitingReason: reason,
|
|
1803
2093
|
awaitingSince: now,
|
|
2094
|
+
awaitingNonce: randomBytes(8).toString('hex'),
|
|
1804
2095
|
updatedAt: now,
|
|
1805
2096
|
};
|
|
1806
2097
|
});
|
|
1807
2098
|
if (!wrote)
|
|
1808
|
-
return;
|
|
2099
|
+
return false;
|
|
1809
2100
|
await this.emitIntervention(projectId, agentId, taskId, {
|
|
1810
2101
|
phase,
|
|
1811
2102
|
reason,
|
|
1812
2103
|
});
|
|
2104
|
+
return true;
|
|
1813
2105
|
}
|
|
1814
2106
|
async markAwaitingIfAckUnknown(agentId, err, expectedTaskId) {
|
|
1815
2107
|
if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
|
|
@@ -1860,7 +2152,69 @@ export class AgentManager {
|
|
|
1860
2152
|
return { resumed: false, releasedBinding: false, reason };
|
|
1861
2153
|
}
|
|
1862
2154
|
if ((state.awaitingPhase === 'dirty-workdir' || state.awaitingPhase === 'checkout-preparation-failed')
|
|
1863
|
-
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
|
|
2155
|
+
&& boundTask && ACTIVE_TASK_STATUSES.has(boundTask.status)
|
|
2156
|
+
// verdict 门禁状态没有在途派发:清掉 hold 后由 spec verdict 重新走完整转码,Resume 放行
|
|
2157
|
+
&& boundTask.status !== 'spec-ready' && boundTask.status !== 'max_rounds') {
|
|
2158
|
+
if (state.taskId && boundTask.qaAgentId === agentId) {
|
|
2159
|
+
// 停驻的 review pass 已死(任务离开 review):重派会伪造新 pass,只清 hold 并释放绑定;
|
|
2160
|
+
// 快照 hold/task 世代供释放路径 CAS,且不得借用 cancel-cleanup 的绕行凭据
|
|
2161
|
+
if (boundTask.status !== 'review') {
|
|
2162
|
+
return {
|
|
2163
|
+
resumed: true,
|
|
2164
|
+
releasedBinding: false,
|
|
2165
|
+
releaseHeldQa: {
|
|
2166
|
+
taskId: state.taskId,
|
|
2167
|
+
expectedHold: {
|
|
2168
|
+
phase: state.awaitingPhase,
|
|
2169
|
+
since: state.awaitingSince,
|
|
2170
|
+
nonce: state.awaitingNonce,
|
|
2171
|
+
},
|
|
2172
|
+
expectedTask: {
|
|
2173
|
+
status: boundTask.status,
|
|
2174
|
+
phase: boundTask.phase,
|
|
2175
|
+
signalToken: boundTask.signalToken,
|
|
2176
|
+
},
|
|
2177
|
+
},
|
|
2178
|
+
projectId: state.projectId,
|
|
2179
|
+
previousPhase: state.awaitingPhase,
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
const nowQa = new Date().toISOString();
|
|
2183
|
+
let clearedHold = false;
|
|
2184
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
2185
|
+
if (!existing || existing.taskId !== state.taskId)
|
|
2186
|
+
return AGENT_STORE_NOOP;
|
|
2187
|
+
if (existing.awaitingPhase !== state.awaitingPhase
|
|
2188
|
+
|| existing.awaitingSince !== state.awaitingSince
|
|
2189
|
+
|| existing.awaitingNonce !== state.awaitingNonce) {
|
|
2190
|
+
return AGENT_STORE_NOOP;
|
|
2191
|
+
}
|
|
2192
|
+
clearedHold = true;
|
|
2193
|
+
return {
|
|
2194
|
+
...existing,
|
|
2195
|
+
status: 'ok',
|
|
2196
|
+
awaitingPhase: undefined,
|
|
2197
|
+
awaitingReason: undefined,
|
|
2198
|
+
awaitingSince: undefined,
|
|
2199
|
+
awaitingNonce: undefined,
|
|
2200
|
+
updatedAt: nowQa,
|
|
2201
|
+
};
|
|
2202
|
+
});
|
|
2203
|
+
if (!clearedHold) {
|
|
2204
|
+
const reason = `Agent ${agentId} hold changed while resuming; re-check the agent state and Resume again.`;
|
|
2205
|
+
console.warn(`[AgentManager] resumeAgent: ${reason}`);
|
|
2206
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
2207
|
+
}
|
|
2208
|
+
return {
|
|
2209
|
+
resumed: true,
|
|
2210
|
+
releasedBinding: false,
|
|
2211
|
+
redispatchReviewTaskId: state.taskId,
|
|
2212
|
+
reviewPassToken: boundTask.signalToken,
|
|
2213
|
+
heldLockToken: state.lockToken,
|
|
2214
|
+
projectId: state.projectId,
|
|
2215
|
+
previousPhase: state.awaitingPhase,
|
|
2216
|
+
};
|
|
2217
|
+
}
|
|
1864
2218
|
const reason = `Task ${state.taskId} was not dispatched because its checkout could not be prepared. ` +
|
|
1865
2219
|
'Fix the Workdir, then cancel and redispatch the task; Resume cannot reconstruct the original dispatch safely.';
|
|
1866
2220
|
console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — ${reason}`);
|
|
@@ -1959,7 +2313,7 @@ export class AgentManager {
|
|
|
1959
2313
|
if (result.redispatchCodeTaskId) {
|
|
1960
2314
|
try {
|
|
1961
2315
|
const task = await this.taskStore.get(result.redispatchCodeTaskId);
|
|
1962
|
-
const round = task
|
|
2316
|
+
const round = task ? (task.specReviewRound ?? 1) : undefined;
|
|
1963
2317
|
const stored = round === undefined
|
|
1964
2318
|
? null
|
|
1965
2319
|
: await this.reviewStore?.getRound(result.redispatchCodeTaskId, 'spec', round);
|
|
@@ -1973,6 +2327,12 @@ export class AgentManager {
|
|
|
1973
2327
|
await this.markAwaitingHuman(agentId, 'code-dispatch-failed', reason, { expectedTaskId: result.redispatchCodeTaskId }).catch(() => undefined);
|
|
1974
2328
|
return { resumed: false, releasedBinding: false, reason };
|
|
1975
2329
|
}
|
|
2330
|
+
// 重派与首派同样不经 startSession 的 post-ack finalize:残留的 bootstrap 标记
|
|
2331
|
+
// 会让下一次 recover 把已送达的 code prompt 再判成未送达
|
|
2332
|
+
if (!this.isResearchHandoff(task, agentId)) {
|
|
2333
|
+
const lockToken = (await this.agentStore.get(agentId))?.lockToken;
|
|
2334
|
+
await this.finalizeReplayedBootstrap(agentId, result.redispatchCodeTaskId, 'code', lockToken);
|
|
2335
|
+
}
|
|
1976
2336
|
}
|
|
1977
2337
|
catch (err) {
|
|
1978
2338
|
console.error(`[AgentManager] resumeAgent code redispatch failed for ${agentId}:`, err);
|
|
@@ -1981,6 +2341,126 @@ export class AgentManager {
|
|
|
1981
2341
|
return { resumed: false, releasedBinding: false, reason };
|
|
1982
2342
|
}
|
|
1983
2343
|
}
|
|
2344
|
+
if (result.releaseHeldQa) {
|
|
2345
|
+
const { taskId: heldTaskId, expectedHold, expectedTask } = result.releaseHeldQa;
|
|
2346
|
+
const released = await this.releaseAgentForTask(agentId, heldTaskId, 'idle', {
|
|
2347
|
+
allowAwaitingHuman: true,
|
|
2348
|
+
expectedHold,
|
|
2349
|
+
expectedTask,
|
|
2350
|
+
});
|
|
2351
|
+
if (!released) {
|
|
2352
|
+
const refreshed = await this.agentStore.get(agentId).catch((readErr) => {
|
|
2353
|
+
console.warn(`[AgentManager] resumeAgent post-release read failed for ${agentId}:`, readErr);
|
|
2354
|
+
return null;
|
|
2355
|
+
});
|
|
2356
|
+
return {
|
|
2357
|
+
resumed: false,
|
|
2358
|
+
releasedBinding: false,
|
|
2359
|
+
reason: refreshed?.awaitingReason
|
|
2360
|
+
?? `Agent ${agentId} could not safely release task ${heldTaskId}; its pass or hold moved on — re-check the agent state and Resume again.`,
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
await this.emitIntervention(result.projectId, agentId, heldTaskId, {
|
|
2364
|
+
phase: 'resumed',
|
|
2365
|
+
previousPhase: result.previousPhase,
|
|
2366
|
+
releasedBinding: true,
|
|
2367
|
+
});
|
|
2368
|
+
return { resumed: true, releasedBinding: true };
|
|
2369
|
+
}
|
|
2370
|
+
if (result.redispatchReviewTaskId) {
|
|
2371
|
+
try {
|
|
2372
|
+
await this.dispatchReviewToQa(result.redispatchReviewTaskId, {
|
|
2373
|
+
bumpRound: false,
|
|
2374
|
+
fromStatus: ['review'],
|
|
2375
|
+
...(result.reviewPassToken !== undefined ? { expectSignalToken: result.reviewPassToken } : {}),
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
catch (err) {
|
|
2379
|
+
console.error(`[AgentManager] resumeAgent review redispatch failed for ${agentId}:`, err);
|
|
2380
|
+
const reason = `QA review redispatch on Resume failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
2381
|
+
const reviewTaskId = result.redispatchReviewTaskId;
|
|
2382
|
+
// 锁代未变(未被 successor re-acquire)才允许补挂;markAwaitingHuman 内的 CAS 原子复核同一条件
|
|
2383
|
+
const restoreHoldIfStillOurs = async (phase, holdReason) => {
|
|
2384
|
+
const after = await this.agentStore.get(agentId);
|
|
2385
|
+
if (after?.taskId === reviewTaskId
|
|
2386
|
+
&& after.status !== 'awaiting_human'
|
|
2387
|
+
&& after.lockToken === result.heldLockToken) {
|
|
2388
|
+
await this.markAwaitingHuman(agentId, phase, holdReason, {
|
|
2389
|
+
expectedTaskId: reviewTaskId,
|
|
2390
|
+
...(result.heldLockToken !== undefined ? { expectedLockToken: result.heldLockToken } : {}),
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
};
|
|
2394
|
+
// handled 只代表 startSession 已尝试落 hold;落库可能已失败,必须核实并按需补挂
|
|
2395
|
+
if (err instanceof EnsureSessionError && err.partial.handled) {
|
|
2396
|
+
try {
|
|
2397
|
+
await restoreHoldIfStillOurs(result.previousPhase ?? 'checkout-preparation-failed', reason);
|
|
2398
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
2399
|
+
}
|
|
2400
|
+
catch (holdErr) {
|
|
2401
|
+
const holdMsg = holdErr instanceof Error ? holdErr.message : String(holdErr);
|
|
2402
|
+
console.error(`[AgentManager] resumeAgent could not verify/restore the handled hold for ${agentId}:`, holdErr);
|
|
2403
|
+
return {
|
|
2404
|
+
resumed: false,
|
|
2405
|
+
releasedBinding: false,
|
|
2406
|
+
reason: `${reason}; verifying/restoring the hold also failed: ${holdMsg} — the agent may be missing its awaiting_human flag, inspect it manually`,
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
// ack_unknown:prompt 可能已在跑,必须恢复 ack hold 的可见性,而非按普通失败补挂 checkout hold
|
|
2411
|
+
if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
|
|
2412
|
+
try {
|
|
2413
|
+
await restoreHoldIfStillOurs('dispatch-failed:ack_unknown', `${err.message}. Prompt may still be running in the pane; verify before resuming.`);
|
|
2414
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
2415
|
+
}
|
|
2416
|
+
catch (holdErr) {
|
|
2417
|
+
const holdMsg = holdErr instanceof Error ? holdErr.message : String(holdErr);
|
|
2418
|
+
console.error(`[AgentManager] resumeAgent could not restore the ack_unknown hold for ${agentId}:`, holdErr);
|
|
2419
|
+
return {
|
|
2420
|
+
resumed: false,
|
|
2421
|
+
releasedBinding: false,
|
|
2422
|
+
reason: `${reason}; restoring the dispatch-failed:ack_unknown hold also failed: ${holdMsg} — prompt state is unknown and the agent is NOT flagged awaiting_human, inspect it manually`,
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
// pass 被接管(token 轮换或任务离开 review)说明该 pass 已死,补挂会把新 pass 的 QA 打成 hold
|
|
2427
|
+
let passSuperseded = false;
|
|
2428
|
+
let recoveryReadFailed = false;
|
|
2429
|
+
try {
|
|
2430
|
+
const taskNow = await this.taskStore.get(reviewTaskId);
|
|
2431
|
+
passSuperseded = taskNow?.status !== 'review'
|
|
2432
|
+
|| (result.reviewPassToken !== undefined && taskNow.signalToken !== result.reviewPassToken);
|
|
2433
|
+
}
|
|
2434
|
+
catch (readErr) {
|
|
2435
|
+
recoveryReadFailed = true;
|
|
2436
|
+
console.error(`[AgentManager] resumeAgent recovery task read failed for ${agentId}:`, readErr);
|
|
2437
|
+
}
|
|
2438
|
+
if (passSuperseded)
|
|
2439
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
2440
|
+
try {
|
|
2441
|
+
if (recoveryReadFailed) {
|
|
2442
|
+
// 读失败时无法证明接管,优先恢复可见性:锁代/绑定 CAS 全部由 markAwaitingHuman 原子判定
|
|
2443
|
+
await this.markAwaitingHuman(agentId, result.previousPhase ?? 'checkout-preparation-failed', reason, {
|
|
2444
|
+
expectedTaskId: reviewTaskId,
|
|
2445
|
+
...(result.heldLockToken !== undefined ? { expectedLockToken: result.heldLockToken } : {}),
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2448
|
+
else {
|
|
2449
|
+
await restoreHoldIfStillOurs(result.previousPhase ?? 'checkout-preparation-failed', reason);
|
|
2450
|
+
}
|
|
2451
|
+
return { resumed: false, releasedBinding: false, reason };
|
|
2452
|
+
}
|
|
2453
|
+
catch (holdErr) {
|
|
2454
|
+
const holdMsg = holdErr instanceof Error ? holdErr.message : String(holdErr);
|
|
2455
|
+
console.error(`[AgentManager] resumeAgent could not restore the QA hold for ${agentId}:`, holdErr);
|
|
2456
|
+
return {
|
|
2457
|
+
resumed: false,
|
|
2458
|
+
releasedBinding: false,
|
|
2459
|
+
reason: `${reason}; restoring the hold also failed: ${holdMsg} — the agent is NOT flagged awaiting_human, inspect it manually`,
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
1984
2464
|
return {
|
|
1985
2465
|
resumed: result.resumed,
|
|
1986
2466
|
releasedBinding: result.releasedBinding,
|
|
@@ -2284,15 +2764,14 @@ export class AgentManager {
|
|
|
2284
2764
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
2285
2765
|
const runner = this.createRunnerFor(cfg);
|
|
2286
2766
|
const tmux = new TmuxManager(runner);
|
|
2287
|
-
const
|
|
2288
|
-
if (!
|
|
2767
|
+
const snapshot = await tmux.getSessionSnapshot(agentId);
|
|
2768
|
+
if (!snapshot) {
|
|
2289
2769
|
throw new Error(`restart-repl: tmux session ${agentId} does not exist; use retry to rebuild`);
|
|
2290
2770
|
}
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
throw new Error(`restart-repl: session claim mismatch (got "${claim ?? 'null'}"); refusing to touch foreign session`);
|
|
2771
|
+
if (snapshot.claim !== agentId) {
|
|
2772
|
+
throw new Error(`restart-repl: session claim mismatch (got "${snapshot.claim ?? 'null'}"); refusing to touch foreign session`);
|
|
2294
2773
|
}
|
|
2295
|
-
const paneId = await tmux.
|
|
2774
|
+
const paneId = await tmux.getSinglePaneIdByRef(snapshot.ref);
|
|
2296
2775
|
await tmux.sendKeysToPane(paneId, 'C-c');
|
|
2297
2776
|
const cmd = await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000 });
|
|
2298
2777
|
const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
|
|
@@ -2314,10 +2793,10 @@ export class AgentManager {
|
|
|
2314
2793
|
'use retry to rebuild the session safely');
|
|
2315
2794
|
}
|
|
2316
2795
|
await this.provisionRepoSkills(runner, cfg, workdir);
|
|
2317
|
-
await
|
|
2796
|
+
await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
|
|
2318
2797
|
const runtime = agentRuntimeKindFor(cfg);
|
|
2319
2798
|
const relaunch = async () => {
|
|
2320
|
-
await tmux.sendKeysToPane(paneId, `${
|
|
2799
|
+
await tmux.sendKeysToPane(paneId, `${launchCommandIn(workdir, cfg)}\n`);
|
|
2321
2800
|
await tmux.handleTrustDialog(paneId, runtime, {
|
|
2322
2801
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
2323
2802
|
});
|
|
@@ -2325,7 +2804,7 @@ export class AgentManager {
|
|
|
2325
2804
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
2326
2805
|
scrollback: 0,
|
|
2327
2806
|
});
|
|
2328
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
2807
|
+
await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
|
|
2329
2808
|
};
|
|
2330
2809
|
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
2331
2810
|
await this.agentStore.update(agentId, (state) => {
|
|
@@ -2338,41 +2817,339 @@ export class AgentManager {
|
|
|
2338
2817
|
};
|
|
2339
2818
|
});
|
|
2340
2819
|
}
|
|
2341
|
-
|
|
2820
|
+
// Holds only the pass captured in the task tuple: a successor pass (rotated token/phase/status) is never overwritten.
|
|
2821
|
+
async holdReplayFailureIfCurrent(agentId, taskAtEntry, holdPhase, reason, entryHold) {
|
|
2822
|
+
return this.withTaskLock(async () => {
|
|
2823
|
+
const fresh = await this.taskStore.get(taskAtEntry.id);
|
|
2824
|
+
if (fresh?.agentId !== agentId
|
|
2825
|
+
|| fresh.status !== taskAtEntry.status
|
|
2826
|
+
|| fresh.phase !== taskAtEntry.phase
|
|
2827
|
+
|| fresh.signalToken !== taskAtEntry.signalToken)
|
|
2828
|
+
return false;
|
|
2829
|
+
if (await this.markAwaitingHuman(agentId, holdPhase, reason, {
|
|
2830
|
+
expectedTaskId: taskAtEntry.id,
|
|
2831
|
+
expectedHold: entryHold,
|
|
2832
|
+
}))
|
|
2833
|
+
return true;
|
|
2834
|
+
// Entry hold gone (Resume raced): write over the empty generation, never over a foreign hold.
|
|
2835
|
+
if (await this.markAwaitingHuman(agentId, holdPhase, reason, {
|
|
2836
|
+
expectedTaskId: taskAtEntry.id,
|
|
2837
|
+
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
2838
|
+
}))
|
|
2839
|
+
return true;
|
|
2840
|
+
const now = await this.agentStore.get(agentId);
|
|
2841
|
+
return now?.status === 'awaiting_human';
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
async redispatchTaskPromptAfterReplRestart(agentId, taskId) {
|
|
2342
2845
|
const agent = this.getAgentConfig(agentId);
|
|
2343
2846
|
const task = await this.taskStore.get(taskId);
|
|
2344
|
-
if (!agent ||
|
|
2847
|
+
if (!agent || !task || !TASK_OWNER_ROLES.has(agent.role))
|
|
2848
|
+
return false;
|
|
2849
|
+
if (task.agentId !== agentId)
|
|
2345
2850
|
return false;
|
|
2346
|
-
|
|
2851
|
+
const participantId = agent.role === 'research' ? task.researchAgentId : task.devAgentId;
|
|
2852
|
+
if (participantId !== agentId)
|
|
2347
2853
|
return false;
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
const
|
|
2357
|
-
|
|
2358
|
-
|
|
2854
|
+
const bindingAtEntry = await this.agentStore.get(agentId);
|
|
2855
|
+
const entryHold = {
|
|
2856
|
+
phase: bindingAtEntry?.status === 'awaiting_human' ? bindingAtEntry.awaitingPhase : undefined,
|
|
2857
|
+
since: bindingAtEntry?.status === 'awaiting_human' ? bindingAtEntry.awaitingSince : undefined,
|
|
2858
|
+
nonce: bindingAtEntry?.status === 'awaiting_human' ? bindingAtEntry.awaitingNonce : undefined,
|
|
2859
|
+
};
|
|
2860
|
+
// Concurrent passes can advance the task mid-replay; every replay gate re-checks this snapshot.
|
|
2861
|
+
const taskStillCurrent = async () => {
|
|
2862
|
+
const fresh = await this.taskStore.get(taskId);
|
|
2863
|
+
return fresh?.agentId === agentId
|
|
2864
|
+
&& fresh.status === task.status
|
|
2865
|
+
&& fresh.phase === task.phase
|
|
2866
|
+
&& fresh.signalToken === task.signalToken;
|
|
2867
|
+
};
|
|
2868
|
+
// Hold ops share one task-mutation slot with a tuple re-check, and CAS on the entry hold generation.
|
|
2869
|
+
const clearEntryHold = async () => this.withTaskLock(async () => {
|
|
2870
|
+
if (!(await taskStillCurrent()))
|
|
2359
2871
|
return false;
|
|
2360
|
-
phase
|
|
2361
|
-
|
|
2362
|
-
|
|
2872
|
+
if (entryHold.phase === undefined) {
|
|
2873
|
+
const now = await this.agentStore.get(agentId);
|
|
2874
|
+
return now?.status !== 'awaiting_human';
|
|
2875
|
+
}
|
|
2876
|
+
return this.clearAwaitingHuman(agentId, { expectedHold: entryHold });
|
|
2877
|
+
});
|
|
2878
|
+
const holdReplayFailure = (phase, reason) => this.holdReplayFailureIfCurrent(agentId, task, phase, reason, entryHold);
|
|
2879
|
+
// skipSnapshot: stale scrollback literals belong to the aborted runtime; onlyReplaceOwnToken keeps a successor's watcher out of reach.
|
|
2880
|
+
const fencedReplay = (expectedKinds, token, stillCurrent) => {
|
|
2881
|
+
const abortStaleReplay = async () => {
|
|
2882
|
+
if (await stillCurrent())
|
|
2883
|
+
return true;
|
|
2884
|
+
this.phaseSignalWatcher?.stopIfToken(taskId, token);
|
|
2885
|
+
return false;
|
|
2886
|
+
};
|
|
2887
|
+
return {
|
|
2888
|
+
armBeforeInject: async () => {
|
|
2889
|
+
if (!(await stillCurrent()))
|
|
2890
|
+
return false;
|
|
2891
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
|
|
2892
|
+
skipSnapshot: true,
|
|
2893
|
+
onlyReplaceOwnToken: true,
|
|
2894
|
+
});
|
|
2895
|
+
if (!armed) {
|
|
2896
|
+
if (!(await stillCurrent()))
|
|
2897
|
+
return false;
|
|
2898
|
+
// Own-generation arm failure: throwing routes the caller into the resumable hold.
|
|
2899
|
+
throw new Error(`replay signal watcher failed to arm for task ${taskId}; Restart REPL again or cancel the task`);
|
|
2900
|
+
}
|
|
2901
|
+
return abortStaleReplay();
|
|
2902
|
+
},
|
|
2903
|
+
guardBeforeInject: abortStaleReplay,
|
|
2904
|
+
};
|
|
2905
|
+
};
|
|
2906
|
+
try {
|
|
2907
|
+
if (task.status === 'approved' && agent.role === 'dev') {
|
|
2908
|
+
return await this.replayApprovedHolderAfterReplRestart(agentId, task, taskStillCurrent, fencedReplay, { clearEntryHold, holdReplayFailure });
|
|
2909
|
+
}
|
|
2910
|
+
const mapped = this.mapTaskStateToExpectedWatcher(task);
|
|
2911
|
+
if (!mapped || mapped.agentId !== agentId)
|
|
2912
|
+
return false;
|
|
2913
|
+
if (!task.signalToken) {
|
|
2914
|
+
return await holdReplayFailure('restart-redispatch-failed', 'REPL restarted mid-pass but the task has no signal token to replay the prompt with; cancel the task or re-dispatch it.');
|
|
2915
|
+
}
|
|
2916
|
+
const signalToken = task.signalToken;
|
|
2917
|
+
const replayOpts = {
|
|
2918
|
+
signalToken,
|
|
2919
|
+
preserveDispatchOutputs: true,
|
|
2920
|
+
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
2921
|
+
...fencedReplay(mapped.expectedKinds, signalToken, taskStillCurrent),
|
|
2922
|
+
};
|
|
2923
|
+
const replayInitialBootstrap = async (phase, opts) => {
|
|
2924
|
+
// This hold means the prompt WAS delivered and only the marker clear failed; replaying would run it twice.
|
|
2925
|
+
if (entryHold.phase === 'bootstrap-marker-clear-failed') {
|
|
2926
|
+
return holdReplayFailure('restart-redispatch-failed', 'REPL restarted while the initial prompt was already delivered (its bootstrap marker clear had failed); replaying would dispatch the task twice. Resume and verify the Workdir, or cancel the task.');
|
|
2927
|
+
}
|
|
2928
|
+
const bindingBefore = await this.agentStore.get(agentId);
|
|
2929
|
+
if (!(await clearEntryHold()))
|
|
2930
|
+
return false;
|
|
2931
|
+
const resumed = await this.continueSession(taskId, agentId, phase, opts);
|
|
2932
|
+
if (resumed && bindingBefore?.bootstrappingTaskId === taskId) {
|
|
2933
|
+
await this.finalizeReplayedBootstrap(agentId, taskId, phase, bindingBefore.lockToken);
|
|
2934
|
+
}
|
|
2935
|
+
return resumed;
|
|
2936
|
+
};
|
|
2937
|
+
if (task.status === 'in_progress' && task.phase === 'research' && agent.role === 'research') {
|
|
2938
|
+
return await replayInitialBootstrap('research', replayOpts);
|
|
2939
|
+
}
|
|
2940
|
+
if (task.status === 'in_progress' && task.phase === undefined && agent.role === 'dev') {
|
|
2941
|
+
// An undelivered bootstrap never started work, so the fresh-dispatch clean gate still applies.
|
|
2942
|
+
const delivered = (await this.agentStore.get(agentId))?.bootstrappingTaskId !== taskId;
|
|
2943
|
+
return await replayInitialBootstrap('develop', {
|
|
2944
|
+
...replayOpts,
|
|
2945
|
+
...(delivered ? { allowDirtyWorkdir: true } : {}),
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
if (task.status === 'in_progress' && task.phase === 'code' && agent.role === 'dev') {
|
|
2949
|
+
const state = await this.agentStore.get(agentId);
|
|
2950
|
+
// A no-QA auto-approved spec never writes specReviewRound back; the code dispatch defaults to round 1.
|
|
2951
|
+
const round = task.specReviewRound ?? 1;
|
|
2952
|
+
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
2953
|
+
if (state?.bootstrappingTaskId === taskId || !stored || stored.phase !== 'spec') {
|
|
2954
|
+
return await holdReplayFailure('code-dispatch-failed', 'REPL restarted but the code-phase prompt cannot be resumed in place. Resume to replay the persisted Spec documents, or cancel the task.');
|
|
2955
|
+
}
|
|
2956
|
+
if (!(await clearEntryHold()))
|
|
2957
|
+
return false;
|
|
2958
|
+
return await this.continueSession(taskId, agentId, 'code', {
|
|
2959
|
+
...replayOpts,
|
|
2960
|
+
allowDirtyWorkdir: true,
|
|
2961
|
+
specDocuments: stored.documents,
|
|
2962
|
+
...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
if (task.status === 'fixing' && task.phase === 'spec') {
|
|
2966
|
+
const round = task.specReviewRound ?? 1;
|
|
2967
|
+
const stored = await this.reviewStore?.getRound(taskId, 'spec', round);
|
|
2968
|
+
if (!stored?.findings) {
|
|
2969
|
+
return await holdReplayFailure('restart-redispatch-failed', `REPL restarted during the spec fixing round but round ${round} findings are not persisted; the feedback prompt cannot be replayed. Cancel the task or re-run the spec review.`);
|
|
2970
|
+
}
|
|
2971
|
+
if (!(await clearEntryHold()))
|
|
2972
|
+
return false;
|
|
2973
|
+
return await this.continueSession(taskId, agentId, 'server-feedback', {
|
|
2974
|
+
...replayOpts,
|
|
2975
|
+
...(agent.role === 'dev' ? { allowDirtyWorkdir: true } : {}),
|
|
2976
|
+
serverPriorFindings: JSON.stringify(stored.findings),
|
|
2977
|
+
...(task.specReviewRound !== undefined ? { currentSpecRound: task.specReviewRound } : {}),
|
|
2978
|
+
});
|
|
2979
|
+
}
|
|
2980
|
+
// A direct (no-SDD) pass never persists a phase, so fixing + undefined is the plain code-feedback round.
|
|
2981
|
+
if (task.status === 'fixing'
|
|
2982
|
+
&& (task.phase === 'code' || task.phase === undefined)
|
|
2983
|
+
&& agent.role === 'dev') {
|
|
2984
|
+
if (task.reviewMode === 'server') {
|
|
2985
|
+
const round = Math.max(task.reviewRound, 1);
|
|
2986
|
+
const stored = await this.reviewStore?.getRound(taskId, 'code', round);
|
|
2987
|
+
if (!stored?.findings) {
|
|
2988
|
+
return await holdReplayFailure('restart-redispatch-failed', `REPL restarted during the code fixing round but round ${round} findings are not persisted; the feedback prompt cannot be replayed. Cancel the task or re-run the code review.`);
|
|
2989
|
+
}
|
|
2990
|
+
if (!(await clearEntryHold()))
|
|
2991
|
+
return false;
|
|
2992
|
+
return await this.continueSession(taskId, agentId, 'server-feedback', {
|
|
2993
|
+
...replayOpts,
|
|
2994
|
+
allowDirtyWorkdir: true,
|
|
2995
|
+
serverPriorFindings: JSON.stringify(stored.findings),
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
2998
|
+
if (!(await clearEntryHold()))
|
|
2999
|
+
return false;
|
|
3000
|
+
return await this.continueSession(taskId, agentId, 'fix', {
|
|
3001
|
+
...replayOpts,
|
|
3002
|
+
allowDirtyWorkdir: true,
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
return false;
|
|
3006
|
+
}
|
|
3007
|
+
catch (err) {
|
|
3008
|
+
// A post-entry throw may land after clearEntryHold; only a tuple-checked hold on the live generation is safe.
|
|
3009
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3010
|
+
if (await holdReplayFailure('restart-redispatch-failed', `REPL restarted but replaying the task prompt failed: ${message}. Resume to retry, or cancel the task.`)) {
|
|
3011
|
+
return true;
|
|
3012
|
+
}
|
|
3013
|
+
throw err;
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
// approved hosts two live holder passes — GitHub post-approve (PostApproveStore) and server after-done (task fields); a parked pre-publish holder stays on the waiting path.
|
|
3017
|
+
async replayApprovedHolderAfterReplRestart(agentId, task, taskStillCurrent, fencedReplay, holdGen) {
|
|
3018
|
+
const taskId = task.id;
|
|
3019
|
+
const { clearEntryHold, holdReplayFailure } = holdGen;
|
|
3020
|
+
if (task.reviewMode === 'server') {
|
|
3021
|
+
if (!task.publishDispatchedAt)
|
|
3022
|
+
return false;
|
|
3023
|
+
if (!task.signalToken) {
|
|
3024
|
+
return holdReplayFailure('restart-redispatch-failed', 'REPL restarted mid-publish but the rotated publish token is missing; the publish prompt cannot be replayed. Cancel the task or mark-complete to re-dispatch the publish.');
|
|
3025
|
+
}
|
|
3026
|
+
const token = task.signalToken;
|
|
3027
|
+
const afterDone = this.resolveAfterDone(task);
|
|
3028
|
+
if (!afterDone) {
|
|
3029
|
+
return holdReplayFailure('restart-redispatch-failed', 'REPL restarted mid-publish but the after-done target cannot be resolved anymore. Resume then mark-complete to retry the publish, or cancel the task.');
|
|
3030
|
+
}
|
|
3031
|
+
let violation;
|
|
3032
|
+
try {
|
|
3033
|
+
violation = await this.findLineageViolation(taskId);
|
|
3034
|
+
}
|
|
3035
|
+
catch (err) {
|
|
3036
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3037
|
+
return holdReplayFailure('restart-redispatch-failed', `REPL restarted mid-publish but the branch lineage re-check failed: ${message}. Resume then Restart REPL to retry, or cancel the task.`);
|
|
3038
|
+
}
|
|
3039
|
+
if (violation) {
|
|
3040
|
+
return holdReplayFailure('restart-redispatch-failed', `The task branch embeds another active task's commits (${violation.branch}); rebase onto origin/HEAD, then mark-complete to retry the publish.`);
|
|
3041
|
+
}
|
|
3042
|
+
if (!(await clearEntryHold()))
|
|
3043
|
+
return false;
|
|
3044
|
+
return this.continueSession(taskId, agentId, 'server-after-done', {
|
|
3045
|
+
signalToken: token,
|
|
3046
|
+
preserveDispatchOutputs: true,
|
|
3047
|
+
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
3048
|
+
allowDirtyWorkdir: true,
|
|
3049
|
+
serverAfterDone: { kind: afterDone, branch: task.branch ?? BRANCH_PREFIX + taskId },
|
|
3050
|
+
...fencedReplay(['code-ready'], token, taskStillCurrent),
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
if (task.postApproveRevoked) {
|
|
3054
|
+
return holdReplayFailure('restart-redispatch-failed', `The post-approve completion was deliberately revoked (${task.postApproveRevoked.reason}); ` +
|
|
3055
|
+
'the block stays effective, so the pass is not replayed. Re-request changes to restart the review round, or cancel the task.');
|
|
3056
|
+
}
|
|
3057
|
+
const stored = await this.getPostApproveCompletion(taskId);
|
|
3058
|
+
let completion;
|
|
3059
|
+
if (stored) {
|
|
3060
|
+
if (!task.postApproveHeadSha) {
|
|
3061
|
+
return holdReplayFailure('restart-redispatch-failed', 'REPL restarted during the post-approve pass but the stored completion cannot be tied to a persisted approved head; re-request the review verdict or cancel the task.');
|
|
3062
|
+
}
|
|
3063
|
+
if (stored.approvedHeadSha !== task.postApproveHeadSha) {
|
|
3064
|
+
// Provably a dead episode's residue: retire it by token, then rebuild for the current head.
|
|
3065
|
+
await this.clearPostApproveCompletionIfMatches(taskId, stored.token);
|
|
3066
|
+
completion = await this.rebuildPostApproveCompletion(agentId, task, holdReplayFailure);
|
|
3067
|
+
}
|
|
3068
|
+
else {
|
|
3069
|
+
completion = { token: stored.token, rebuilt: false };
|
|
3070
|
+
}
|
|
2363
3071
|
}
|
|
2364
3072
|
else {
|
|
3073
|
+
completion = await this.rebuildPostApproveCompletion(agentId, task, holdReplayFailure);
|
|
3074
|
+
}
|
|
3075
|
+
if (completion === 'held')
|
|
3076
|
+
return true;
|
|
3077
|
+
if (completion === 'stale')
|
|
3078
|
+
return false;
|
|
3079
|
+
const resolvedCompletion = completion;
|
|
3080
|
+
const completionStillCurrent = async () => {
|
|
3081
|
+
const fresh = await this.taskStore.get(taskId);
|
|
3082
|
+
return fresh?.agentId === agentId
|
|
3083
|
+
&& fresh.status === task.status
|
|
3084
|
+
&& fresh.phase === task.phase
|
|
3085
|
+
&& fresh.signalToken === task.signalToken
|
|
3086
|
+
&& fresh.postApproveRevoked === undefined
|
|
3087
|
+
&& fresh.postApproveHeadSha === task.postApproveHeadSha
|
|
3088
|
+
&& (await this.getPostApproveCompletion(taskId))?.token === resolvedCompletion.token;
|
|
3089
|
+
};
|
|
3090
|
+
if (!(await completionStillCurrent()))
|
|
3091
|
+
return false;
|
|
3092
|
+
if (!(await clearEntryHold()))
|
|
2365
3093
|
return false;
|
|
3094
|
+
let resumed = false;
|
|
3095
|
+
try {
|
|
3096
|
+
resumed = await this.continueSession(taskId, agentId, 'post-approve', {
|
|
3097
|
+
signalToken: completion.token,
|
|
3098
|
+
preserveDispatchOutputs: true,
|
|
3099
|
+
expectedHold: { phase: undefined, since: undefined, nonce: undefined },
|
|
3100
|
+
allowDirtyWorkdir: true,
|
|
3101
|
+
...fencedReplay(['pr-merge-ready'], completion.token, completionStillCurrent),
|
|
3102
|
+
});
|
|
2366
3103
|
}
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
3104
|
+
catch (err) {
|
|
3105
|
+
if (completion.rebuilt)
|
|
3106
|
+
await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
|
|
3107
|
+
throw err;
|
|
3108
|
+
}
|
|
3109
|
+
if (!resumed && completion.rebuilt) {
|
|
3110
|
+
await this.clearPostApproveCompletionIfMatches(taskId, completion.token);
|
|
3111
|
+
const fresh = await this.taskStore.get(taskId);
|
|
3112
|
+
const episodeIntact = fresh?.agentId === agentId
|
|
3113
|
+
&& fresh.status === task.status
|
|
3114
|
+
&& fresh.phase === task.phase
|
|
3115
|
+
&& fresh.signalToken === task.signalToken
|
|
3116
|
+
&& fresh.postApproveRevoked === undefined
|
|
3117
|
+
&& fresh.postApproveHeadSha === task.postApproveHeadSha;
|
|
3118
|
+
if (!episodeIntact)
|
|
3119
|
+
return false;
|
|
3120
|
+
return holdReplayFailure('restart-redispatch-failed', 'REPL restarted during the post-approve pass and the rebuilt prompt could not be delivered; Resume then Restart REPL to retry, or cancel the task.');
|
|
3121
|
+
}
|
|
3122
|
+
return resumed;
|
|
3123
|
+
}
|
|
3124
|
+
// A bare hold would loop (Resume clears it, the next restart misses the record again), so rebuild from the persisted approved head.
|
|
3125
|
+
async rebuildPostApproveCompletion(agentId, task, holdReplayFailure) {
|
|
3126
|
+
// latestHeadSha drifts with pushes/mismatch probes; only the head persisted at dispatch is provably approved.
|
|
3127
|
+
const approvedHeadSha = task.postApproveHeadSha;
|
|
3128
|
+
if (!approvedHeadSha) {
|
|
3129
|
+
const held = await holdReplayFailure('restart-redispatch-failed', 'REPL restarted during the post-approve pass but neither its completion context nor a provably approved head are persisted; cancel the task or re-request the review verdict.');
|
|
3130
|
+
return held ? 'held' : 'stale';
|
|
3131
|
+
}
|
|
3132
|
+
const token = createSignalToken();
|
|
3133
|
+
// Conditional create: the in-lock re-read rejects any revoke/dispatch landed since the snapshot.
|
|
3134
|
+
const created = await this.withTaskLock(async () => {
|
|
3135
|
+
const fresh = await this.taskStore.get(task.id);
|
|
3136
|
+
if (!fresh
|
|
3137
|
+
|| fresh.agentId !== agentId
|
|
3138
|
+
|| fresh.status !== 'approved'
|
|
3139
|
+
|| fresh.phase !== task.phase
|
|
3140
|
+
|| fresh.signalToken !== task.signalToken
|
|
3141
|
+
|| fresh.postApproveRevoked !== undefined
|
|
3142
|
+
|| fresh.postApproveHeadSha !== approvedHeadSha) {
|
|
3143
|
+
return false;
|
|
3144
|
+
}
|
|
3145
|
+
if (await this.postApproveStore.get(task.id))
|
|
3146
|
+
return false;
|
|
3147
|
+
await this.postApproveStore.set(task.id, { token, approvedHeadSha });
|
|
3148
|
+
return true;
|
|
2375
3149
|
});
|
|
3150
|
+
if (!created)
|
|
3151
|
+
return 'stale';
|
|
3152
|
+
return { token, rebuilt: true };
|
|
2376
3153
|
}
|
|
2377
3154
|
prepareRemoveTargets(agentId) {
|
|
2378
3155
|
const cfg = this.getAgentConfig(agentId);
|
|
@@ -2419,17 +3196,23 @@ export class AgentManager {
|
|
|
2419
3196
|
}
|
|
2420
3197
|
}
|
|
2421
3198
|
try {
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
}
|
|
2428
|
-
else {
|
|
3199
|
+
await this.runUnderSessionLifecycle(id, async () => {
|
|
3200
|
+
const snapshot = await tmux.getSessionSnapshot(id);
|
|
3201
|
+
if (!snapshot)
|
|
3202
|
+
return;
|
|
3203
|
+
if (snapshot.claim !== id) {
|
|
2429
3204
|
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
|
|
2430
|
-
`(claim=${claim ?? 'null'}; not baxian-managed)`);
|
|
3205
|
+
`(claim=${snapshot.claim ?? 'null'}; not baxian-managed)`);
|
|
3206
|
+
return;
|
|
2431
3207
|
}
|
|
2432
|
-
|
|
3208
|
+
const outcome = await tmux.killSessionRef(snapshot.ref);
|
|
3209
|
+
if (outcome === 'stale-server') {
|
|
3210
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: tmux server generation changed for ${id} — leaving session untouched`);
|
|
3211
|
+
}
|
|
3212
|
+
else if (outcome === 'killed') {
|
|
3213
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
|
|
3214
|
+
}
|
|
3215
|
+
});
|
|
2433
3216
|
}
|
|
2434
3217
|
catch (err) {
|
|
2435
3218
|
failures.push({ agentId: id, step: 'tmux', error: err });
|
|
@@ -2546,14 +3329,27 @@ export class AgentManager {
|
|
|
2546
3329
|
async getPostApproveCompletion(taskId) {
|
|
2547
3330
|
return this.postApproveStore.get(taskId);
|
|
2548
3331
|
}
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
if (!this.phaseSignalWatcher)
|
|
2553
|
-
return;
|
|
3332
|
+
// Install CASes on the current approved episode; only a fresh human verdict (clearRevocation) unlocks a standing marker.
|
|
3333
|
+
async setPostApproveCompletion(taskId, value, opts = {}) {
|
|
3334
|
+
return this.withTaskLock(async () => {
|
|
2554
3335
|
const task = await this.taskStore.get(taskId);
|
|
3336
|
+
if (task) {
|
|
3337
|
+
if (task.status !== 'approved')
|
|
3338
|
+
return false;
|
|
3339
|
+
if (task.postApproveRevoked && !opts.clearRevocation)
|
|
3340
|
+
return false;
|
|
3341
|
+
if (task.postApproveHeadSha !== undefined && task.postApproveHeadSha !== value.approvedHeadSha)
|
|
3342
|
+
return false;
|
|
3343
|
+
}
|
|
3344
|
+
await this.postApproveStore.set(taskId, value);
|
|
2555
3345
|
if (!task)
|
|
2556
|
-
return;
|
|
3346
|
+
return true;
|
|
3347
|
+
if (task.postApproveRevoked) {
|
|
3348
|
+
const { postApproveRevoked: _revoked, ...rest } = task;
|
|
3349
|
+
await this.taskStore.set({ ...rest, updatedAt: new Date().toISOString() });
|
|
3350
|
+
}
|
|
3351
|
+
if (!this.phaseSignalWatcher)
|
|
3352
|
+
return true;
|
|
2557
3353
|
await this.startPhaseSignalWatch({
|
|
2558
3354
|
taskId,
|
|
2559
3355
|
projectId: task.projectId,
|
|
@@ -2561,6 +3357,7 @@ export class AgentManager {
|
|
|
2561
3357
|
expectedKinds: 'pr-merge-ready',
|
|
2562
3358
|
token: value.token,
|
|
2563
3359
|
});
|
|
3360
|
+
return true;
|
|
2564
3361
|
});
|
|
2565
3362
|
}
|
|
2566
3363
|
async clearPostApproveCompletion(taskId) {
|
|
@@ -2569,6 +3366,46 @@ export class AgentManager {
|
|
|
2569
3366
|
this.phaseSignalWatcher?.stop(taskId);
|
|
2570
3367
|
});
|
|
2571
3368
|
}
|
|
3369
|
+
// CAS for an existing pass: never creates a record nor touches the marker, so it cannot resurrect a revoked completion.
|
|
3370
|
+
async updatePostApproveCompletionIfToken(taskId, expectedToken, patch) {
|
|
3371
|
+
return this.withTaskLock(async () => {
|
|
3372
|
+
const task = await this.taskStore.get(taskId);
|
|
3373
|
+
if (task?.postApproveRevoked)
|
|
3374
|
+
return false;
|
|
3375
|
+
const current = await this.postApproveStore.get(taskId);
|
|
3376
|
+
if (!current || current.token !== expectedToken)
|
|
3377
|
+
return false;
|
|
3378
|
+
await this.postApproveStore.set(taskId, { ...current, ...patch });
|
|
3379
|
+
return true;
|
|
3380
|
+
});
|
|
3381
|
+
}
|
|
3382
|
+
// Deliberate clear: the persisted marker keeps a restart replay from rebuilding the completion and bypassing the block.
|
|
3383
|
+
async revokePostApproveCompletion(taskId, reason, opts = {}) {
|
|
3384
|
+
return this.withTaskLock(async () => {
|
|
3385
|
+
const task = await this.taskStore.get(taskId);
|
|
3386
|
+
if (!task || task.status !== 'approved')
|
|
3387
|
+
return false;
|
|
3388
|
+
// Head binds the revoke to one approved episode; an unpersisted head cannot disprove it, so blocking stays fail closed.
|
|
3389
|
+
if (opts.expectedHeadSha !== undefined
|
|
3390
|
+
&& task.postApproveHeadSha !== undefined
|
|
3391
|
+
&& task.postApproveHeadSha !== opts.expectedHeadSha)
|
|
3392
|
+
return false;
|
|
3393
|
+
if (opts.expectedToken !== undefined) {
|
|
3394
|
+
const current = await this.postApproveStore.get(taskId);
|
|
3395
|
+
if (current?.token !== opts.expectedToken)
|
|
3396
|
+
return false;
|
|
3397
|
+
}
|
|
3398
|
+
// Marker lands first: a crash before the clear leaves marker+completion, which every consumer fences on.
|
|
3399
|
+
await this.taskStore.set({
|
|
3400
|
+
...task,
|
|
3401
|
+
postApproveRevoked: { reason, at: new Date().toISOString() },
|
|
3402
|
+
updatedAt: new Date().toISOString(),
|
|
3403
|
+
});
|
|
3404
|
+
await this.postApproveStore.clear(taskId);
|
|
3405
|
+
this.phaseSignalWatcher?.stop(taskId);
|
|
3406
|
+
return true;
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
2572
3409
|
async clearPostApproveCompletionIfMatches(taskId, token) {
|
|
2573
3410
|
return this.withTaskLock(async () => {
|
|
2574
3411
|
const cleared = await this.postApproveStore.clearIfMatches(taskId, token);
|
|
@@ -2577,14 +3414,67 @@ export class AgentManager {
|
|
|
2577
3414
|
return cleared;
|
|
2578
3415
|
});
|
|
2579
3416
|
}
|
|
3417
|
+
// Marker/token/head/pending are re-checked inside the committing critical section, so a concurrent revoke never loses.
|
|
3418
|
+
async completeApprovedPassToMergeReady(taskId, expectedToken) {
|
|
3419
|
+
return this.withTaskLock(async () => {
|
|
3420
|
+
const task = await this.taskStore.get(taskId);
|
|
3421
|
+
if (!task || task.status !== 'approved' || task.postApproveRevoked) {
|
|
3422
|
+
return { refused: 'stale' };
|
|
3423
|
+
}
|
|
3424
|
+
const completion = await this.postApproveStore.get(taskId);
|
|
3425
|
+
if (!completion
|
|
3426
|
+
|| completion.token !== expectedToken
|
|
3427
|
+
|| task.postApproveHeadSha === undefined
|
|
3428
|
+
|| completion.approvedHeadSha !== task.postApproveHeadSha) {
|
|
3429
|
+
return { refused: 'stale' };
|
|
3430
|
+
}
|
|
3431
|
+
if (completion.pendingRedispatch)
|
|
3432
|
+
return { refused: 'pending' };
|
|
3433
|
+
delete task.postApproveHeadSha;
|
|
3434
|
+
Object.assign(task, {
|
|
3435
|
+
status: 'merge-ready',
|
|
3436
|
+
latestHeadSha: completion.approvedHeadSha,
|
|
3437
|
+
updatedAt: new Date().toISOString(),
|
|
3438
|
+
});
|
|
3439
|
+
await this.taskStore.set(task);
|
|
3440
|
+
await this.postApproveStore.clear(taskId);
|
|
3441
|
+
this.phaseSignalWatcher?.stop(taskId);
|
|
3442
|
+
return { task };
|
|
3443
|
+
});
|
|
3444
|
+
}
|
|
2580
3445
|
async setupRecoveredPostApproveSignals() {
|
|
2581
|
-
if (!this.phaseSignalWatcher)
|
|
2582
|
-
return;
|
|
2583
3446
|
const tasks = await this.taskStore.list({ status: 'approved' });
|
|
2584
3447
|
for (const task of tasks) {
|
|
3448
|
+
if (task.postApproveRevoked) {
|
|
3449
|
+
// Finish an interrupted revoke: the marker is authoritative, the leftover record must not re-arm.
|
|
3450
|
+
try {
|
|
3451
|
+
await this.postApproveStore.clear(task.id);
|
|
3452
|
+
}
|
|
3453
|
+
catch (err) {
|
|
3454
|
+
console.warn(`[AgentManager] setupRecoveredPostApproveSignals: revoked completion cleanup failed for task=${task.id}:`, err);
|
|
3455
|
+
}
|
|
3456
|
+
continue;
|
|
3457
|
+
}
|
|
2585
3458
|
const completion = await this.postApproveStore.get(task.id);
|
|
2586
3459
|
if (!completion)
|
|
2587
3460
|
continue;
|
|
3461
|
+
if (task.postApproveHeadSha === undefined) {
|
|
3462
|
+
// A completion the task head cannot vouch for is another episode's residue until proven otherwise.
|
|
3463
|
+
console.warn(`[AgentManager] setupRecoveredPostApproveSignals: task=${task.id} has a stored completion but no persisted approved head; not re-arming`);
|
|
3464
|
+
continue;
|
|
3465
|
+
}
|
|
3466
|
+
if (completion.approvedHeadSha !== task.postApproveHeadSha) {
|
|
3467
|
+
try {
|
|
3468
|
+
await this.postApproveStore.clear(task.id);
|
|
3469
|
+
console.warn(`[AgentManager] setupRecoveredPostApproveSignals: task=${task.id} retired a completion bound to head ${completion.approvedHeadSha} (persisted approved head ${task.postApproveHeadSha})`);
|
|
3470
|
+
}
|
|
3471
|
+
catch (err) {
|
|
3472
|
+
console.warn(`[AgentManager] setupRecoveredPostApproveSignals: stale completion cleanup failed for task=${task.id}:`, err);
|
|
3473
|
+
}
|
|
3474
|
+
continue;
|
|
3475
|
+
}
|
|
3476
|
+
if (!this.phaseSignalWatcher)
|
|
3477
|
+
continue;
|
|
2588
3478
|
try {
|
|
2589
3479
|
await this.startPhaseSignalWatch({
|
|
2590
3480
|
taskId: task.id,
|
|
@@ -2761,6 +3651,20 @@ export class AgentManager {
|
|
|
2761
3651
|
task.updatedAt = new Date().toISOString();
|
|
2762
3652
|
await this.taskStore.set(task);
|
|
2763
3653
|
}
|
|
3654
|
+
async updateTaskIfStatus(taskId, expectedStatus, updates, alsoExpect = {}) {
|
|
3655
|
+
return this.withTaskLock(async () => {
|
|
3656
|
+
const task = await this.taskStore.get(taskId);
|
|
3657
|
+
if (!task || task.status !== expectedStatus)
|
|
3658
|
+
return false;
|
|
3659
|
+
if ('signalToken' in alsoExpect && task.signalToken !== alsoExpect.signalToken)
|
|
3660
|
+
return false;
|
|
3661
|
+
if ('reviewRound' in alsoExpect && task.reviewRound !== alsoExpect.reviewRound)
|
|
3662
|
+
return false;
|
|
3663
|
+
Object.assign(task, updates, { updatedAt: new Date().toISOString() });
|
|
3664
|
+
await this.taskStore.set(task);
|
|
3665
|
+
return true;
|
|
3666
|
+
});
|
|
3667
|
+
}
|
|
2764
3668
|
async updateTask(taskId, updates) {
|
|
2765
3669
|
await this.withTaskLock(async () => {
|
|
2766
3670
|
const task = await this.taskStore.get(taskId);
|
|
@@ -2780,11 +3684,18 @@ export class AgentManager {
|
|
|
2780
3684
|
return null;
|
|
2781
3685
|
if (!guard.fromStatus.includes(previousStatus))
|
|
2782
3686
|
return null;
|
|
3687
|
+
if (guard.expectSignalToken !== undefined && task.signalToken !== guard.expectSignalToken)
|
|
3688
|
+
return null;
|
|
2783
3689
|
if (patch?.branch && patch.branch !== task.branch) {
|
|
2784
3690
|
const existing = await this.findTaskByBranch(patch.branch, task.projectId);
|
|
2785
3691
|
if (existing && existing.id !== taskId)
|
|
2786
3692
|
return null;
|
|
2787
3693
|
}
|
|
3694
|
+
// The revocation marker and approved head scope to the approved episode they were created in.
|
|
3695
|
+
if (previousStatus === 'approved' && toStatus !== 'approved') {
|
|
3696
|
+
delete task.postApproveRevoked;
|
|
3697
|
+
delete task.postApproveHeadSha;
|
|
3698
|
+
}
|
|
2788
3699
|
Object.assign(task, patch ?? {}, {
|
|
2789
3700
|
status: toStatus,
|
|
2790
3701
|
updatedAt: new Date().toISOString(),
|
|
@@ -3543,18 +4454,12 @@ export class AgentManager {
|
|
|
3543
4454
|
const stillOwner = stateNow?.taskId === taskId
|
|
3544
4455
|
&& stateNow.lockToken === lockToken
|
|
3545
4456
|
&& await this.lockManager.isOwner(agentId, taskId, lockToken);
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
const runner = this.createRunnerFor(agent);
|
|
3549
|
-
await new TmuxManager(runner).killSession(agentId);
|
|
3550
|
-
}
|
|
3551
|
-
else {
|
|
3552
|
-
console.warn(`[AgentManager] startSession ensureSession rollback skipped killSession for stale owner ` +
|
|
3553
|
-
`${agentId}/${taskId}`);
|
|
3554
|
-
}
|
|
4457
|
+
if (stillOwner) {
|
|
4458
|
+
await this.rollbackCreatedSession(agentId, err.partial, 'startSession ensure rollback');
|
|
3555
4459
|
}
|
|
3556
|
-
|
|
3557
|
-
console.warn(`[AgentManager] startSession ensureSession rollback
|
|
4460
|
+
else {
|
|
4461
|
+
console.warn(`[AgentManager] startSession ensureSession rollback skipped for stale owner ` +
|
|
4462
|
+
`${agentId}/${taskId}`);
|
|
3558
4463
|
}
|
|
3559
4464
|
}
|
|
3560
4465
|
throw err;
|
|
@@ -3595,13 +4500,17 @@ export class AgentManager {
|
|
|
3595
4500
|
else {
|
|
3596
4501
|
if (!task.branch)
|
|
3597
4502
|
throw new Error(`Task ${taskId} has no task branch`);
|
|
3598
|
-
await branchManager.switchToTaskBranch(workdir, taskId, task.branch, task.branchCreatedByBaxian === true
|
|
4503
|
+
await branchManager.switchToTaskBranch(workdir, taskId, task.branch, task.branchCreatedByBaxian === true, task.branchLocalCleaned
|
|
4504
|
+
? { restorableRemoteTip: task.branchLocalCleaned.remoteTipSha }
|
|
4505
|
+
: {});
|
|
4506
|
+
if (task.branchLocalCleaned)
|
|
4507
|
+
await this.clearBranchLocalCleaned(taskId);
|
|
3599
4508
|
}
|
|
3600
4509
|
await assertOwner();
|
|
3601
4510
|
if (!ensure.freshRuntime) {
|
|
3602
4511
|
await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
|
|
3603
4512
|
if (ensure.skillsStale)
|
|
3604
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
4513
|
+
await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
|
|
3605
4514
|
}
|
|
3606
4515
|
}
|
|
3607
4516
|
catch (err) {
|
|
@@ -3840,7 +4749,7 @@ export class AgentManager {
|
|
|
3840
4749
|
throw err;
|
|
3841
4750
|
}
|
|
3842
4751
|
}
|
|
3843
|
-
async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime) {
|
|
4752
|
+
async injectAndAwaitAck(tmux, paneId, prompt, agentId, runtime, guardBeforePaste) {
|
|
3844
4753
|
const before = await this.agentStore.get(agentId);
|
|
3845
4754
|
const beforeLockToken = before?.taskId
|
|
3846
4755
|
? await this.resolveTaskLockToken(before, before.taskId)
|
|
@@ -3889,13 +4798,16 @@ export class AgentManager {
|
|
|
3889
4798
|
}
|
|
3890
4799
|
: undefined;
|
|
3891
4800
|
await revalidate?.();
|
|
3892
|
-
|
|
4801
|
+
if (guardBeforePaste && !(await guardBeforePaste())) {
|
|
4802
|
+
return { acked: false, composerDelivered: false, aborted: true };
|
|
4803
|
+
}
|
|
4804
|
+
return await this.injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste);
|
|
3893
4805
|
}
|
|
3894
4806
|
finally {
|
|
3895
4807
|
this.compactInFlight.delete(agentId);
|
|
3896
4808
|
}
|
|
3897
4809
|
}
|
|
3898
|
-
async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate) {
|
|
4810
|
+
async injectAndAwaitAckSteps(tmux, paneId, prompt, agentId, runtime, revalidate, guardBeforePaste) {
|
|
3899
4811
|
// 静态忙信号(文本忙样式/working title)可能是残稿或 stale title 冒充,一律由帧活性仲裁;ready 视图覆盖 stale title。
|
|
3900
4812
|
const preTitle = await tmux.readPaneTitle(paneId);
|
|
3901
4813
|
const preFrame = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
|
|
@@ -3905,15 +4817,87 @@ export class AgentManager {
|
|
|
3905
4817
|
if (staticBusy && await this.paneHasLiveTurn(tmux, paneId, runtime)) {
|
|
3906
4818
|
throw new Error(`pre-inject busy check: pane ${paneId} is still running a turn; dispatch aborted`);
|
|
3907
4819
|
}
|
|
3908
|
-
await tmux.clearComposerDraft(paneId);
|
|
3909
4820
|
await revalidate?.();
|
|
3910
|
-
|
|
4821
|
+
if (guardBeforePaste) {
|
|
4822
|
+
// A stale replay must not touch the pane at all: the guard runs before the first composer scrub.
|
|
4823
|
+
if (!(await guardBeforePaste())) {
|
|
4824
|
+
return { acked: false, composerDelivered: false, aborted: true };
|
|
4825
|
+
}
|
|
4826
|
+
const staged = await tmux.stagePromptBuffer(paneId, prompt, agentId);
|
|
4827
|
+
// Fence + scrub + paste share one task-mutation-queue slot, so a rotation lands before or after, never between.
|
|
4828
|
+
let pasted = false;
|
|
4829
|
+
try {
|
|
4830
|
+
pasted = await this.withTaskLock(async () => {
|
|
4831
|
+
if (!(await guardBeforePaste()))
|
|
4832
|
+
return false;
|
|
4833
|
+
await tmux.clearComposerDraft(paneId);
|
|
4834
|
+
await tmux.pasteStagedBuffer(paneId, staged.buf);
|
|
4835
|
+
return true;
|
|
4836
|
+
});
|
|
4837
|
+
}
|
|
4838
|
+
catch (pasteErr) {
|
|
4839
|
+
// Buffer still present proves the paste never landed; missing/unprobeable fails closed to a composer scrub.
|
|
4840
|
+
let bufferConsumed = true;
|
|
4841
|
+
try {
|
|
4842
|
+
await tmux.dropStagedBuffer(staged.buf);
|
|
4843
|
+
bufferConsumed = false;
|
|
4844
|
+
}
|
|
4845
|
+
catch (dropErr) {
|
|
4846
|
+
console.warn(`[AgentManager] staged buffer ${staged.buf} not confirmed dropped after failed paste (missing or unprobeable); failing closed to a composer scrub:`, dropErr);
|
|
4847
|
+
}
|
|
4848
|
+
if (bufferConsumed) {
|
|
4849
|
+
try {
|
|
4850
|
+
await tmux.clearComposerDraft(paneId);
|
|
4851
|
+
}
|
|
4852
|
+
catch (clearErr) {
|
|
4853
|
+
console.warn(`[AgentManager] composer scrub after unknown paste outcome failed for pane ${paneId}:`, clearErr);
|
|
4854
|
+
}
|
|
4855
|
+
}
|
|
4856
|
+
throw pasteErr;
|
|
4857
|
+
}
|
|
4858
|
+
if (!pasted) {
|
|
4859
|
+
try {
|
|
4860
|
+
await tmux.dropStagedBuffer(staged.buf);
|
|
4861
|
+
}
|
|
4862
|
+
catch (err) {
|
|
4863
|
+
console.warn(`[AgentManager] staged buffer ${staged.buf} cleanup failed:`, err);
|
|
4864
|
+
}
|
|
4865
|
+
return { acked: false, composerDelivered: false, aborted: true };
|
|
4866
|
+
}
|
|
4867
|
+
}
|
|
4868
|
+
else {
|
|
4869
|
+
await tmux.clearComposerDraft(paneId);
|
|
4870
|
+
await revalidate?.();
|
|
4871
|
+
await tmux.injectPrompt(paneId, prompt, agentId);
|
|
4872
|
+
}
|
|
3911
4873
|
let baseline;
|
|
3912
4874
|
let baselineTitle = '';
|
|
3913
4875
|
try {
|
|
3914
4876
|
baseline = await tmux.captureSettledSnapshot(paneId, { timeoutMs: this.dispatchSettleTimeoutMs });
|
|
3915
4877
|
baselineTitle = await tmux.readPaneTitle(paneId);
|
|
3916
|
-
|
|
4878
|
+
if (guardBeforePaste) {
|
|
4879
|
+
// The Enter is the actual submission; it takes the same fenced queue slot.
|
|
4880
|
+
const submitted = await this.withTaskLock(async () => {
|
|
4881
|
+
if (!(await guardBeforePaste()))
|
|
4882
|
+
return false;
|
|
4883
|
+
await tmux.sendEnter(paneId);
|
|
4884
|
+
return true;
|
|
4885
|
+
});
|
|
4886
|
+
if (!submitted) {
|
|
4887
|
+
try {
|
|
4888
|
+
await tmux.clearComposerDraft(paneId);
|
|
4889
|
+
}
|
|
4890
|
+
catch (err) {
|
|
4891
|
+
// Fail closed: a submit-ready stale prompt could not be confirmed gone.
|
|
4892
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4893
|
+
throw new Error(`fence-rejected prompt could not be scrubbed from the composer of pane ${paneId}; verify the pane before reuse: ${message}`);
|
|
4894
|
+
}
|
|
4895
|
+
return { acked: false, composerDelivered: false, aborted: true };
|
|
4896
|
+
}
|
|
4897
|
+
}
|
|
4898
|
+
else {
|
|
4899
|
+
await tmux.sendEnter(paneId);
|
|
4900
|
+
}
|
|
3917
4901
|
}
|
|
3918
4902
|
catch (preAckErr) {
|
|
3919
4903
|
if (await this.clearComposerForReuse(tmux, paneId, agentId))
|
|
@@ -3921,11 +4905,24 @@ export class AgentManager {
|
|
|
3921
4905
|
const message = preAckErr instanceof Error ? preAckErr.message : String(preAckErr);
|
|
3922
4906
|
throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on live pane ${paneId}: ${message}`);
|
|
3923
4907
|
}
|
|
4908
|
+
let staleDuringResend = false;
|
|
3924
4909
|
try {
|
|
3925
4910
|
await tmux.waitSubmitAck(paneId, baseline, runtime, {
|
|
3926
4911
|
timeoutMs: this.dispatchAckTimeoutMs,
|
|
3927
4912
|
baselineTitle,
|
|
3928
|
-
resend:
|
|
4913
|
+
resend: guardBeforePaste
|
|
4914
|
+
? async () => {
|
|
4915
|
+
// A resend is a fresh submission of whatever sits in the composer; it takes the same fence.
|
|
4916
|
+
const resent = await this.withTaskLock(async () => {
|
|
4917
|
+
if (!(await guardBeforePaste()))
|
|
4918
|
+
return false;
|
|
4919
|
+
await tmux.sendEnter(paneId);
|
|
4920
|
+
return true;
|
|
4921
|
+
});
|
|
4922
|
+
if (!resent)
|
|
4923
|
+
staleDuringResend = true;
|
|
4924
|
+
}
|
|
4925
|
+
: () => tmux.sendEnter(paneId),
|
|
3929
4926
|
resendIntervalMs: this.dispatchAckResendIntervalMs,
|
|
3930
4927
|
});
|
|
3931
4928
|
return { acked: true, composerDelivered: true };
|
|
@@ -3935,6 +4932,16 @@ export class AgentManager {
|
|
|
3935
4932
|
if (!(err instanceof Error && /runtime ack timeout/.test(err.message))) {
|
|
3936
4933
|
throw new DispatchTerminalError('ack_unknown', `ack_unknown for pane ${paneId}: ${message}`);
|
|
3937
4934
|
}
|
|
4935
|
+
if (staleDuringResend) {
|
|
4936
|
+
try {
|
|
4937
|
+
await tmux.clearComposerDraft(paneId);
|
|
4938
|
+
}
|
|
4939
|
+
catch (scrubErr) {
|
|
4940
|
+
const scrubMessage = scrubErr instanceof Error ? scrubErr.message : String(scrubErr);
|
|
4941
|
+
throw new Error(`fence-rejected resend left an unverified composer on pane ${paneId}; verify the pane before reuse: ${scrubMessage}`);
|
|
4942
|
+
}
|
|
4943
|
+
return { acked: false, composerDelivered: false, aborted: true };
|
|
4944
|
+
}
|
|
3938
4945
|
console.warn(`[AgentManager] dispatch ack timeout for pane ${paneId} agent ${agentId}: ${message}`);
|
|
3939
4946
|
const state = await this.agentStore.get(agentId).catch(() => null);
|
|
3940
4947
|
await this.emitIntervention(state?.projectId ?? '', agentId, state?.taskId, {
|
|
@@ -4018,18 +5025,12 @@ export class AgentManager {
|
|
|
4018
5025
|
const stillOwner = stateNow?.taskId === taskId
|
|
4019
5026
|
&& stateNow.lockToken === lockToken
|
|
4020
5027
|
&& await this.lockManager.isOwner(agentId, taskId, lockToken);
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
const runner = this.createRunnerFor(agent);
|
|
4024
|
-
await new TmuxManager(runner).killSession(agentId);
|
|
4025
|
-
}
|
|
4026
|
-
else {
|
|
4027
|
-
console.warn(`[AgentManager] continueSession ensureSession rollback skipped killSession for stale owner ` +
|
|
4028
|
-
`${agentId}/${taskId}`);
|
|
4029
|
-
}
|
|
5028
|
+
if (stillOwner) {
|
|
5029
|
+
await this.rollbackCreatedSession(agentId, err.partial, 'continueSession ensure rollback');
|
|
4030
5030
|
}
|
|
4031
|
-
|
|
4032
|
-
console.warn(`[AgentManager] continueSession ensureSession rollback
|
|
5031
|
+
else {
|
|
5032
|
+
console.warn(`[AgentManager] continueSession ensureSession rollback skipped for stale owner ` +
|
|
5033
|
+
`${agentId}/${taskId}`);
|
|
4033
5034
|
}
|
|
4034
5035
|
}
|
|
4035
5036
|
throw err;
|
|
@@ -4050,7 +5051,7 @@ export class AgentManager {
|
|
|
4050
5051
|
const reason = `Workdir changed during continueSession: ` +
|
|
4051
5052
|
`before=${taskWorkdir}, ensure=${ensure.workdir}, state=${verifiedWorkdir ?? 'missing'}`;
|
|
4052
5053
|
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4053
|
-
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
5054
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId, ...(opts.expectedHold ? { expectedHold: opts.expectedHold } : {}) });
|
|
4054
5055
|
return false;
|
|
4055
5056
|
}
|
|
4056
5057
|
const expectedStatuses = PHASE_EXPECTED_STATUS[phase] ?? [];
|
|
@@ -4066,11 +5067,22 @@ export class AgentManager {
|
|
|
4066
5067
|
}
|
|
4067
5068
|
if (agent.role === 'dev' && task.branch) {
|
|
4068
5069
|
const branches = new BranchManager(runner);
|
|
4069
|
-
|
|
5070
|
+
if (!opts.allowDirtyWorkdir)
|
|
5071
|
+
await branches.assertClean(verifiedWorkdir);
|
|
4070
5072
|
const actualRef = await branches.currentRef(verifiedWorkdir);
|
|
4071
5073
|
if (actualRef !== `refs/heads/${task.branch}`) {
|
|
4072
|
-
|
|
4073
|
-
|
|
5074
|
+
// dev 可能已被释放过(如 spec max_rounds 停驻默认分支);switchToTaskBranch 自带
|
|
5075
|
+
// assertClean,脏树时抛 DirtyWorkdirError fail-closed,不会覆盖未提交的改动
|
|
5076
|
+
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
5077
|
+
await branches.switchToTaskBranch(verifiedWorkdir, taskId, task.branch, task.branchCreatedByBaxian === true, {
|
|
5078
|
+
requireExistingWork: true,
|
|
5079
|
+
...(task.branchLocalCleaned
|
|
5080
|
+
? { restorableRemoteTip: task.branchLocalCleaned.remoteTipSha }
|
|
5081
|
+
: {}),
|
|
5082
|
+
});
|
|
5083
|
+
await this.assertTaskGeneration(agentId, taskId, lockToken, verifiedWorkdir);
|
|
5084
|
+
if (task.branchLocalCleaned)
|
|
5085
|
+
await this.clearBranchLocalCleaned(taskId);
|
|
4074
5086
|
}
|
|
4075
5087
|
}
|
|
4076
5088
|
else if (agent.role === 'research') {
|
|
@@ -4113,6 +5125,7 @@ export class AgentManager {
|
|
|
4113
5125
|
agent,
|
|
4114
5126
|
workdir: verifiedWorkdir,
|
|
4115
5127
|
skillRegistry: this.skillRegistry,
|
|
5128
|
+
hasQaPartner: task.qaAgentId !== undefined,
|
|
4116
5129
|
...(signalToken ? { signalToken } : {}),
|
|
4117
5130
|
...(useIncrementalNudge
|
|
4118
5131
|
? { postApproveRedispatchCount: opts.postApproveRedispatchCount }
|
|
@@ -4164,17 +5177,29 @@ export class AgentManager {
|
|
|
4164
5177
|
const reason = `Workdir changed before prompt injection: ` +
|
|
4165
5178
|
`verified=${verifiedWorkdir}, current=${agentFresh.workdir ?? 'missing'}`;
|
|
4166
5179
|
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4167
|
-
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
5180
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId, ...(opts.expectedHold ? { expectedHold: opts.expectedHold } : {}) });
|
|
4168
5181
|
return false;
|
|
4169
5182
|
}
|
|
4170
5183
|
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
5184
|
+
// A revoke marks before clearing and a push transitions before clearing; every half-window must abort the paste.
|
|
5185
|
+
const postApprovePassStillLive = async () => {
|
|
5186
|
+
const [taskFresh, completionFresh] = await Promise.all([
|
|
5187
|
+
this.taskStore.get(taskId),
|
|
5188
|
+
this.getPostApproveCompletion(taskId),
|
|
5189
|
+
]);
|
|
5190
|
+
return taskFresh?.agentId === agentId
|
|
5191
|
+
&& taskFresh.status === 'approved'
|
|
5192
|
+
&& taskFresh.postApproveRevoked === undefined
|
|
5193
|
+
&& taskFresh.postApproveHeadSha !== undefined
|
|
5194
|
+
&& completionFresh !== null
|
|
5195
|
+
&& completionFresh.token === signalToken
|
|
5196
|
+
&& completionFresh.approvedHeadSha === taskFresh.postApproveHeadSha;
|
|
5197
|
+
};
|
|
5198
|
+
if (phase === 'post-approve' && !(await postApprovePassStillLive())) {
|
|
5199
|
+
console.warn(`[AgentManager] continueSession[post-approve]: pass revoked or rotated before paste for task ${taskId}; skipping`);
|
|
5200
|
+
return false;
|
|
4177
5201
|
}
|
|
5202
|
+
const guardBeforeInject = opts.guardBeforeInject ?? (phase === 'post-approve' ? postApprovePassStillLive : undefined);
|
|
4178
5203
|
if (opts.armBeforeInject && !(await opts.armBeforeInject({}))) {
|
|
4179
5204
|
return false;
|
|
4180
5205
|
}
|
|
@@ -4205,11 +5230,19 @@ export class AgentManager {
|
|
|
4205
5230
|
const reason = `Workdir changed before prompt injection: ` +
|
|
4206
5231
|
`verified=${verifiedWorkdir}, current=${stateBeforeInject.workdir ?? 'missing'}`;
|
|
4207
5232
|
console.warn(`[AgentManager] continueSession[${phase}]: ${reason}; holding`);
|
|
4208
|
-
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId });
|
|
5233
|
+
await this.markAwaitingHuman(agentId, 'workdir-changed-during-dispatch', `${reason}. The prompt was not delivered; verify the agent runtime and Workdir before resuming.`, { expectedTaskId: taskId, ...(opts.expectedHold ? { expectedHold: opts.expectedHold } : {}) });
|
|
4209
5234
|
return false;
|
|
4210
5235
|
}
|
|
4211
5236
|
await this.assertTaskLockOwner(agentId, taskId, lockToken);
|
|
4212
|
-
|
|
5237
|
+
if (guardBeforeInject && !(await guardBeforeInject())) {
|
|
5238
|
+
console.warn(`[AgentManager] continueSession[${phase}]: pre-inject guard rejected the paste for task ${taskId}; skipping`);
|
|
5239
|
+
return false;
|
|
5240
|
+
}
|
|
5241
|
+
const delivery = await this.injectAndAwaitAck(tmux, paneId, prompt, agentId, agent.runtime, guardBeforeInject);
|
|
5242
|
+
if (delivery.aborted) {
|
|
5243
|
+
console.warn(`[AgentManager] continueSession[${phase}]: pre-paste guard rejected the inject for task ${taskId}; skipping`);
|
|
5244
|
+
return false;
|
|
5245
|
+
}
|
|
4213
5246
|
return true;
|
|
4214
5247
|
}
|
|
4215
5248
|
async rollbackUndeliveredBootstrap(state) {
|
|
@@ -4294,6 +5327,49 @@ export class AgentManager {
|
|
|
4294
5327
|
return { ...rest, updatedAt: new Date().toISOString() };
|
|
4295
5328
|
});
|
|
4296
5329
|
}
|
|
5330
|
+
// continueSession never runs startSession's post-ack finalization; a surviving marker would make recover() roll the running task back to pending.
|
|
5331
|
+
async finalizeReplayedBootstrap(agentId, taskId, phase, expectedLockToken) {
|
|
5332
|
+
let cleared = false;
|
|
5333
|
+
let deliveredWorkdir;
|
|
5334
|
+
try {
|
|
5335
|
+
await this.agentStore.update(agentId, (latest) => {
|
|
5336
|
+
if (!latest
|
|
5337
|
+
|| latest.taskId !== taskId
|
|
5338
|
+
|| latest.bootstrappingTaskId !== taskId
|
|
5339
|
+
|| latest.lockToken !== expectedLockToken) {
|
|
5340
|
+
return AGENT_STORE_NOOP;
|
|
5341
|
+
}
|
|
5342
|
+
cleared = true;
|
|
5343
|
+
deliveredWorkdir = latest.workdir;
|
|
5344
|
+
const { bootstrappingTaskId: _delivered, ...rest } = latest;
|
|
5345
|
+
return { ...rest, updatedAt: new Date().toISOString() };
|
|
5346
|
+
});
|
|
5347
|
+
}
|
|
5348
|
+
catch (err) {
|
|
5349
|
+
console.warn(`[AgentManager] replay: clearing bootstrap marker for task=${taskId} failed:`, err);
|
|
5350
|
+
await this.markAwaitingHuman(agentId, 'bootstrap-marker-clear-failed', 'Prompt was replayed but clearing the in-flight bootstrap marker failed; held so recovery does ' +
|
|
5351
|
+
'not roll back an already-running task. Verify the agent via web terminal, then Resume.', { expectedTaskId: taskId }).catch((holdErr) => {
|
|
5352
|
+
console.warn(`[AgentManager] replay: hold after marker-clear failure for task=${taskId} failed:`, holdErr);
|
|
5353
|
+
});
|
|
5354
|
+
return;
|
|
5355
|
+
}
|
|
5356
|
+
if (!cleared)
|
|
5357
|
+
return;
|
|
5358
|
+
try {
|
|
5359
|
+
await this.eventBus.emit({
|
|
5360
|
+
id: '',
|
|
5361
|
+
type: 'session.started',
|
|
5362
|
+
timestamp: new Date().toISOString(),
|
|
5363
|
+
projectId: this.getAgentConfig(agentId)?.projectId ?? '',
|
|
5364
|
+
agentId,
|
|
5365
|
+
taskId,
|
|
5366
|
+
data: { phase, ...(deliveredWorkdir !== undefined ? { workdir: deliveredWorkdir } : {}) },
|
|
5367
|
+
});
|
|
5368
|
+
}
|
|
5369
|
+
catch (err) {
|
|
5370
|
+
console.warn(`[AgentManager] replay: session.started emit failed for task=${taskId}:`, err);
|
|
5371
|
+
}
|
|
5372
|
+
}
|
|
4297
5373
|
async releaseOrphanedLocks(states) {
|
|
4298
5374
|
const bindings = new Map(states.map(state => [state.id, state]));
|
|
4299
5375
|
const claims = await this.lockManager.listClaims();
|
|
@@ -4463,13 +5539,7 @@ export class AgentManager {
|
|
|
4463
5539
|
continue;
|
|
4464
5540
|
}
|
|
4465
5541
|
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
4466
|
-
|
|
4467
|
-
const runner = this.createRunnerFor(agentConfig);
|
|
4468
|
-
await new TmuxManager(runner).killSession(state.id);
|
|
4469
|
-
}
|
|
4470
|
-
catch (cleanupErr) {
|
|
4471
|
-
console.warn(`[recover] killSession rollback failed for agent=${state.id}:`, cleanupErr);
|
|
4472
|
-
}
|
|
5542
|
+
await this.rollbackCreatedSession(state.id, err.partial, 'recover ensure rollback');
|
|
4473
5543
|
}
|
|
4474
5544
|
const message = err instanceof Error ? err.message : String(err);
|
|
4475
5545
|
console.warn(`[recover] ensureSession failed for agent=${state.id}: ${message}`);
|
|
@@ -4821,13 +5891,38 @@ export class AgentManager {
|
|
|
4821
5891
|
? 'review'
|
|
4822
5892
|
: 'recheck';
|
|
4823
5893
|
const prevQa = await this.agentStore.get(qaId);
|
|
5894
|
+
const resumingHeldDispatch = prevQa?.taskId === taskId && prevQa.status === 'awaiting_human';
|
|
4824
5895
|
if (prevQa?.taskId === taskId) {
|
|
4825
|
-
|
|
5896
|
+
if (prevQa.status === 'awaiting_human' && !isRecoverableQaDispatchHold(prevQa)) {
|
|
5897
|
+
throw new ApiError(409, `QA agent ${qaId} is awaiting human (${prevQa.awaitingPhase ?? 'unknown phase'}); resolve that hold before redispatching review`);
|
|
5898
|
+
}
|
|
5899
|
+
const released = await this.releaseAgentIfBound(qaId, taskId, prevQa.status === 'awaiting_human'
|
|
5900
|
+
? {
|
|
5901
|
+
allowAwaitingHuman: true,
|
|
5902
|
+
expectedHold: {
|
|
5903
|
+
phase: prevQa.awaitingPhase,
|
|
5904
|
+
since: prevQa.awaitingSince,
|
|
5905
|
+
nonce: prevQa.awaitingNonce,
|
|
5906
|
+
},
|
|
5907
|
+
}
|
|
5908
|
+
: undefined);
|
|
5909
|
+
if (!released) {
|
|
5910
|
+
const after = await this.agentStore.get(qaId);
|
|
5911
|
+
const holdNote = after?.status === 'awaiting_human'
|
|
5912
|
+
? ` (awaiting_human: ${after.awaitingPhase ?? 'unknown phase'})`
|
|
5913
|
+
: '';
|
|
5914
|
+
throw new ApiError(409, `QA agent ${qaId} could not be released from task ${taskId}${holdNote}; review dispatch aborted`);
|
|
5915
|
+
}
|
|
4826
5916
|
}
|
|
4827
5917
|
const acquired = await this.acquireAgentForTask(qaId, taskId, qaPhase);
|
|
4828
5918
|
if (!acquired) {
|
|
4829
5919
|
throw new ApiError(409, `QA agent ${qaId} is busy or unavailable`);
|
|
4830
5920
|
}
|
|
5921
|
+
const acquiredClaim = await this.lockManager.claimOf(qaId);
|
|
5922
|
+
const acquiredLockToken = acquiredClaim?.taskId === taskId ? acquiredClaim.token : undefined;
|
|
5923
|
+
const releaseOwnAcquire = acquiredLockToken !== undefined
|
|
5924
|
+
? { expectedLockToken: acquiredLockToken }
|
|
5925
|
+
: {};
|
|
4831
5926
|
let devParked = false;
|
|
4832
5927
|
if (!isTerminal && devAgentId) {
|
|
4833
5928
|
const devState = await this.agentStore.get(devAgentId);
|
|
@@ -4838,7 +5933,7 @@ export class AgentManager {
|
|
|
4838
5933
|
return false;
|
|
4839
5934
|
});
|
|
4840
5935
|
if (!devOk) {
|
|
4841
|
-
await this.releaseAgentForTask(qaId, taskId, 'idle')
|
|
5936
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire)
|
|
4842
5937
|
.catch(() => undefined);
|
|
4843
5938
|
throw new ApiError(500, `Cannot park dev ${devAgentId} into waiting for manual QA review (task status=${taskStatusAtClaim}); QA released`);
|
|
4844
5939
|
}
|
|
@@ -4853,7 +5948,8 @@ export class AgentManager {
|
|
|
4853
5948
|
reviewDispatchedAt: pre?.reviewDispatchedAt,
|
|
4854
5949
|
};
|
|
4855
5950
|
const isTerminalAtClaim = TERMINAL_STATUSES.includes(taskStatusAtClaim);
|
|
4856
|
-
|
|
5951
|
+
// 仅当恢复的是任务当前 review pass 时沿用轮次;任务已离开 review 的手工调用是新 pass,保留加轮
|
|
5952
|
+
const bumpRound = opts.bumpRound !== false && !(resumingHeldDispatch && taskStatusAtClaim === 'review');
|
|
4857
5953
|
// 回滚在锁内判定 pass 是否已被并发接管并返回;仅未被接管时才释放本次 acquire 的 QA,
|
|
4858
5954
|
// 否则会误清接管方 re-acquire 的同一 QA 绑定(同 task 同 agent,绕过 release 的 mismatch 保护)
|
|
4859
5955
|
const abortDispatch = async (dispatchToken) => {
|
|
@@ -4866,13 +5962,18 @@ export class AgentManager {
|
|
|
4866
5962
|
let transitionToken;
|
|
4867
5963
|
if (!isTerminalAtClaim) {
|
|
4868
5964
|
const reviewAnchor = await this.fetchPrHeadSha(taskId).catch(() => undefined);
|
|
4869
|
-
const preDispatched = await this.transitionTaskStatus(taskId, 'review', {
|
|
5965
|
+
const preDispatched = await this.transitionTaskStatus(taskId, 'review', {
|
|
5966
|
+
fromStatus: [taskStatusAtClaim],
|
|
5967
|
+
// 入口 claim 到此处之间 pass 可能被并发轮换;expect token 必须与写入同一原子段复核
|
|
5968
|
+
...(opts.expectSignalToken !== undefined ? { expectSignalToken: opts.expectSignalToken } : {}),
|
|
5969
|
+
}, {
|
|
4870
5970
|
reviewHeadAnchorSha: reviewAnchor,
|
|
4871
5971
|
reviewDispatchedAt: new Date().toISOString(),
|
|
4872
5972
|
signalToken: createSignalToken(),
|
|
4873
5973
|
});
|
|
4874
5974
|
if (!preDispatched) {
|
|
4875
|
-
|
|
5975
|
+
// fence 拒绝可能源于 successor 已换代并 re-acquire 同一 QA;释放必须钉在本次 acquire 的锁代上
|
|
5976
|
+
await this.releaseAgentForTask(qaId, taskId, 'idle', releaseOwnAcquire).catch(() => undefined);
|
|
4876
5977
|
if (devParked)
|
|
4877
5978
|
await this.emitManualReviewDevParkedQaFailedIntervention(devAgentId, taskId);
|
|
4878
5979
|
throw new ApiError(409, `Task ${taskId} status changed during dispatch; cannot enter review`);
|
|
@@ -4923,11 +6024,17 @@ export class AgentManager {
|
|
|
4923
6024
|
});
|
|
4924
6025
|
}
|
|
4925
6026
|
catch (err) {
|
|
4926
|
-
if (
|
|
4927
|
-
|
|
4928
|
-
|
|
6027
|
+
if (err instanceof DispatchTerminalError && err.reason === 'ack_unknown') {
|
|
6028
|
+
// prompt 可能已注入:不回滚 armed pass、不释放 QA;hold 持久化失败也不得吞掉 ack_unknown 分类
|
|
6029
|
+
try {
|
|
6030
|
+
await this.markAwaitingIfAckUnknown(qaId, err, taskId);
|
|
6031
|
+
}
|
|
6032
|
+
catch (holdErr) {
|
|
6033
|
+
console.error(`[AgentManager] dispatchReviewToQa could not persist the ack_unknown hold for ${qaId}:`, holdErr);
|
|
6034
|
+
}
|
|
6035
|
+
throw err;
|
|
4929
6036
|
}
|
|
4930
|
-
|
|
6037
|
+
if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
4931
6038
|
await abortDispatch(armedToken);
|
|
4932
6039
|
}
|
|
4933
6040
|
throw err;
|
|
@@ -5227,7 +6334,9 @@ export class AgentManager {
|
|
|
5227
6334
|
const task = await this.taskStore.get(taskId);
|
|
5228
6335
|
if (!task?.signalToken)
|
|
5229
6336
|
return false;
|
|
5230
|
-
return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken,
|
|
6337
|
+
return this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, task.signalToken, {
|
|
6338
|
+
skipSnapshot: opts.skipSnapshot ?? false,
|
|
6339
|
+
});
|
|
5231
6340
|
}
|
|
5232
6341
|
async emitManualReviewDevParkedQaFailedIntervention(agentId, expectedTaskId) {
|
|
5233
6342
|
if (!agentId)
|
|
@@ -5723,7 +6832,7 @@ export class AgentManager {
|
|
|
5723
6832
|
}
|
|
5724
6833
|
return publishDispatchedAt ? 'delivered' : false;
|
|
5725
6834
|
}
|
|
5726
|
-
async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token,
|
|
6835
|
+
async setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, opts = {}) {
|
|
5727
6836
|
if (!this.phaseSignalWatcher)
|
|
5728
6837
|
return true;
|
|
5729
6838
|
const task = await this.taskStore.get(taskId);
|
|
@@ -5736,8 +6845,9 @@ export class AgentManager {
|
|
|
5736
6845
|
agentId,
|
|
5737
6846
|
expectedKinds,
|
|
5738
6847
|
token,
|
|
5739
|
-
skipSnapshot,
|
|
5740
|
-
...(
|
|
6848
|
+
skipSnapshot: opts.skipSnapshot ?? false,
|
|
6849
|
+
...(opts.onlyReplaceOwnToken ? { onlyReplaceOwnToken: true } : {}),
|
|
6850
|
+
...(opts.onReadFile ? { onReadFile: opts.onReadFile } : {}),
|
|
5741
6851
|
});
|
|
5742
6852
|
}
|
|
5743
6853
|
catch (err) {
|
|
@@ -5746,7 +6856,10 @@ export class AgentManager {
|
|
|
5746
6856
|
}
|
|
5747
6857
|
}
|
|
5748
6858
|
async armPostDispatchSignalOrHold(taskId, agentId, expectedKinds, token, skipSnapshot = false, onReadFile) {
|
|
5749
|
-
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token,
|
|
6859
|
+
const armed = await this.setupPhaseSignalWatcher(taskId, agentId, expectedKinds, token, {
|
|
6860
|
+
skipSnapshot,
|
|
6861
|
+
...(onReadFile ? { onReadFile } : {}),
|
|
6862
|
+
});
|
|
5750
6863
|
if (!armed)
|
|
5751
6864
|
await this.holdAgentForUnarmedSignal(taskId, agentId, expectedKinds);
|
|
5752
6865
|
}
|
|
@@ -5835,8 +6948,9 @@ export class AgentManager {
|
|
|
5835
6948
|
const fresh = await this.taskStore.get(taskId);
|
|
5836
6949
|
if (!fresh)
|
|
5837
6950
|
throw new ApiError(404, `Task ${taskId} not found`);
|
|
5838
|
-
|
|
5839
|
-
|
|
6951
|
+
const specMaxRounds = fresh.status === 'max_rounds' && isSpecStagePhase(fresh.phase);
|
|
6952
|
+
if (fresh.status !== 'spec-ready' && !specMaxRounds) {
|
|
6953
|
+
throw new ApiError(409, `Task ${taskId} is ${fresh.status}; spec verdict requires spec-ready or spec-phase max_rounds`);
|
|
5840
6954
|
}
|
|
5841
6955
|
if (verdict === 'archive' && !fresh.researchAgentId) {
|
|
5842
6956
|
throw new ApiError(409, `Task ${taskId} is not a Research task; only Research specs can be archived`);
|
|
@@ -5871,7 +6985,7 @@ export class AgentManager {
|
|
|
5871
6985
|
...base,
|
|
5872
6986
|
userDecision: { verdict, ...(trimmed ? { comments: trimmed } : {}), at },
|
|
5873
6987
|
});
|
|
5874
|
-
const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready'] });
|
|
6988
|
+
const transition = await this.transitionTaskStatus(taskId, 'done', { fromStatus: ['spec-ready', 'max_rounds'] });
|
|
5875
6989
|
if (!transition)
|
|
5876
6990
|
throw new ApiError(409, `Task ${taskId} changed while archiving`);
|
|
5877
6991
|
this.stopPhaseSignalWatcher(taskId);
|
|
@@ -5897,18 +7011,40 @@ export class AgentManager {
|
|
|
5897
7011
|
}
|
|
5898
7012
|
if (verdict === 'approve') {
|
|
5899
7013
|
await store.putRound(taskId, 'spec', { ...base, userDecision: { verdict, at } });
|
|
5900
|
-
|
|
7014
|
+
let result;
|
|
7015
|
+
try {
|
|
7016
|
+
result = await this.transitionToCodePhase(taskId);
|
|
7017
|
+
}
|
|
7018
|
+
catch (err) {
|
|
7019
|
+
if (err instanceof ApiError)
|
|
7020
|
+
throw err;
|
|
7021
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7022
|
+
const after = await this.taskStore.get(taskId);
|
|
7023
|
+
if (after?.status === 'failed') {
|
|
7024
|
+
const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
|
|
7025
|
+
throw new ApiError(500, `Code-phase dispatch failed terminally for task ${taskId}${reason}: ${message}. `
|
|
7026
|
+
+ 'The task has been marked failed; use Retry to run it as a fresh task.');
|
|
7027
|
+
}
|
|
7028
|
+
if (after?.phase === 'code' && after.status === 'in_progress') {
|
|
7029
|
+
throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
|
|
7030
|
+
+ 'The task already moved to the code phase and the dev is held; '
|
|
7031
|
+
+ 'Resume the dev agent to redeliver the prompt.');
|
|
7032
|
+
}
|
|
7033
|
+
throw new ApiError(500, `Code-phase dispatch failed for task ${taskId}: ${message}. `
|
|
7034
|
+
+ 'The task was rolled back to await the verdict; fix the dev Workdir '
|
|
7035
|
+
+ '(Resume the dev first if it is still held), then retry the verdict.');
|
|
7036
|
+
}
|
|
5901
7037
|
if (!result) {
|
|
7038
|
+
const after = await this.taskStore.get(taskId);
|
|
7039
|
+
if (after?.phase === 'code' && after.status === 'in_progress') {
|
|
7040
|
+
throw new ApiError(500, `Code-phase prompt was not delivered for task ${taskId}; the task already moved to the `
|
|
7041
|
+
+ 'code phase and the dev is held. Resume the dev agent to redeliver the prompt '
|
|
7042
|
+
+ '(retrying the verdict will be rejected).');
|
|
7043
|
+
}
|
|
5902
7044
|
throw new ApiError(409, `Dev is unavailable for task ${taskId}; approval was recorded and can be retried`);
|
|
5903
7045
|
}
|
|
5904
7046
|
return result;
|
|
5905
7047
|
}
|
|
5906
|
-
// 打回消耗一轮修订;到达上限时拒绝而非派发注定进 max_rounds 的修订,让用户当场决策
|
|
5907
|
-
const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
|
|
5908
|
-
if (round >= cap) {
|
|
5909
|
-
throw new ApiError(409, `Task ${taskId} has reached the spec review round cap (${cap}); `
|
|
5910
|
-
+ 'approve the spec, or cancel the task and retry with a refined description');
|
|
5911
|
-
}
|
|
5912
7048
|
if (!trimmed)
|
|
5913
7049
|
throw new ApiError(400, 'comments is required for request-changes');
|
|
5914
7050
|
const priorFindings = base.findings?.findings ?? [];
|
|
@@ -5918,16 +7054,106 @@ export class AgentManager {
|
|
|
5918
7054
|
verdict: 'request-changes',
|
|
5919
7055
|
findings: [...priorFindings, { id: nextUserId, severity: 'major', message: trimmed }],
|
|
5920
7056
|
};
|
|
5921
|
-
|
|
7057
|
+
const rejectedRound = {
|
|
5922
7058
|
...base,
|
|
5923
7059
|
findings: mergedFindings,
|
|
5924
7060
|
userDecision: { verdict, comments: trimmed, at },
|
|
5925
|
-
}
|
|
5926
|
-
|
|
7061
|
+
};
|
|
7062
|
+
// 到达上限后用户仍显式打回 = 对「再评一轮」的当场决策:扩 cap 而非拒绝。
|
|
7063
|
+
// holder 恢复与扩 cap 正交:max_rounds 入态时 holder 已被释放清空,即便 cap
|
|
7064
|
+
// 因配置热更新出现余量,打回前也必须先恢复 holder
|
|
7065
|
+
const cap = this.getConfig().review.rounds + (task.maxRoundsContinues ?? 0);
|
|
7066
|
+
const claim = task.status === 'max_rounds' || round >= cap
|
|
7067
|
+
? await this.claimSpecFixDispatch(taskId, { extendCapToFit: round >= cap ? round : null })
|
|
7068
|
+
: null;
|
|
7069
|
+
let dispatched = null;
|
|
7070
|
+
try {
|
|
7071
|
+
await store.putRound(taskId, 'spec', rejectedRound);
|
|
7072
|
+
dispatched = await this.dispatchServerFixToDev(taskId, JSON.stringify(mergedFindings));
|
|
7073
|
+
}
|
|
7074
|
+
catch (err) {
|
|
7075
|
+
if (err instanceof ApiError)
|
|
7076
|
+
throw err;
|
|
7077
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7078
|
+
const after = await this.taskStore.get(taskId);
|
|
7079
|
+
if (after?.status === 'failed') {
|
|
7080
|
+
const reason = err instanceof DispatchTerminalError ? ` (${err.reason})` : '';
|
|
7081
|
+
throw new ApiError(500, `Spec fix dispatch failed terminally for task ${taskId}${reason}: ${message}. `
|
|
7082
|
+
+ 'The task has been marked failed with the rejection kept on record; '
|
|
7083
|
+
+ 'use Retry to run it as a fresh task.');
|
|
7084
|
+
}
|
|
7085
|
+
if (after?.status === 'fixing') {
|
|
7086
|
+
throw new ApiError(500, `Spec fix dispatch was interrupted for task ${taskId}: ${message}. `
|
|
7087
|
+
+ 'The task moved to fixing with the rejection recorded; resume the holder agent to redeliver it.');
|
|
7088
|
+
}
|
|
7089
|
+
throw new ApiError(500, `Spec fix dispatch failed for task ${taskId}: ${message}. `
|
|
7090
|
+
+ 'The rejection was rolled back; fix the agent runtime or configuration, then retry.');
|
|
7091
|
+
}
|
|
7092
|
+
finally {
|
|
7093
|
+
if (!dispatched)
|
|
7094
|
+
await this.recoverSpecRejectionState(taskId, claim, base);
|
|
7095
|
+
}
|
|
5927
7096
|
if (!dispatched)
|
|
5928
7097
|
throw new ApiError(500, `Failed to dispatch spec fix for task ${taskId}`);
|
|
5929
7098
|
return dispatched;
|
|
5930
7099
|
}
|
|
7100
|
+
async claimSpecFixDispatch(taskId, opts) {
|
|
7101
|
+
return await this.withTaskLock(async () => {
|
|
7102
|
+
const fresh = await this.taskStore.get(taskId);
|
|
7103
|
+
if (!fresh)
|
|
7104
|
+
throw new ApiError(404, `Task ${taskId} not found`);
|
|
7105
|
+
if (fresh.status !== 'spec-ready' && fresh.status !== 'max_rounds') {
|
|
7106
|
+
throw new ApiError(409, `Task ${taskId} changed status during the spec verdict; aborted`);
|
|
7107
|
+
}
|
|
7108
|
+
const holder = fresh.agentId || fresh.researchAgentId || fresh.devAgentId;
|
|
7109
|
+
if (!holder || !this.getAgentConfig(holder)) {
|
|
7110
|
+
throw new ApiError(409, `Task ${taskId} spec holder agent ${holder || '(none)'} is not in the current config; `
|
|
7111
|
+
+ 'restore the agent config or cancel the task');
|
|
7112
|
+
}
|
|
7113
|
+
const originalAgentId = fresh.agentId;
|
|
7114
|
+
const originalCounter = fresh.maxRoundsContinues ?? 0;
|
|
7115
|
+
let counterAfter = originalCounter;
|
|
7116
|
+
if (opts.extendCapToFit !== null) {
|
|
7117
|
+
// cap 必须容下下一轮(round+1);rounds 被调低时一次 +1 可能不够
|
|
7118
|
+
counterAfter = Math.max(originalCounter + 1, opts.extendCapToFit + 1 - this.getConfig().review.rounds);
|
|
7119
|
+
fresh.maxRoundsContinues = counterAfter;
|
|
7120
|
+
}
|
|
7121
|
+
fresh.agentId = holder;
|
|
7122
|
+
fresh.updatedAt = new Date().toISOString();
|
|
7123
|
+
await this.taskStore.set(fresh);
|
|
7124
|
+
return { originalAgentId, originalCounter, holderWritten: holder, counterAfter };
|
|
7125
|
+
});
|
|
7126
|
+
}
|
|
7127
|
+
// 补偿以 claim 时的 status+counter+holder 为代际证明:任务已被推进(fixing/cancelled/新扩轮)
|
|
7128
|
+
// 就放弃恢复——尤其派发把任务带进 held fixing 时,打回 round 必须保留给后续 Resume 重派
|
|
7129
|
+
async recoverSpecRejectionState(taskId, claim, baseRound) {
|
|
7130
|
+
const store = this.getReviewStore();
|
|
7131
|
+
await this.withTaskLock(async () => {
|
|
7132
|
+
const fresh = await this.taskStore.get(taskId);
|
|
7133
|
+
if (!fresh)
|
|
7134
|
+
return;
|
|
7135
|
+
if (fresh.status !== 'spec-ready' && fresh.status !== 'max_rounds') {
|
|
7136
|
+
console.warn(`[AgentManager] spec rejection recovery(${taskId}) skipped: task moved to ${fresh.status}`);
|
|
7137
|
+
return;
|
|
7138
|
+
}
|
|
7139
|
+
if (claim) {
|
|
7140
|
+
if ((fresh.maxRoundsContinues ?? 0) !== claim.counterAfter
|
|
7141
|
+
|| fresh.agentId !== claim.holderWritten) {
|
|
7142
|
+
console.warn(`[AgentManager] spec rejection recovery(${taskId}) skipped: `
|
|
7143
|
+
+ `continues=${fresh.maxRoundsContinues ?? 0} agentId=${fresh.agentId} `
|
|
7144
|
+
+ `(expected ${claim.counterAfter}/${claim.holderWritten})`);
|
|
7145
|
+
return;
|
|
7146
|
+
}
|
|
7147
|
+
fresh.maxRoundsContinues = claim.originalCounter;
|
|
7148
|
+
fresh.agentId = claim.originalAgentId;
|
|
7149
|
+
fresh.updatedAt = new Date().toISOString();
|
|
7150
|
+
await this.taskStore.set(fresh);
|
|
7151
|
+
}
|
|
7152
|
+
await store?.putRound(taskId, 'spec', baseRound);
|
|
7153
|
+
}).catch(err => {
|
|
7154
|
+
console.error(`[AgentManager] spec rejection recovery(${taskId}) failed:`, err);
|
|
7155
|
+
});
|
|
7156
|
+
}
|
|
5931
7157
|
async submitCodeVerdict(taskId, comments) {
|
|
5932
7158
|
const trimmed = comments?.trim();
|
|
5933
7159
|
if (!trimmed) {
|
|
@@ -6090,6 +7316,7 @@ export class AgentManager {
|
|
|
6090
7316
|
if (task.qaAgentId && !await releaseParticipant(task.qaAgentId, 'code-phase-qa-release-failed')) {
|
|
6091
7317
|
return null;
|
|
6092
7318
|
}
|
|
7319
|
+
const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
|
|
6093
7320
|
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'code');
|
|
6094
7321
|
if (!acquired) {
|
|
6095
7322
|
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
@@ -6098,8 +7325,10 @@ export class AgentManager {
|
|
|
6098
7325
|
});
|
|
6099
7326
|
return null;
|
|
6100
7327
|
}
|
|
7328
|
+
const acquiredLockToken = (await this.agentStore.get(devAgentId))?.lockToken;
|
|
6101
7329
|
const newToken = createSignalToken();
|
|
6102
|
-
|
|
7330
|
+
// spec 阶段的扩轮决策不延续到 code 评审:cap 共享字段,进入编码时归零
|
|
7331
|
+
const transition = await this.transitionTaskStatus(taskId, 'in_progress', { fromStatus: ['review', 'fixing', 'in_progress', 'spec-ready', 'max_rounds'] }, { agentId: devAgentId, phase: 'code', signalToken: newToken, maxRoundsContinues: 0 });
|
|
6103
7332
|
if (!transition) {
|
|
6104
7333
|
await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
|
|
6105
7334
|
.catch(() => undefined);
|
|
@@ -6114,6 +7343,33 @@ export class AgentManager {
|
|
|
6114
7343
|
if (err instanceof DispatchTerminalError) {
|
|
6115
7344
|
await this.failTaskForDispatchError(taskId, 'code', devAgentId, err);
|
|
6116
7345
|
}
|
|
7346
|
+
else if (err instanceof EnsureSessionError && err.partial.handled
|
|
7347
|
+
&& (task.status === 'spec-ready' || task.status === 'max_rounds')) {
|
|
7348
|
+
// dev 已被 checkout/dialog hold 且 prompt 未送达;resumeAgent 不重派这些 hold,
|
|
7349
|
+
// 任务停在 in_progress 会同时封死 Resume 与 verdict——退回 verdict 门禁状态
|
|
7350
|
+
const rolledBack = await this.transitionTaskStatus(taskId, task.status, { fromStatus: ['in_progress'] }, {
|
|
7351
|
+
...(task.phase !== undefined ? { phase: task.phase } : {}),
|
|
7352
|
+
agentId: task.agentId,
|
|
7353
|
+
...(task.signalToken ? { signalToken: task.signalToken } : {}),
|
|
7354
|
+
maxRoundsContinues: task.maxRoundsContinues ?? 0,
|
|
7355
|
+
});
|
|
7356
|
+
if (!rolledBack) {
|
|
7357
|
+
console.warn(`[AgentManager] transitionToCodePhase(${taskId}): handled dispatch failure but the task `
|
|
7358
|
+
+ 'moved on; leaving its status as-is');
|
|
7359
|
+
}
|
|
7360
|
+
else if (!devBoundBefore) {
|
|
7361
|
+
// 转码前 dev 未持有本任务(如 research handoff / max_rounds 重占用):任务已退回
|
|
7362
|
+
// verdict 门禁,释放本次新占用,否则 dev 会被回退后的任务永久占住
|
|
7363
|
+
const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle', { allowAwaitingHuman: true })
|
|
7364
|
+
.catch(() => false);
|
|
7365
|
+
if (!released) {
|
|
7366
|
+
await this.emitIntervention(task.projectId, devAgentId, taskId, {
|
|
7367
|
+
phase: 'code-rollback-dev-release-failed',
|
|
7368
|
+
devAgentId,
|
|
7369
|
+
});
|
|
7370
|
+
}
|
|
7371
|
+
}
|
|
7372
|
+
}
|
|
6117
7373
|
else if (!(err instanceof EnsureSessionError && err.partial.handled)) {
|
|
6118
7374
|
await this.markAwaitingHuman(devAgentId, 'code-dispatch-failed', 'Code-phase prompt was not delivered after spec approval; the task looks in_progress but the dev never received it. Resume/restart the agent or cancel the task.', { expectedTaskId: taskId }).catch(() => undefined);
|
|
6119
7375
|
}
|
|
@@ -6128,6 +7384,11 @@ export class AgentManager {
|
|
|
6128
7384
|
});
|
|
6129
7385
|
return null;
|
|
6130
7386
|
}
|
|
7387
|
+
// 重占用(released dev)的 acquire 会写 bootstrappingTaskId,而 continueSession 不做
|
|
7388
|
+
// startSession 的 post-ack finalize;残留标记会让 recover 把已运行的任务按未送达回滚
|
|
7389
|
+
if (!researchHandoff) {
|
|
7390
|
+
await this.finalizeReplayedBootstrap(devAgentId, taskId, 'code', acquiredLockToken);
|
|
7391
|
+
}
|
|
6131
7392
|
return await this.taskStore.get(taskId);
|
|
6132
7393
|
}
|
|
6133
7394
|
async dispatchServerReviewToQa(taskId, opts) {
|
|
@@ -6270,7 +7531,11 @@ export class AgentManager {
|
|
|
6270
7531
|
armBeforeInject: (ctx) => {
|
|
6271
7532
|
dispatchedCheckoutMode = ctx.serverReviewCheckout;
|
|
6272
7533
|
const allowReadFile = opts.phase === 'spec' || opts.continuation || ctx.serverReviewCheckout === 'base';
|
|
6273
|
-
return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken,
|
|
7534
|
+
return this.setupPhaseSignalWatcher(taskId, qaId, expectedKind, newToken, {
|
|
7535
|
+
...(allowReadFile
|
|
7536
|
+
? { onReadFile: (req) => { void this.handleReadFileRequest(taskId, qaId, req); } }
|
|
7537
|
+
: {}),
|
|
7538
|
+
});
|
|
6274
7539
|
},
|
|
6275
7540
|
};
|
|
6276
7541
|
const rearmConsumedSignal = async () => {
|
|
@@ -6364,6 +7629,16 @@ export class AgentManager {
|
|
|
6364
7629
|
const reviewedKind = taskPhase === 'spec' ? 'spec-reviewed' : 'code-reviewed';
|
|
6365
7630
|
await this.setupPhaseSignal(taskId, qaAgentId, reviewedKind, { skipSnapshot: true });
|
|
6366
7631
|
};
|
|
7632
|
+
const devBoundBefore = (await this.agentStore.get(devAgentId))?.taskId === taskId;
|
|
7633
|
+
const releaseFreshAcquire = async () => {
|
|
7634
|
+
if (devBoundBefore)
|
|
7635
|
+
return;
|
|
7636
|
+
const released = await this.releaseAgentForTask(devAgentId, taskId, 'idle')
|
|
7637
|
+
.catch(() => false);
|
|
7638
|
+
if (!released) {
|
|
7639
|
+
console.error(`[AgentManager] dispatchServerFixToDev: releasing freshly acquired dev ${devAgentId} for ${taskId} failed`);
|
|
7640
|
+
}
|
|
7641
|
+
};
|
|
6367
7642
|
const acquired = await this.acquireAgentForTask(devAgentId, taskId, 'server-feedback');
|
|
6368
7643
|
if (!acquired) {
|
|
6369
7644
|
await rearmReviewedSignal();
|
|
@@ -6374,6 +7649,7 @@ export class AgentManager {
|
|
|
6374
7649
|
const released = await this.releaseAgentIfBound(qaAgentId, taskId)
|
|
6375
7650
|
.catch(() => false);
|
|
6376
7651
|
if (!released) {
|
|
7652
|
+
await releaseFreshAcquire();
|
|
6377
7653
|
await rearmReviewedSignal();
|
|
6378
7654
|
await this.emitIntervention(projectId, qaAgentId, taskId, { phase: 'server-fix-qa-release-failed', qaAgentId });
|
|
6379
7655
|
return null;
|
|
@@ -6381,6 +7657,7 @@ export class AgentManager {
|
|
|
6381
7657
|
}
|
|
6382
7658
|
const transition = await this.transitionTaskStatus(taskId, 'fixing', { fromStatus: ['review', 'max_rounds', 'spec-ready', 'ready'] }, { signalToken: newToken, fixDispatchedAt: new Date().toISOString() });
|
|
6383
7659
|
if (!transition) {
|
|
7660
|
+
await releaseFreshAcquire();
|
|
6384
7661
|
await this.emitIntervention(projectId, devAgentId, taskId, { phase: 'server-fix-transition-failed', devAgentId });
|
|
6385
7662
|
return null;
|
|
6386
7663
|
}
|