baxian 1.2.8 → 1.2.10
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/manager.d.ts +18 -0
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +374 -42
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/prompt.d.ts +1 -2
- package/dist/agent/prompt.d.ts.map +1 -1
- package/dist/agent/prompt.js +68 -58
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +4 -2
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tmux.d.ts +1 -0
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +4 -0
- package/dist/agent/tmux.js.map +1 -1
- package/dist/shared/constants.js +13 -13
- package/dist/shared/constants.js.map +1 -1
- package/dist/skill/registry.d.ts +3 -0
- package/dist/skill/registry.d.ts.map +1 -1
- package/dist/skill/registry.js +49 -5
- package/dist/skill/registry.js.map +1 -1
- package/dist/skills/{pr-feedback → baxian-pr-feedback}/SKILL.md +2 -1
- package/dist/skills/baxian-pr-feedback/agents/openai.yaml +2 -0
- package/dist/skills/{pr-recheck → baxian-pr-recheck}/SKILL.md +2 -1
- package/dist/skills/baxian-pr-recheck/agents/openai.yaml +2 -0
- package/dist/skills/{pr-review → baxian-pr-review}/SKILL.md +2 -1
- package/dist/skills/baxian-pr-review/agents/openai.yaml +2 -0
- package/dist/skills/{task-check → baxian-task-check}/SKILL.md +4 -3
- package/dist/skills/baxian-task-check/agents/openai.yaml +2 -0
- package/dist/web/assets/index-D29eOcxi.js +4 -0
- package/dist/web/assets/{react-BG4Iuztk.js → react-BFCkCmbU.js} +7 -7
- package/dist/web/assets/{router-eEZdpwQZ.js → router-D24GsdXZ.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/dist/skills/baxian-rules/SKILL.md +0 -18
- package/dist/skills/server-feedback/SKILL.md +0 -34
- package/dist/skills/server-recheck/SKILL.md +0 -30
- package/dist/skills/server-review/SKILL.md +0 -43
- package/dist/skills/server-spec-review/SKILL.md +0 -31
- package/dist/skills/spells/SKILL.md +0 -8
- package/dist/web/assets/index-6OPCjoD6.js +0 -4
package/dist/agent/manager.js
CHANGED
|
@@ -6,7 +6,7 @@ import { BRANCH_PREFIX, isValidBranchName, PHASE_EXPECTED_STATUS, PHASE_REQUIRES
|
|
|
6
6
|
import { AGENT_STORE_NOOP } from '../state/agent-store.js';
|
|
7
7
|
import { PostApproveStore } from '../state/post-approve-store.js';
|
|
8
8
|
import { SkillRegistry } from '../skill/registry.js';
|
|
9
|
-
import { createRunner, LocalRunner, shellQuote, resolveAgentHost } from './runner.js';
|
|
9
|
+
import { createRunner, LocalRunner, shellQuote, resolveAgentHost, hostGroupKey } from './runner.js';
|
|
10
10
|
import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
|
|
11
11
|
import { TmuxManager, ReplNotReadyError, detectStartupDialog, detectRuntimeMenu, runtimeBusyCheck, hasRuntimeReadyView, hasReplProcTitle, } from './tmux.js';
|
|
12
12
|
import { WorktreeManager } from './worktree.js';
|
|
@@ -87,8 +87,24 @@ export function canDispatchWithBinding(binding) {
|
|
|
87
87
|
// recover 路径)都可能让第二个 prompt 派进同 pane 与旧 turn 混在一起。outcome handler 通过显式
|
|
88
88
|
// allowAwaitingHuman:true release,gate 单点放行。
|
|
89
89
|
const TURN_COMPLETED_AWAITING_PHASES = new Set();
|
|
90
|
+
// Pane is stopped (interrupt landed) but its session was not cleared: cancel is mid /clear
|
|
91
|
+
// (`cancel-clearing`) or /clear was unconfirmed (`cancel-clear-failed`). Resume can't fix it (it doesn't
|
|
92
|
+
// /clear), so these are DELETE-only: shouldReleaseHeldBinding returns false → recover()/escape/Resume all
|
|
93
|
+
// refuse. Persisted, so the protection survives a restart mid-cleanup.
|
|
94
|
+
const UNCLEARED_PANE_PHASES = new Set(['cancel-clearing', 'cancel-clear-failed']);
|
|
95
|
+
// All cancel-cleanup holds (the un-cleared ones plus `cancel-interrupt-failed`, where the interrupt failed
|
|
96
|
+
// so the pane may still be running the cancelled task). None may be AUTO-released — not by recover(), not by
|
|
97
|
+
// a terminal-task escape, not even by an allowAwaitingHuman caller — because that would reuse the cancelled
|
|
98
|
+
// session. Only cancel's own confirmed-/clear release (fromCancelCleanup) frees one automatically; the
|
|
99
|
+
// operator recovers via Resume (cancel-interrupt-failed only, after verifying) or DELETE (any).
|
|
100
|
+
const CANCEL_CLEANUP_HOLD_PHASES = new Set([...UNCLEARED_PANE_PHASES, 'cancel-interrupt-failed']);
|
|
101
|
+
// A prompt line still holding the typed `/clear` (e.g. `❯ /clear`, `› /clear`) = the Enter was swallowed,
|
|
102
|
+
// so /clear was never submitted. After a real submission /clear wipes the screen and the composer is empty.
|
|
103
|
+
const CLEAR_PENDING_IN_COMPOSER_RE = /(?:^|\n)[ \t]*[❯>›→][ \t]*\/clear\b/;
|
|
90
104
|
// Resume / recover 共用:决定 Held agent 的 binding 是否随状态恢复一起清掉。
|
|
91
105
|
export function shouldReleaseHeldBinding(state, boundTask) {
|
|
106
|
+
if (state.awaitingPhase != null && UNCLEARED_PANE_PHASES.has(state.awaitingPhase))
|
|
107
|
+
return false;
|
|
92
108
|
const taskIsTerminal = !!boundTask && TERMINAL_STATUSES.includes(boundTask.status);
|
|
93
109
|
const turnCompleted = state.awaitingPhase != null && TURN_COMPLETED_AWAITING_PHASES.has(state.awaitingPhase);
|
|
94
110
|
return !boundTask || taskIsTerminal || turnCompleted;
|
|
@@ -134,6 +150,7 @@ export class AgentManager {
|
|
|
134
150
|
compactIdleWaitMs = 5 * 60_000;
|
|
135
151
|
compactIdlePollMs = 2_000;
|
|
136
152
|
manualCompactWaitMs = 5_000;
|
|
153
|
+
clearContextWaitMs = 30_000;
|
|
137
154
|
postMergeFetchTimeoutMs = 60_000;
|
|
138
155
|
postMergeBranchTimeoutMs = 10_000;
|
|
139
156
|
// taskIds with in-flight manual review — second concurrent POST gets 409.
|
|
@@ -311,6 +328,16 @@ export class AgentManager {
|
|
|
311
328
|
catch (err) {
|
|
312
329
|
throw new EnsureSessionError({ createdSession: false, agentId }, `ensureWorkdir failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
313
330
|
}
|
|
331
|
+
// Skills must be on disk at the repo root before the REPL launches OR is reused,
|
|
332
|
+
// so native discovery sees them and the dispatch's /skill / $skill resolves. Runs
|
|
333
|
+
// on every path — fresh launch, adopt of a live runtime, and shell/REPL restart —
|
|
334
|
+
// not just buildFreshSession; the version marker keeps the steady state a single cat.
|
|
335
|
+
try {
|
|
336
|
+
await this.provisionRepoSkills(runner, agent, workdir);
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `skill provisioning failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
340
|
+
}
|
|
314
341
|
let alive;
|
|
315
342
|
try {
|
|
316
343
|
alive = await tmux.hasSession(agentId);
|
|
@@ -327,6 +354,130 @@ export class AgentManager {
|
|
|
327
354
|
}
|
|
328
355
|
return this.buildFreshSession(tmux, agent, agentId, workdir);
|
|
329
356
|
}
|
|
357
|
+
// Materialize baxian skills into the repo root the REPL launches in (cwd at
|
|
358
|
+
// launch), so the agent's `claude`/`codex` discovers them as native skills and
|
|
359
|
+
// the dispatch can force-load one with `/skill` / `$skill`. Each file is written
|
|
360
|
+
// atomically (stage + rename) and a current skill's dir is never removed, so a
|
|
361
|
+
// concurrent agent's lazy SKILL.md read never observes an absent/partial file.
|
|
362
|
+
async provisionRepoSkills(runner, agent, workdir) {
|
|
363
|
+
if (this.skillRegistry.names().length === 0)
|
|
364
|
+
return;
|
|
365
|
+
const subdir = agent.runtime === 'codex' ? '.agents/skills' : '.claude/skills';
|
|
366
|
+
const destRoot = `${workdir}/${subdir}`;
|
|
367
|
+
// Re-run on EVERY dispatch — do NOT cache the result. Config hot-reload
|
|
368
|
+
// (replaceConfig, no restart) can repoint this agent's workdir/runtime to another
|
|
369
|
+
// repo / skills dir, and repo code or a prior agent turn can tamper the on-disk
|
|
370
|
+
// skill tree between dispatches; a skip cache (in-memory or on-disk) would then
|
|
371
|
+
// serve a missing or repo-controlled tree. The files are tiny, so materialize
|
|
372
|
+
// unconditionally. The cleanup + git-exclude are idempotent + best-effort.
|
|
373
|
+
await this.excludeInjectedSkills(runner, workdir, subdir);
|
|
374
|
+
// Serialize the cleanup+materialize per target skills dir (shared with the launch-time
|
|
375
|
+
// scan in buildFreshSession): two same-runtime agents on one repo would otherwise let
|
|
376
|
+
// one's `rm` blank the tree while the other materializes or its REPL scans for skills.
|
|
377
|
+
await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
|
|
378
|
+
await this.ensureSkillDirSafe(runner, workdir, subdir);
|
|
379
|
+
await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, path, content), destRoot);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
// Per (host, workdir, runtime-subdir) in-process lock. Both the cleanup+materialize
|
|
383
|
+
// and the fresh REPL launch (which scans the skills dir at startup) run under it, so a
|
|
384
|
+
// concurrent same-dir agent never observes a transiently-empty skills tree.
|
|
385
|
+
skillDirChain = new Map();
|
|
386
|
+
skillDirLockKey(agent, workdir) {
|
|
387
|
+
// Canonicalize the host (a registry id and an equivalent inline host, or a blank vs default
|
|
388
|
+
// port, must collapse to one key) and the workdir (a trailing slash must not fork the lock),
|
|
389
|
+
// so two agents truly pointing at the same physical dir serialize instead of racing.
|
|
390
|
+
const host = hostGroupKey(agent.mode, resolveAgentHost(this.config.host, agent.host));
|
|
391
|
+
const subdir = agent.runtime === 'codex' ? '.agents/skills' : '.claude/skills';
|
|
392
|
+
const dir = workdir.replace(/\/+$/, '');
|
|
393
|
+
return `${host}:${dir}:${subdir}`;
|
|
394
|
+
}
|
|
395
|
+
runUnderSkillDirLock(key, fn) {
|
|
396
|
+
const prev = this.skillDirChain.get(key) ?? Promise.resolve();
|
|
397
|
+
const run = prev.then(fn, fn);
|
|
398
|
+
this.skillDirChain.set(key, run.then(() => undefined, () => undefined));
|
|
399
|
+
return run;
|
|
400
|
+
}
|
|
401
|
+
// Atomic per-file replace. materialize() hands us each skill file's FINAL path; we stage it as a
|
|
402
|
+
// sibling `.baxian-tmp` and `mv -f` it into place. POSIX rename is atomic, so a claude/codex lazy
|
|
403
|
+
// SKILL.md body read — which happens at `/baxian-*` / `$baxian-*` INVOKE time, after this dir's
|
|
404
|
+
// provisioning lock has already been released — sees either the complete old file or the complete
|
|
405
|
+
// new one, never the truncate-in-place window of a bare writeFile or the blank window of a
|
|
406
|
+
// delete-then-rewrite. The tmp lives inside the `baxian-*` leaf, so the git-exclude rule covers it.
|
|
407
|
+
async atomicWriteFile(runner, finalPath, content) {
|
|
408
|
+
const tmp = `${finalPath}.baxian-tmp`;
|
|
409
|
+
await runner.writeFile(tmp, content);
|
|
410
|
+
const mv = `mv -f ${shellQuote(tmp)} ${shellQuote(finalPath)}`;
|
|
411
|
+
const res = await runner.exec(`sh -c ${shellQuote(mv)}`);
|
|
412
|
+
if (res.exitCode !== 0) {
|
|
413
|
+
throw new Error(`atomic skill write failed (${finalPath}): ${res.stderr || 'unknown error'}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Make the skills subtree symlink-safe before writing into it, WITHOUT blanking a live skill. A
|
|
417
|
+
// current skill's dir is left in place (its files are swapped atomically by atomicWriteFile); we
|
|
418
|
+
// prune `baxian-*` dirs no longer in the registry, strip EVERY symlink anywhere under a `baxian-*`
|
|
419
|
+
// tree (leaf OR a nested component like `baxian-pr-review/agents`) so the atomic write can't be
|
|
420
|
+
// redirected out of the workdir, and drop stale helper files left by a past skill version (a
|
|
421
|
+
// removed/renamed file) — SKILL.md is kept so a concurrent lazy read is never blanked, and
|
|
422
|
+
// materialize re-writes every current file atomically. The PARENT components (`.claude`/`.agents`
|
|
423
|
+
// + their `skills` subdir) fail fast when they are symlinks: following one could write OUTSIDE the
|
|
424
|
+
// workdir, and silently rm-ing it would destroy a user's legitimate symlinked skills dir (codex
|
|
425
|
+
// documents symlinked skill folders as supported). `find -name`/`-path` (not a bare glob) avoids
|
|
426
|
+
// zsh NOMATCH; the whole thing runs under POSIX `sh -c` since wrapRemoteCommand otherwise uses the
|
|
427
|
+
// login shell (maybe fish). `top`/`subdir` are fixed constants; names are baxian-owned slugs.
|
|
428
|
+
async ensureSkillDirSafe(runner, workdir, subdir) {
|
|
429
|
+
const top = subdir.split('/')[0];
|
|
430
|
+
const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
|
|
431
|
+
const inner = `cd ${shellQuote(workdir)} && ` +
|
|
432
|
+
`for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
|
|
433
|
+
`printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
|
|
434
|
+
`exit 3; fi; done && ` +
|
|
435
|
+
`mkdir -p ${subdir} && ` +
|
|
436
|
+
`find ${subdir} -maxdepth 1 -name 'baxian-*' ${keep} -exec rm -rf {} + && ` +
|
|
437
|
+
`find ${subdir} -path '${subdir}/baxian-*' -type l -exec rm -f {} + && ` +
|
|
438
|
+
`find ${subdir} -path '${subdir}/baxian-*/*' -type f ! -name 'SKILL.md' -exec rm -f {} +`;
|
|
439
|
+
const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
|
|
440
|
+
if (res.exitCode !== 0) {
|
|
441
|
+
throw new Error(`failed to prepare a symlink-safe ${subdir} in ${workdir}: ${res.stderr || 'unknown error'}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// Tag a freshly-launched session with the skills version it discovered at launch, so
|
|
445
|
+
// adoptOrRestartSession can tell when a live REPL predates the current skills.
|
|
446
|
+
async tagSessionSkillsVersion(tmux, agentId) {
|
|
447
|
+
if (this.skillRegistry.names().length === 0)
|
|
448
|
+
return;
|
|
449
|
+
await tmux.setOption(agentId, '@baxian-skills-version', this.skillRegistry.contentHash());
|
|
450
|
+
}
|
|
451
|
+
// True when a live REPL's launch-time skills version differs from the current one
|
|
452
|
+
// (or is absent — a pre-skills session): it cannot resolve a dispatched /baxian-*.
|
|
453
|
+
async replSkillsStale(tmux, agentId) {
|
|
454
|
+
if (this.skillRegistry.names().length === 0)
|
|
455
|
+
return false;
|
|
456
|
+
// getOption already maps a MISSING tag to null (→ stale). Do NOT swallow other
|
|
457
|
+
// errors: a thrown tmux probe failure must propagate so the caller surfaces it as an
|
|
458
|
+
// EnsureSessionError, instead of being read as stale and needlessly killing the REPL.
|
|
459
|
+
const tagged = await tmux.getOption(agentId, '@baxian-skills-version');
|
|
460
|
+
return tagged !== this.skillRegistry.contentHash();
|
|
461
|
+
}
|
|
462
|
+
// Hide ONLY what baxian writes — the `baxian-*` skill dirs — from the agent's
|
|
463
|
+
// `git status` / PRs. Excluding the whole skills dir would also hide a user repo's own
|
|
464
|
+
// untracked native skills there, defeating the `baxian-` prefix's coexistence intent.
|
|
465
|
+
// The `if git rev-parse` guard skips a non-git workdir, and failure only warns: skills
|
|
466
|
+
// are already on disk, so a git hiccup must not block the session.
|
|
467
|
+
async excludeInjectedSkills(runner, workdir, subdir) {
|
|
468
|
+
// info/exclude patterns anchor at the REPO ROOT, but the skills dir lives at the
|
|
469
|
+
// workdir; when workdir is a SUBDIR of the repo, prefix the rule with the workdir's
|
|
470
|
+
// path relative to the repo root (git rev-parse --show-prefix) so the pattern matches.
|
|
471
|
+
const inner = `cd ${shellQuote(workdir)} && if p="$(git rev-parse --git-path info/exclude 2>/dev/null)"; then ` +
|
|
472
|
+
`pre="$(git rev-parse --show-prefix 2>/dev/null)"; rule="\${pre}${subdir}/baxian-*"; ` +
|
|
473
|
+
`mkdir -p "$(dirname "$p")" && { grep -qxF "$rule" "$p" 2>/dev/null || printf '%s\\n' "$rule" >> "$p"; }; fi`;
|
|
474
|
+
// Run under POSIX sh (if/then/fi + $() are not fish syntax; wrapRemoteCommand uses $SHELL).
|
|
475
|
+
const res = await runner.exec(`sh -c ${shellQuote(inner)}`);
|
|
476
|
+
if (res.exitCode !== 0) {
|
|
477
|
+
console.warn(`[AgentManager] skill info/exclude best-effort failed in ${workdir} ` +
|
|
478
|
+
`(skills still materialized): ${res.stderr || 'unknown error'}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
330
481
|
async pinRuntimeSessionOptions(tmux, agentId) {
|
|
331
482
|
await tmux.setOption(agentId, 'prefix', 'C-b');
|
|
332
483
|
await tmux.setOption(agentId, 'prefix2', 'None');
|
|
@@ -338,6 +489,11 @@ export class AgentManager {
|
|
|
338
489
|
await this.pinRuntimeSessionOptions(tmux, agentId);
|
|
339
490
|
}
|
|
340
491
|
async buildFreshSession(tmux, agent, agentId, workdir) {
|
|
492
|
+
// Hold the per-skills-dir lock across the launch so the REPL's startup skill scan
|
|
493
|
+
// can't overlap a concurrent same-dir agent's provisioning rm (see provisionRepoSkills).
|
|
494
|
+
return this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir));
|
|
495
|
+
}
|
|
496
|
+
async buildFreshSessionLocked(tmux, agent, agentId, workdir) {
|
|
341
497
|
let createdSession = false;
|
|
342
498
|
const runtime = agentRuntimeKindFor(agent);
|
|
343
499
|
try {
|
|
@@ -361,6 +517,7 @@ export class AgentManager {
|
|
|
361
517
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
362
518
|
scrollback: 0,
|
|
363
519
|
});
|
|
520
|
+
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
364
521
|
return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir };
|
|
365
522
|
}
|
|
366
523
|
catch (err) {
|
|
@@ -923,9 +1080,25 @@ export class AgentManager {
|
|
|
923
1080
|
throw new EnsureSessionError({ createdSession: false, agentId }, `classifyPaneForAdopt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
924
1081
|
}
|
|
925
1082
|
switch (state.kind) {
|
|
926
|
-
case 'live-runtime':
|
|
1083
|
+
case 'live-runtime': {
|
|
1084
|
+
let stale;
|
|
1085
|
+
try {
|
|
1086
|
+
stale = await this.replSkillsStale(tmux, agentId);
|
|
1087
|
+
}
|
|
1088
|
+
catch (err) {
|
|
1089
|
+
// A tmux probe failure here is transient — surface it, do NOT kill the REPL.
|
|
1090
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `skills-version probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1091
|
+
}
|
|
1092
|
+
if (stale) {
|
|
1093
|
+
// This REPL launched before the current skills were on disk; claude/codex only
|
|
1094
|
+
// discover a freshly-created top-level skills dir at launch, so a dispatched
|
|
1095
|
+
// /baxian-* / $baxian-* would not resolve. Rebuild so the command works.
|
|
1096
|
+
await tmux.killSession(agentId).catch(() => { });
|
|
1097
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir);
|
|
1098
|
+
}
|
|
927
1099
|
// 复用既有 REPL,上下文未中断——dedup 仍可沿用。
|
|
928
1100
|
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
|
|
1101
|
+
}
|
|
929
1102
|
case 'startup-dialog':
|
|
930
1103
|
throw new EnsureSessionError({
|
|
931
1104
|
createdSession: false,
|
|
@@ -949,6 +1122,7 @@ export class AgentManager {
|
|
|
949
1122
|
scrollback: 0,
|
|
950
1123
|
});
|
|
951
1124
|
// 信任弹窗刚被答完,REPL 从启动态进入可用——上下文是新的。
|
|
1125
|
+
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
952
1126
|
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
|
|
953
1127
|
}
|
|
954
1128
|
catch (trustErr) {
|
|
@@ -976,6 +1150,7 @@ export class AgentManager {
|
|
|
976
1150
|
scrollback: 0,
|
|
977
1151
|
});
|
|
978
1152
|
// shell 路径:在原 pane 里重新启动了 REPL,新进程没有旧 prompt 上下文。
|
|
1153
|
+
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
979
1154
|
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
|
|
980
1155
|
}
|
|
981
1156
|
catch (relErr) {
|
|
@@ -1038,6 +1213,17 @@ export class AgentManager {
|
|
|
1038
1213
|
`(expected ${expectedTaskId}, got ${state.taskId}); skipping`);
|
|
1039
1214
|
return false;
|
|
1040
1215
|
}
|
|
1216
|
+
// Cancel-cleanup hold: only cancel's own release may free it. Checked BEFORE the allowAwaitingHuman
|
|
1217
|
+
// gate below, because that gate skips shouldReleaseHeldBinding entirely — so without this an
|
|
1218
|
+
// allowAwaitingHuman caller (startup false-start, review/max-rounds handlers, a terminal-task escape)
|
|
1219
|
+
// could reassign the un-cleared/maybe-running pane before cancel confirms /clear. (Operator recovery
|
|
1220
|
+
// via resumeAgent / DELETE doesn't go through this path.)
|
|
1221
|
+
if (state.awaitingPhase != null
|
|
1222
|
+
&& CANCEL_CLEANUP_HOLD_PHASES.has(state.awaitingPhase)
|
|
1223
|
+
&& !opts.fromCancelCleanup) {
|
|
1224
|
+
console.warn(`[AgentManager] releaseAgentForTask: agent ${agentId} ${state.awaitingPhase} (cancel-cleanup hold); refusing auto-release`);
|
|
1225
|
+
return false;
|
|
1226
|
+
}
|
|
1041
1227
|
const boundTask = await this.taskStore.get(expectedTaskId);
|
|
1042
1228
|
if (state.status === 'awaiting_human' && !opts.allowAwaitingHuman) {
|
|
1043
1229
|
// gate 例外:bound task 已 terminal / turn-completed phase 都属于正常 cleanup 路径,
|
|
@@ -1268,6 +1454,13 @@ export class AgentManager {
|
|
|
1268
1454
|
console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} with active task ${state.taskId} — the dispatched prompt's pane signal has no consumer and Resume cannot rebuild the watcher; refusing Resume. Operator should cancel the task or DELETE the agent.`);
|
|
1269
1455
|
return { resumed: false, releasedBinding: false };
|
|
1270
1456
|
}
|
|
1457
|
+
// Un-cleared pane (cancel mid-clear or /clear unconfirmed): Resume would free + reuse it (terminal
|
|
1458
|
+
// task → shouldReleaseHeldBinding) and leak the cancelled task's context. Refuse; only DELETE (which
|
|
1459
|
+
// destroys the pane) is a safe recovery.
|
|
1460
|
+
if (state.awaitingPhase != null && UNCLEARED_PANE_PHASES.has(state.awaitingPhase)) {
|
|
1461
|
+
console.warn(`[AgentManager] resumeAgent: agent ${agentId} ${state.awaitingPhase} — pane holds un-cleared context; refusing Resume. DELETE the agent to discard it.`);
|
|
1462
|
+
return { resumed: false, releasedBinding: false };
|
|
1463
|
+
}
|
|
1271
1464
|
const now = new Date().toISOString();
|
|
1272
1465
|
const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask);
|
|
1273
1466
|
const cfg = this.getAgentConfig(agentId);
|
|
@@ -1339,25 +1532,29 @@ export class AgentManager {
|
|
|
1339
1532
|
}
|
|
1340
1533
|
return { resumed: result.resumed, releasedBinding: result.releasedBinding };
|
|
1341
1534
|
}
|
|
1342
|
-
async
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
console.warn(`[AgentManager] interruptPaneAndWaitReady: getSinglePaneId failed for ${cfg.id}:`, err);
|
|
1352
|
-
return false;
|
|
1353
|
-
}
|
|
1535
|
+
async resolvePaneId(state, cfg) {
|
|
1536
|
+
if (state.paneId)
|
|
1537
|
+
return state.paneId;
|
|
1538
|
+
try {
|
|
1539
|
+
return await new TmuxManager(this.createRunnerFor(cfg)).getSinglePaneId(cfg.id);
|
|
1540
|
+
}
|
|
1541
|
+
catch (err) {
|
|
1542
|
+
console.warn(`[AgentManager] resolvePaneId: getSinglePaneId failed for ${cfg.id}:`, err);
|
|
1543
|
+
return null;
|
|
1354
1544
|
}
|
|
1545
|
+
}
|
|
1546
|
+
// ESC (not Ctrl-C) is the interrupt key both runtimes advertise; returns whether the pane reached idle.
|
|
1547
|
+
async interruptPaneAndWaitReady(state, cfg) {
|
|
1548
|
+
const paneId = await this.resolvePaneId(state, cfg);
|
|
1549
|
+
if (!paneId)
|
|
1550
|
+
return false;
|
|
1551
|
+
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
1355
1552
|
try {
|
|
1356
|
-
await tmux.sendKeysToPane(paneId, '
|
|
1553
|
+
await tmux.sendKeysToPane(paneId, 'Escape');
|
|
1357
1554
|
await new Promise(r => setTimeout(r, 200));
|
|
1358
1555
|
}
|
|
1359
1556
|
catch (err) {
|
|
1360
|
-
console.warn(`[AgentManager] interruptPaneAndWaitReady: send
|
|
1557
|
+
console.warn(`[AgentManager] interruptPaneAndWaitReady: send Escape failed for pane ${paneId}:`, err);
|
|
1361
1558
|
return false;
|
|
1362
1559
|
}
|
|
1363
1560
|
try {
|
|
@@ -1372,6 +1569,72 @@ export class AgentManager {
|
|
|
1372
1569
|
return false;
|
|
1373
1570
|
}
|
|
1374
1571
|
}
|
|
1572
|
+
// Persist a "cancel is interrupting + /clearing this pane" hold BEFORE the ESC→/clear window so the
|
|
1573
|
+
// protection survives a restart (recover() holds UNCLEARED_PANE_PHASES) and the escape can't reassign the
|
|
1574
|
+
// un-cleared pane. Direct update (no intervention event): a normal cancel clears it on release; only a
|
|
1575
|
+
// crash/failure leaves it. Conditional on the binding so a stale cancel can't mark an agent rebound away.
|
|
1576
|
+
async markPaneCancelClearing(agentId, taskId) {
|
|
1577
|
+
const now = new Date().toISOString();
|
|
1578
|
+
await this.agentStore.update(agentId, (latest) => {
|
|
1579
|
+
if (!latest || latest.taskId !== taskId)
|
|
1580
|
+
return AGENT_STORE_NOOP;
|
|
1581
|
+
return {
|
|
1582
|
+
...latest,
|
|
1583
|
+
status: 'awaiting_human',
|
|
1584
|
+
awaitingPhase: 'cancel-clearing',
|
|
1585
|
+
awaitingReason: 'Cancelling: interrupting and clearing the runtime session; not reusable until /clear is confirmed or the agent is deleted.',
|
|
1586
|
+
awaitingSince: now,
|
|
1587
|
+
updatedAt: now,
|
|
1588
|
+
};
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
// Returns whether /clear was confirmed (a real busy→idle, not the stale pre-/clear idle frame); the
|
|
1592
|
+
// caller must hold the agent, not release it, on false or the un-cleared context leaks to the next dispatch.
|
|
1593
|
+
async clearPaneContext(state, cfg) {
|
|
1594
|
+
const paneId = await this.resolvePaneId(state, cfg);
|
|
1595
|
+
if (!paneId)
|
|
1596
|
+
return false;
|
|
1597
|
+
if (!this.tryAcquireCompactGuard(cfg.id)) {
|
|
1598
|
+
console.warn(`[AgentManager] clearPaneContext: ${cfg.id} compact/upload in progress; cannot /clear`);
|
|
1599
|
+
return false;
|
|
1600
|
+
}
|
|
1601
|
+
try {
|
|
1602
|
+
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
1603
|
+
const runtime = agentRuntimeKindFor(cfg);
|
|
1604
|
+
// C-c clears any prompt an ack-timeout left in the composer, so /clear isn't appended to it and submitted.
|
|
1605
|
+
await tmux.sendKeysToPane(paneId, 'C-c');
|
|
1606
|
+
await this.waitForReplPromptReady(tmux, paneId, runtime, this.clearContextWaitMs);
|
|
1607
|
+
await tmux.sendKeysLiteral(paneId, '/clear');
|
|
1608
|
+
// Snapshot the composer holding the typed /clear; require submission proof so a swallowed Enter
|
|
1609
|
+
// (which would leave /clear idle in the composer and let waitForReplPromptReady pass on the stale
|
|
1610
|
+
// frame) is resent rather than treated as cleared.
|
|
1611
|
+
const beforeSubmit = await tmux.capturePaneSnapshot(paneId);
|
|
1612
|
+
await tmux.sendEnter(paneId);
|
|
1613
|
+
await tmux.waitSubmitAck(paneId, beforeSubmit, runtime, {
|
|
1614
|
+
timeoutMs: this.clearContextWaitMs,
|
|
1615
|
+
acceptComposerChange: true,
|
|
1616
|
+
resend: () => tmux.sendEnter(paneId),
|
|
1617
|
+
resendIntervalMs: this.compactIdlePollMs,
|
|
1618
|
+
});
|
|
1619
|
+
await this.waitForReplPromptReady(tmux, paneId, runtime, this.clearContextWaitMs);
|
|
1620
|
+
// Positively confirm the composer is empty: a ready anchor alone can sit above a composer that still
|
|
1621
|
+
// holds the typed /clear (e.g. acceptComposerChange returned on an unrelated redraw). If /clear is
|
|
1622
|
+
// still parked there, it was never submitted — treat as unconfirmed so the caller holds the pane.
|
|
1623
|
+
const afterClear = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
|
|
1624
|
+
if (CLEAR_PENDING_IN_COMPOSER_RE.test(afterClear)) {
|
|
1625
|
+
console.warn(`[AgentManager] clearPaneContext: /clear still in composer for ${cfg.id}; unconfirmed`);
|
|
1626
|
+
return false;
|
|
1627
|
+
}
|
|
1628
|
+
return true;
|
|
1629
|
+
}
|
|
1630
|
+
catch (err) {
|
|
1631
|
+
console.warn(`[AgentManager] clearPaneContext: /clear failed for ${cfg.id}:`, err);
|
|
1632
|
+
return false;
|
|
1633
|
+
}
|
|
1634
|
+
finally {
|
|
1635
|
+
this.compactInFlight.delete(cfg.id);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1375
1638
|
async failTaskForDispatchError(taskId, phase, agentId, err) {
|
|
1376
1639
|
const expected = PHASE_EXPECTED_STATUS[phase] ?? [];
|
|
1377
1640
|
const transitioned = await this.transitionTaskStatus(taskId, 'failed', { fromStatus: expected.length > 0 ? expected : ['in_progress', 'review', 'fixing', 'approved', 'merge-ready'] });
|
|
@@ -1747,7 +2010,7 @@ export class AgentManager {
|
|
|
1747
2010
|
return cleared;
|
|
1748
2011
|
});
|
|
1749
2012
|
}
|
|
1750
|
-
//
|
|
2013
|
+
// Recovery snapshot replay is safe because the completion token is cleared after merge-ready.
|
|
1751
2014
|
async setupRecoveredPostApproveSignals() {
|
|
1752
2015
|
if (!this.phaseSignalWatcher)
|
|
1753
2016
|
return;
|
|
@@ -1756,8 +2019,6 @@ export class AgentManager {
|
|
|
1756
2019
|
const completion = await this.postApproveStore.get(task.id);
|
|
1757
2020
|
if (!completion)
|
|
1758
2021
|
continue;
|
|
1759
|
-
const project = this.getProjectConfig(task.projectId);
|
|
1760
|
-
const skipSnapshot = project?.merge !== 'auto';
|
|
1761
2022
|
try {
|
|
1762
2023
|
await this.phaseSignalWatcher.start({
|
|
1763
2024
|
taskId: task.id,
|
|
@@ -1765,7 +2026,7 @@ export class AgentManager {
|
|
|
1765
2026
|
agentId: task.agentId,
|
|
1766
2027
|
expectedKinds: 'pr-merge-ready',
|
|
1767
2028
|
token: completion.token,
|
|
1768
|
-
skipSnapshot,
|
|
2029
|
+
skipSnapshot: false,
|
|
1769
2030
|
recovered: true,
|
|
1770
2031
|
});
|
|
1771
2032
|
}
|
|
@@ -2316,11 +2577,26 @@ export class AgentManager {
|
|
|
2316
2577
|
if (!fresh || TERMINAL_STATUSES.includes(fresh.status)) {
|
|
2317
2578
|
const cfg = this.getAgentConfig(agentId);
|
|
2318
2579
|
const state = await this.agentStore.get(agentId);
|
|
2319
|
-
if (cfg && state && state.taskId === taskId
|
|
2320
|
-
|
|
2321
|
-
|
|
2580
|
+
if (cfg && state && state.taskId === taskId) {
|
|
2581
|
+
// Persist the un-cleared hold before the ESC→/clear window so a restart mid-cleanup recovers it
|
|
2582
|
+
// held instead of releasing the still-dirty pane (mirrors cancelTask).
|
|
2583
|
+
await this.markPaneCancelClearing(agentId, taskId);
|
|
2584
|
+
if (!(await this.interruptPaneAndWaitReady(state, cfg))) {
|
|
2585
|
+
await this.markAwaitingHuman(agentId, 'cancel-interrupt-failed', 'Task was cancelled during startup but ESC / REPL ready check failed; the agent may still be ' +
|
|
2586
|
+
'running the cancelled prompt. Attach via web terminal to verify, then Resume or Delete.', { expectedTaskId: taskId });
|
|
2587
|
+
return null;
|
|
2588
|
+
}
|
|
2589
|
+
if (!(await this.clearPaneContext(state, cfg))) {
|
|
2590
|
+
await this.markAwaitingHuman(agentId, 'cancel-clear-failed', 'Task was cancelled during startup and the session interrupted, but /clear was not confirmed; ' +
|
|
2591
|
+
'the pane holds un-cleared context. DELETE the agent to discard it (Resume will not reuse an un-cleared pane).', { expectedTaskId: taskId });
|
|
2592
|
+
return null;
|
|
2593
|
+
}
|
|
2594
|
+
// /clear confirmed → cancel cleanup is done; free the cancel-clearing hold (only path allowed to).
|
|
2595
|
+
await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true, fromCancelCleanup: true });
|
|
2322
2596
|
return null;
|
|
2323
2597
|
}
|
|
2598
|
+
// No live pane to clean (agent gone / rebound): release without the cancel-cleanup bypass — if it
|
|
2599
|
+
// somehow holds an un-cleared phase, refusing here keeps the dirty pane for the owning cancel.
|
|
2324
2600
|
await this.releaseAgentForTask(agentId, taskId, 'idle', { allowAwaitingHuman: true });
|
|
2325
2601
|
return null;
|
|
2326
2602
|
}
|
|
@@ -3320,7 +3596,11 @@ export class AgentManager {
|
|
|
3320
3596
|
console.warn(`[recover] dispatchPostMergeCleanup(${state.id}, ${boundTask.id}) failed:`, cleanupErr);
|
|
3321
3597
|
}
|
|
3322
3598
|
}
|
|
3323
|
-
|
|
3599
|
+
// A cancel-cleanup hold must NOT be auto-released on restart (it would reuse the cancelled,
|
|
3600
|
+
// un-cleared/maybe-running pane). cancel-interrupt-failed has shouldReleaseHeldBinding=true (it's
|
|
3601
|
+
// operator-Resume recoverable), so exclude the whole cancel-cleanup set here explicitly.
|
|
3602
|
+
const cancelHold = state.awaitingPhase != null && CANCEL_CLEANUP_HOLD_PHASES.has(state.awaitingPhase);
|
|
3603
|
+
const shouldReleaseBinding = shouldReleaseHeldBinding(state, boundTask) && !cancelHold;
|
|
3324
3604
|
// 释放 binding 时同步清 worktree(与 resumeAgent 一致)——否则跨重启恢复后
|
|
3325
3605
|
// worktreePath 在下面 update 中被丢弃,磁盘上的 worktree 永远无人回收。
|
|
3326
3606
|
if (shouldReleaseBinding && state.worktreePath) {
|
|
@@ -3378,7 +3658,9 @@ export class AgentManager {
|
|
|
3378
3658
|
if (shouldReleaseBinding) {
|
|
3379
3659
|
await this.lockManager.release(state.id);
|
|
3380
3660
|
}
|
|
3381
|
-
|
|
3661
|
+
// Skip the menu-watch for cancel-cleanup holds: they await operator Resume/DELETE, and their taskId
|
|
3662
|
+
// won't clear on its own, so the watcher would poll forever.
|
|
3663
|
+
if (state.taskId && !shouldReleaseBinding && !cancelHold) {
|
|
3382
3664
|
this.startRuntimeMenuWatch(state.id);
|
|
3383
3665
|
}
|
|
3384
3666
|
}
|
|
@@ -3573,6 +3855,13 @@ export class AgentManager {
|
|
|
3573
3855
|
mayBeInFlight: false,
|
|
3574
3856
|
};
|
|
3575
3857
|
}
|
|
3858
|
+
// Mark the panes cancel-clearing BEFORE flipping the task terminal (still under the lock), so any
|
|
3859
|
+
// window — a concurrent escape, or a restart — sees a persisted hold instead of a plain binding to a
|
|
3860
|
+
// terminal task that recover()/the escape would release with the session still un-cleared.
|
|
3861
|
+
for (const id of [devToRelease, qaToRelease]) {
|
|
3862
|
+
if (id)
|
|
3863
|
+
await this.markPaneCancelClearing(id, taskId);
|
|
3864
|
+
}
|
|
3576
3865
|
const now = new Date().toISOString();
|
|
3577
3866
|
task.status = 'cancelled';
|
|
3578
3867
|
task.updatedAt = now;
|
|
@@ -3594,31 +3883,46 @@ export class AgentManager {
|
|
|
3594
3883
|
// (config hot-removed: the pane outlives the config; state gone; rebound)
|
|
3595
3884
|
// leave an in-flight publish possible.
|
|
3596
3885
|
let devStopConfirmed = false;
|
|
3886
|
+
// Phase 1 — interrupt every still-bound pane first, so a slow /clear on one agent can't keep another
|
|
3887
|
+
// running the cancelled task. The persisted cancel-clearing hold (set under the lock) blocks any
|
|
3888
|
+
// concurrent escape from releasing these panes until they are /cleared.
|
|
3889
|
+
const stopped = [];
|
|
3597
3890
|
for (const id of [devToRelease, qaToRelease]) {
|
|
3598
3891
|
if (!id)
|
|
3599
3892
|
continue;
|
|
3600
3893
|
const cfg = this.getAgentConfig(id);
|
|
3601
3894
|
const state = await this.agentStore.get(id);
|
|
3602
|
-
if (!cfg || !state)
|
|
3603
|
-
|
|
3604
|
-
// 重校验绑定:lock 释放后另一路 release+acquire 可能已把 agent 绑给新任务,
|
|
3605
|
-
// 此时不能 C-c 打到新会话上、也不能继续 idle release。
|
|
3606
|
-
if (state.taskId !== taskId) {
|
|
3607
|
-
console.warn(`[AgentManager] cancelTask: ${id} no longer bound to ${taskId} (got ${state.taskId}); skipping`);
|
|
3895
|
+
if (!cfg || !state || state.taskId !== taskId) {
|
|
3896
|
+
console.warn(`[AgentManager] cancelTask: ${id} no longer bound to ${taskId} (got ${state?.taskId}); skipping`);
|
|
3608
3897
|
continue;
|
|
3609
3898
|
}
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
await this.markAwaitingHuman(id, 'cancel-interrupt-failed', 'Task marked cancelled but C-c / REPL ready check failed; agent may still be running. Attach via web terminal to verify, then Resume or Delete.');
|
|
3899
|
+
if (!(await this.interruptPaneAndWaitReady(state, cfg))) {
|
|
3900
|
+
await this.markAwaitingHuman(id, 'cancel-interrupt-failed', 'Task marked cancelled but ESC / REPL ready check failed; agent may still be running the cancelled prompt. Attach via web terminal to verify, then Resume or Delete.', { expectedTaskId: taskId });
|
|
3613
3901
|
continue;
|
|
3614
3902
|
}
|
|
3903
|
+
// devStopConfirmed reflects only "the pane stopped" — set before /clear so published-artifact
|
|
3904
|
+
// retirement still proceeds even when /clear can't be confirmed.
|
|
3615
3905
|
if (id === publishedCleanup?.devAgentId)
|
|
3616
3906
|
devStopConfirmed = true;
|
|
3907
|
+
stopped.push(id);
|
|
3908
|
+
}
|
|
3909
|
+
// Phase 2 — every pane is stopped; /clear + release each.
|
|
3910
|
+
for (const id of stopped) {
|
|
3911
|
+
const cfg = this.getAgentConfig(id);
|
|
3912
|
+
const state = await this.agentStore.get(id);
|
|
3913
|
+
if (!cfg || !state || state.taskId !== taskId) {
|
|
3914
|
+
console.warn(`[AgentManager] cancelTask: ${id} rebound before /clear (got ${state?.taskId}); skipping`);
|
|
3915
|
+
continue;
|
|
3916
|
+
}
|
|
3917
|
+
if (!(await this.clearPaneContext(state, cfg))) {
|
|
3918
|
+
// /clear unconfirmed → hold; the un-cleared pane stays bound (UNCLEARED_PANE_PHASES) until DELETE.
|
|
3919
|
+
await this.markAwaitingHuman(id, 'cancel-clear-failed', 'Task marked cancelled and the session interrupted, but /clear was not confirmed; the pane holds un-cleared context. DELETE the agent to discard it (Resume will not reuse an un-cleared pane).', { expectedTaskId: taskId });
|
|
3920
|
+
continue;
|
|
3921
|
+
}
|
|
3617
3922
|
try {
|
|
3618
|
-
//
|
|
3619
|
-
//
|
|
3620
|
-
|
|
3621
|
-
await this.releaseAgentForTask(id, taskId, 'idle', { allowAwaitingHuman: true });
|
|
3923
|
+
// fromCancelCleanup: this IS the owning cancel, having confirmed /clear — the only release allowed
|
|
3924
|
+
// to free the cancel-clearing hold. allowAwaitingHuman: cross the awaiting_human gate too.
|
|
3925
|
+
await this.releaseAgentForTask(id, taskId, 'idle', { allowAwaitingHuman: true, fromCancelCleanup: true });
|
|
3622
3926
|
}
|
|
3623
3927
|
catch (err) {
|
|
3624
3928
|
console.error(`[AgentManager] cancelTask releaseAgentForTask(${id}) failed:`, err);
|
|
@@ -4623,10 +4927,10 @@ export class AgentManager {
|
|
|
4623
4927
|
await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
|
|
4624
4928
|
if (!await bindingStillOurs())
|
|
4625
4929
|
return;
|
|
4626
|
-
|
|
4627
|
-
await
|
|
4628
|
-
|
|
4629
|
-
|
|
4930
|
+
const command = cleanSlate ? '/clear' : '/compact';
|
|
4931
|
+
if (!await this.sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, command, bindingStillOurs)) {
|
|
4932
|
+
return;
|
|
4933
|
+
}
|
|
4630
4934
|
break;
|
|
4631
4935
|
}
|
|
4632
4936
|
catch (err) {
|
|
@@ -4636,6 +4940,34 @@ export class AgentManager {
|
|
|
4636
4940
|
}
|
|
4637
4941
|
await this.releasePostMergeAgent(agentId, originalTaskId);
|
|
4638
4942
|
}
|
|
4943
|
+
async sendPostMergeSlashCommand(tmux, paneId, agentId, runtime, command, bindingStillOurs) {
|
|
4944
|
+
let rejection;
|
|
4945
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
4946
|
+
await tmux.sendKeysLiteral(paneId, command);
|
|
4947
|
+
await tmux.sendEnter(paneId);
|
|
4948
|
+
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
4949
|
+
await this.waitForReplPromptReady(tmux, paneId, runtime, this.compactIdleWaitMs);
|
|
4950
|
+
if (!await bindingStillOurs())
|
|
4951
|
+
return false;
|
|
4952
|
+
if (!await this.hasRuntimeSlashCommandRejection(tmux, paneId, command))
|
|
4953
|
+
return true;
|
|
4954
|
+
rejection = new Error(`runtime rejected ${command} because a task is still in progress`);
|
|
4955
|
+
if (attempt < 2) {
|
|
4956
|
+
console.warn(`[AgentManager] runPostMergeCompaction(${agentId}) ${command} rejected; retrying`);
|
|
4957
|
+
await new Promise(r => setTimeout(r, this.compactIdlePollMs));
|
|
4958
|
+
if (!await bindingStillOurs())
|
|
4959
|
+
return false;
|
|
4960
|
+
}
|
|
4961
|
+
}
|
|
4962
|
+
throw rejection ?? new Error(`runtime rejected ${command}`);
|
|
4963
|
+
}
|
|
4964
|
+
async hasRuntimeSlashCommandRejection(tmux, paneId, command) {
|
|
4965
|
+
const cap = await tmux.capturePaneById(paneId, { ansi: false, scrollback: 0 });
|
|
4966
|
+
return this.runtimeSlashCommandRejectedPattern(command).test(cap);
|
|
4967
|
+
}
|
|
4968
|
+
runtimeSlashCommandRejectedPattern(command) {
|
|
4969
|
+
return new RegExp(`["'“”‘’]?${command}["'“”‘’]?\\s+is disabled while a task is in progress\\.`, 'gi');
|
|
4970
|
+
}
|
|
4639
4971
|
// pane_current_command 是 runtime 是否仍活的权威信号(不被 viewport stale frame 骗)。
|
|
4640
4972
|
// anchor 在 codex busy 屏(`Working on it…\n esc to interrupt`)不存在,所以 busy 状态只看
|
|
4641
4973
|
// procTitle;只有准备返回 idle 时才用 anchor 作双重证据,挡 stale-frame + shell 误报。
|