baxian 1.2.56 → 1.2.57
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 +17 -2
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +260 -115
- 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/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 +21 -2
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +200 -16
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +2 -11
- package/dist/api/projects.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/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/package.json +1 -1
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
|
}
|
|
@@ -449,8 +454,11 @@ export class AgentManager {
|
|
|
449
454
|
`if tmux is missing, ${tmuxInstallHint(agent.projectId)}`);
|
|
450
455
|
}
|
|
451
456
|
if (mode === 'create' && alive) {
|
|
452
|
-
|
|
453
|
-
|
|
457
|
+
const reclaimed = await this.runUnderSessionLifecycle(agentId, () => this.reclaimHalfCreatedSession(tmux, agentId));
|
|
458
|
+
if (!reclaimed) {
|
|
459
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux session "${agentId}" already exists on host; baxian only manages sessions ` +
|
|
460
|
+
`it creates itself — kill it manually or pick a different agent id`);
|
|
461
|
+
}
|
|
454
462
|
}
|
|
455
463
|
if (alive && (mode === 'runtime' || mode === 'recover')) {
|
|
456
464
|
return this.adoptOrRestartSession(tmux, runner, agent, agentId, workdir, workdir);
|
|
@@ -470,9 +478,78 @@ export class AgentManager {
|
|
|
470
478
|
// before the skill runs. Message-body payloads are not expanded, so this path is injection-safe.
|
|
471
479
|
await this.runUnderSkillDirLock(this.skillDirLockKey(agent, workdir), async () => {
|
|
472
480
|
await this.ensureSkillDirSafe(runner, workdir, subdir);
|
|
473
|
-
await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, path, content), destRoot);
|
|
481
|
+
await this.skillRegistry.materialize((path, content) => this.atomicWriteFile(runner, workdir, path, content), destRoot);
|
|
474
482
|
});
|
|
475
483
|
}
|
|
484
|
+
// A rollback only kills if no adoption bumped the generation since its create.
|
|
485
|
+
sessionLifecycleChain = new Map();
|
|
486
|
+
adoptGeneration = new Map();
|
|
487
|
+
runUnderSessionLifecycle(agentId, fn) {
|
|
488
|
+
const prev = this.sessionLifecycleChain.get(agentId) ?? Promise.resolve();
|
|
489
|
+
const cur = prev.then(fn, fn);
|
|
490
|
+
const settled = cur.then(() => undefined, () => undefined);
|
|
491
|
+
this.sessionLifecycleChain.set(agentId, settled);
|
|
492
|
+
void settled.finally(() => {
|
|
493
|
+
if (this.sessionLifecycleChain.get(agentId) === settled)
|
|
494
|
+
this.sessionLifecycleChain.delete(agentId);
|
|
495
|
+
});
|
|
496
|
+
return cur;
|
|
497
|
+
}
|
|
498
|
+
// Inside the lifecycle lock, a generation bump since create means handoff — stand down.
|
|
499
|
+
async rollbackCreatedSession(agentId, partial, reason, opts = {}) {
|
|
500
|
+
await this.runUnderSessionLifecycle(agentId, async () => {
|
|
501
|
+
try {
|
|
502
|
+
const ref = partial.sessionRef;
|
|
503
|
+
if (!ref) {
|
|
504
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: no session ref recorded`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if ((this.adoptGeneration.get(agentId) ?? 0) !== (partial.genAtCreate ?? 0)) {
|
|
508
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: session adopted since create — leaving it to its successor`);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (opts.expectCreationToken !== undefined) {
|
|
512
|
+
let fresh;
|
|
513
|
+
try {
|
|
514
|
+
fresh = await this.agentStore.get(agentId);
|
|
515
|
+
}
|
|
516
|
+
catch (storeErr) {
|
|
517
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent store read failed — session left in place:`, storeErr);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (!fresh) {
|
|
521
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent record gone`);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (fresh.creationToken !== opts.expectCreationToken) {
|
|
525
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: creationToken rotated — leaving the session to its successor`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const cfg = this.getAgentConfig(agentId);
|
|
530
|
+
if (!cfg) {
|
|
531
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}) skipped: agent no longer in config`);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const runner = this.createRunnerFor(cfg);
|
|
535
|
+
const outcome = await new TmuxManager(runner).killSessionRef(ref);
|
|
536
|
+
if (outcome === 'stale-server') {
|
|
537
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}): tmux server generation changed — leaving session ${ref.sessionId} untouched`);
|
|
538
|
+
}
|
|
539
|
+
else if (outcome === 'killed') {
|
|
540
|
+
console.warn(`[AgentManager] ${agentId} rollback (${reason}): killed created session ${ref.sessionId}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
catch (cleanupErr) {
|
|
544
|
+
console.warn(`[AgentManager] created-session rollback (${reason}) failed for ${agentId}:`, cleanupErr);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
async rollbackEnsureSessionFailure(agentId, err, reason) {
|
|
549
|
+
if (!(err instanceof EnsureSessionError) || !err.partial.createdSession)
|
|
550
|
+
return;
|
|
551
|
+
await this.rollbackCreatedSession(agentId, err.partial, reason);
|
|
552
|
+
}
|
|
476
553
|
skillDirChain = new Map();
|
|
477
554
|
skillDirLockKey(agent, workdir) {
|
|
478
555
|
const host = hostGroupKey(agent.mode, resolveAgentHost(this.config.host, agent.host));
|
|
@@ -491,19 +568,21 @@ export class AgentManager {
|
|
|
491
568
|
});
|
|
492
569
|
return run;
|
|
493
570
|
}
|
|
494
|
-
async atomicWriteFile(runner, finalPath, content) {
|
|
495
|
-
const tmp = `${finalPath}.baxian-tmp`;
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
571
|
+
async atomicWriteFile(runner, workdir, finalPath, content) {
|
|
572
|
+
const tmp = `${finalPath}.baxian-tmp-${randomBytes(6).toString('hex')}`;
|
|
573
|
+
try {
|
|
574
|
+
await stageFileGuarded(runner, workdir, tmp, content);
|
|
575
|
+
await moveFileIntoPlace(runner, tmp, finalPath, { guardRoot: workdir });
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
throw new Error(`atomic skill write failed (${finalPath}): ${err instanceof Error ? err.message : String(err)}`);
|
|
501
579
|
}
|
|
502
580
|
}
|
|
503
581
|
async ensureSkillDirSafe(runner, workdir, subdir) {
|
|
504
582
|
const top = subdir.split('/')[0];
|
|
505
583
|
const keep = this.skillRegistry.names().map((n) => `! -name ${shellQuote(n)}`).join(' ');
|
|
506
|
-
const inner = `
|
|
584
|
+
const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
|
|
585
|
+
`cd ${shellQuote(workdir)} && ` +
|
|
507
586
|
`for d in ${top} ${subdir}; do if [ -L "$d" ]; then ` +
|
|
508
587
|
`printf 'baxian: %s is a symlink -> %s; replace it with a real directory\\n' "$d" "$(readlink "$d")" >&2; ` +
|
|
509
588
|
`exit 3; fi; done && ` +
|
|
@@ -516,10 +595,12 @@ export class AgentManager {
|
|
|
516
595
|
throw new Error(`failed to prepare a symlink-safe ${subdir} in ${workdir}: ${res.stderr || 'unknown error'}`);
|
|
517
596
|
}
|
|
518
597
|
}
|
|
519
|
-
async tagSessionSkillsVersion(tmux, agentId) {
|
|
598
|
+
async tagSessionSkillsVersion(tmux, agentId, ref) {
|
|
520
599
|
if (this.skillRegistry.names().length === 0)
|
|
521
600
|
return;
|
|
522
|
-
await
|
|
601
|
+
await this.setSessionOptions(tmux, agentId, ref, [
|
|
602
|
+
['@baxian-skills-version', this.skillRegistry.contentHash()],
|
|
603
|
+
]);
|
|
523
604
|
}
|
|
524
605
|
async replSkillsStale(tmux, agentId) {
|
|
525
606
|
if (this.skillRegistry.names().length === 0)
|
|
@@ -528,7 +609,8 @@ export class AgentManager {
|
|
|
528
609
|
return tagged !== this.skillRegistry.contentHash();
|
|
529
610
|
}
|
|
530
611
|
async excludeInjectedSkills(runner, workdir, subdir) {
|
|
531
|
-
const inner = `
|
|
612
|
+
const inner = `[ ! -L ${shellQuote(workdir)} ] && [ -d ${shellQuote(workdir)} ] && ` +
|
|
613
|
+
`cd ${shellQuote(workdir)} && ` +
|
|
532
614
|
`if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo BX_SKILLS_NON_GIT; exit 0; fi; ` +
|
|
533
615
|
`tracked="$(git ls-files -- ${shellQuote(`${subdir}/baxian-*`)} 2>/dev/null | head -5)"; ` +
|
|
534
616
|
`if [ -n "$tracked" ]; then printf 'BX_SKILLS_TRACKED %s\\n' "$tracked" | head -1; exit 0; fi; ` +
|
|
@@ -551,35 +633,46 @@ export class AgentManager {
|
|
|
551
633
|
}
|
|
552
634
|
throw new Error(`cannot make injected skills invisible to Git in ${workdir}: ${verdict || 'no probe output'}`);
|
|
553
635
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
636
|
+
runtimePinEntries() {
|
|
637
|
+
return [
|
|
638
|
+
['prefix', 'C-b'],
|
|
639
|
+
['prefix2', 'None'],
|
|
640
|
+
['mouse', 'on'],
|
|
641
|
+
];
|
|
558
642
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
643
|
+
// A name (or even a bare $id) can rebind to a same-name successor mid-flight;
|
|
644
|
+
// every option write goes through the identity-checked by-ref batch.
|
|
645
|
+
async setSessionOptions(tmux, agentId, ref, entries) {
|
|
646
|
+
const outcome = await tmux.setSessionOptionsIfAlive(ref, entries);
|
|
647
|
+
if (outcome === 'gone') {
|
|
648
|
+
throw new Error(`tmux session ${ref.sessionId} (${agentId}) vanished before its options could be applied`);
|
|
649
|
+
}
|
|
562
650
|
}
|
|
563
651
|
async buildFreshSession(tmux, agent, agentId, workdir, sessionDir = workdir) {
|
|
564
652
|
return this.runUnderSkillDirLock(this.skillDirLockKey(agent, sessionDir), () => this.buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir));
|
|
565
653
|
}
|
|
566
654
|
async buildFreshSessionLocked(tmux, agent, agentId, workdir, sessionDir) {
|
|
567
655
|
let createdSession = false;
|
|
656
|
+
let createdRef;
|
|
657
|
+
const genAtCreate = this.adoptGeneration.get(agentId) ?? 0;
|
|
568
658
|
const runtime = agentRuntimeKindFor(agent);
|
|
569
659
|
try {
|
|
570
|
-
await tmux.createSession(agentId, sessionDir);
|
|
660
|
+
createdRef = await tmux.createSession(agentId, sessionDir);
|
|
571
661
|
createdSession = true;
|
|
572
|
-
await
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
662
|
+
await this.setSessionOptions(tmux, agentId, createdRef, [
|
|
663
|
+
['@baxian-agent-id', agentId],
|
|
664
|
+
['@baxian-runtime', agent.runtime],
|
|
665
|
+
[WORKDIR_SESSION_OPTION, sessionDir],
|
|
666
|
+
['allow-passthrough', 'on'],
|
|
667
|
+
['set-titles', 'on'],
|
|
668
|
+
['window-size', 'latest'],
|
|
669
|
+
...this.runtimePinEntries(),
|
|
670
|
+
['status-right', ''],
|
|
671
|
+
]);
|
|
579
672
|
await tmux.setServerOption('extended-keys', 'on');
|
|
580
673
|
await tmux.appendServerOptionIfMissing('terminal-features', 'xterm*:extkeys');
|
|
581
|
-
const paneId = await tmux.
|
|
582
|
-
await tmux.sendKeysToPane(paneId, `${
|
|
674
|
+
const paneId = await tmux.getSinglePaneIdByRef(createdRef);
|
|
675
|
+
await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
|
|
583
676
|
await tmux.handleTrustDialog(paneId, runtime, {
|
|
584
677
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
585
678
|
});
|
|
@@ -587,11 +680,13 @@ export class AgentManager {
|
|
|
587
680
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
588
681
|
scrollback: 0,
|
|
589
682
|
});
|
|
590
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
591
|
-
return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir };
|
|
683
|
+
await this.tagSessionSkillsVersion(tmux, agentId, createdRef);
|
|
684
|
+
return { ok: true, createdSession: true, freshRuntime: true, paneId, workdir, sessionRef: createdRef };
|
|
592
685
|
}
|
|
593
686
|
catch (err) {
|
|
594
|
-
const partial = { createdSession, agentId };
|
|
687
|
+
const partial = { createdSession, agentId, genAtCreate };
|
|
688
|
+
if (createdRef)
|
|
689
|
+
partial.sessionRef = createdRef;
|
|
595
690
|
if (err instanceof ReplNotReadyError) {
|
|
596
691
|
partial.lastScreen = err.lastScreen;
|
|
597
692
|
if (createdSession && detectStartupDialog(err.lastScreen, runtime)) {
|
|
@@ -607,22 +702,12 @@ export class AgentManager {
|
|
|
607
702
|
console.warn(`[bootstrap] ${agentId} not in config at bootstrap start — aborting`);
|
|
608
703
|
return;
|
|
609
704
|
}
|
|
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
705
|
try {
|
|
620
706
|
const result = await this.ensureSession(agentId, 'create');
|
|
621
707
|
if (!(await this.runGreetingHandshake(agentId, cfgAtStart, result.paneId))) {
|
|
622
708
|
const current = await this.agentStore.get(agentId);
|
|
623
709
|
if (current && current.creationToken !== creationToken) {
|
|
624
|
-
console.warn(`[bootstrap] ${agentId} creationToken changed during greeting —
|
|
625
|
-
await tryKillOrphanSession('greeting-failure token mismatch');
|
|
710
|
+
console.warn(`[bootstrap] ${agentId} creationToken changed during greeting — leaving the session to its successor`);
|
|
626
711
|
return;
|
|
627
712
|
}
|
|
628
713
|
await this.markGreetingFailed(agentId, creationToken);
|
|
@@ -643,8 +728,7 @@ export class AgentManager {
|
|
|
643
728
|
};
|
|
644
729
|
});
|
|
645
730
|
if (!resolvedExisting) {
|
|
646
|
-
console.warn(`[bootstrap] ${agentId} creationToken mismatch on success —
|
|
647
|
-
await tryKillOrphanSession('post-success token mismatch');
|
|
731
|
+
console.warn(`[bootstrap] ${agentId} creationToken mismatch on success — leaving the session to its successor`);
|
|
648
732
|
return;
|
|
649
733
|
}
|
|
650
734
|
await this.safeEmit({
|
|
@@ -662,8 +746,7 @@ export class AgentManager {
|
|
|
662
746
|
if (err.partial.createdSession) {
|
|
663
747
|
const fresh = await this.agentStore.get(agentId);
|
|
664
748
|
if (!fresh || fresh.creationToken !== creationToken) {
|
|
665
|
-
console.warn(`[bootstrap] ${agentId} dialog-path token mismatch —
|
|
666
|
-
await tryKillOrphanSession('dialog-path token mismatch');
|
|
749
|
+
console.warn(`[bootstrap] ${agentId} dialog-path token mismatch — leaving the session to its successor`);
|
|
667
750
|
return;
|
|
668
751
|
}
|
|
669
752
|
}
|
|
@@ -674,7 +757,9 @@ export class AgentManager {
|
|
|
674
757
|
return;
|
|
675
758
|
}
|
|
676
759
|
if (err instanceof EnsureSessionError && err.partial.createdSession) {
|
|
677
|
-
await
|
|
760
|
+
await this.rollbackCreatedSession(agentId, err.partial, 'hard-failure rollback', {
|
|
761
|
+
expectCreationToken: creationToken,
|
|
762
|
+
});
|
|
678
763
|
}
|
|
679
764
|
const message = err instanceof Error ? err.message : String(err);
|
|
680
765
|
await this.markBootstrapFailed(agentId, creationToken, message);
|
|
@@ -1143,27 +1228,39 @@ export class AgentManager {
|
|
|
1143
1228
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
1144
1229
|
});
|
|
1145
1230
|
}
|
|
1231
|
+
// Claim snapshot and adoption declaration share one critical section with rollbacks.
|
|
1146
1232
|
async adoptOrRestartSession(tmux, runner, agent, agentId, workdir, sessionDir) {
|
|
1147
|
-
|
|
1233
|
+
return this.runUnderSessionLifecycle(agentId, () => this.adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir));
|
|
1234
|
+
}
|
|
1235
|
+
async adoptOrRestartSessionLocked(tmux, runner, agent, agentId, workdir, sessionDir) {
|
|
1236
|
+
let snapshot;
|
|
1148
1237
|
try {
|
|
1149
|
-
|
|
1238
|
+
snapshot = await tmux.getSessionSnapshot(agentId);
|
|
1150
1239
|
}
|
|
1151
1240
|
catch (err) {
|
|
1152
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `
|
|
1241
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1153
1242
|
}
|
|
1154
|
-
if (
|
|
1243
|
+
if (!snapshot) {
|
|
1244
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1245
|
+
}
|
|
1246
|
+
const sessionRef = snapshot.ref;
|
|
1247
|
+
if (snapshot.claim !== agentId) {
|
|
1248
|
+
if (snapshot.claim === null && await this.reclaimHalfCreatedSession(tmux, agentId, snapshot)) {
|
|
1249
|
+
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1250
|
+
}
|
|
1155
1251
|
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`);
|
|
1252
|
+
`(got "${snapshot.claim ?? 'null'}"); baxian will not adopt foreign session — operator must intervene`);
|
|
1157
1253
|
}
|
|
1254
|
+
this.adoptGeneration.set(agentId, (this.adoptGeneration.get(agentId) ?? 0) + 1);
|
|
1158
1255
|
try {
|
|
1159
|
-
await this.
|
|
1256
|
+
await this.setSessionOptions(tmux, agentId, sessionRef, this.runtimePinEntries());
|
|
1160
1257
|
}
|
|
1161
1258
|
catch (err) {
|
|
1162
|
-
throw new EnsureSessionError({ createdSession: false, agentId }, `
|
|
1259
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `pinning runtime session options failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1163
1260
|
}
|
|
1164
1261
|
let paneId;
|
|
1165
1262
|
try {
|
|
1166
|
-
paneId = await tmux.
|
|
1263
|
+
paneId = await tmux.getSinglePaneIdByRef(sessionRef);
|
|
1167
1264
|
}
|
|
1168
1265
|
catch (err) {
|
|
1169
1266
|
throw new EnsureSessionError({ createdSession: false, agentId }, `getSinglePaneId failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1202,10 +1299,10 @@ export class AgentManager {
|
|
|
1202
1299
|
else if (state.kind !== 'shell') {
|
|
1203
1300
|
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux pane path ${currentPath} differs from Workdir ${sessionDir}, but runtime is not safely idle`);
|
|
1204
1301
|
}
|
|
1205
|
-
await
|
|
1302
|
+
await this.killAdoptTarget(tmux, agentId, sessionRef, 'workdir change');
|
|
1206
1303
|
return this.buildFreshSession(tmux, agent, agentId, workdir, sessionDir);
|
|
1207
1304
|
}
|
|
1208
|
-
await
|
|
1305
|
+
await this.setSessionOptions(tmux, agentId, sessionRef, [[WORKDIR_SESSION_OPTION, sessionDir]]);
|
|
1209
1306
|
switch (state.kind) {
|
|
1210
1307
|
case 'live-runtime': {
|
|
1211
1308
|
let stale;
|
|
@@ -1223,9 +1320,10 @@ export class AgentManager {
|
|
|
1223
1320
|
skillsStale: true,
|
|
1224
1321
|
paneId,
|
|
1225
1322
|
workdir,
|
|
1323
|
+
sessionRef,
|
|
1226
1324
|
};
|
|
1227
1325
|
}
|
|
1228
|
-
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir };
|
|
1326
|
+
return { ok: true, createdSession: false, freshRuntime: false, paneId, workdir, sessionRef };
|
|
1229
1327
|
}
|
|
1230
1328
|
case 'startup-dialog':
|
|
1231
1329
|
throw new EnsureSessionError({
|
|
@@ -1247,8 +1345,8 @@ export class AgentManager {
|
|
|
1247
1345
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1248
1346
|
scrollback: 0,
|
|
1249
1347
|
});
|
|
1250
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1251
|
-
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
|
|
1348
|
+
await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
|
|
1349
|
+
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
|
|
1252
1350
|
}
|
|
1253
1351
|
catch (trustErr) {
|
|
1254
1352
|
if (trustErr instanceof ReplNotReadyError && detectStartupDialog(trustErr.lastScreen, runtime)) {
|
|
@@ -1266,7 +1364,7 @@ export class AgentManager {
|
|
|
1266
1364
|
break;
|
|
1267
1365
|
}
|
|
1268
1366
|
try {
|
|
1269
|
-
await tmux.sendKeysToPane(paneId, `${
|
|
1367
|
+
await tmux.sendKeysToPane(paneId, `${launchCommandIn(sessionDir, agent)}\n`);
|
|
1270
1368
|
await tmux.handleTrustDialog(paneId, runtime, {
|
|
1271
1369
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
1272
1370
|
});
|
|
@@ -1274,8 +1372,8 @@ export class AgentManager {
|
|
|
1274
1372
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
1275
1373
|
scrollback: 0,
|
|
1276
1374
|
});
|
|
1277
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
1278
|
-
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir };
|
|
1375
|
+
await this.tagSessionSkillsVersion(tmux, agentId, sessionRef);
|
|
1376
|
+
return { ok: true, createdSession: false, freshRuntime: true, paneId, workdir, sessionRef };
|
|
1279
1377
|
}
|
|
1280
1378
|
catch (relErr) {
|
|
1281
1379
|
if (relErr instanceof ReplNotReadyError && detectStartupDialog(relErr.lastScreen, runtime)) {
|
|
@@ -1289,6 +1387,66 @@ export class AgentManager {
|
|
|
1289
1387
|
throw new EnsureSessionError({ createdSession: false, agentId }, `REPL relaunch failed: ${relErr instanceof Error ? relErr.message : String(relErr)}`);
|
|
1290
1388
|
}
|
|
1291
1389
|
}
|
|
1390
|
+
// Frees a bootstrap blocked on its predecessor's half-created session (nonce
|
|
1391
|
+
// env present but claim never written); anything else is left alone.
|
|
1392
|
+
async reclaimHalfCreatedSession(tmux, agentId, knownSnapshot) {
|
|
1393
|
+
let snapshot;
|
|
1394
|
+
try {
|
|
1395
|
+
snapshot = knownSnapshot ?? await tmux.getSessionSnapshot(agentId);
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `session snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1399
|
+
}
|
|
1400
|
+
if (!snapshot)
|
|
1401
|
+
return true;
|
|
1402
|
+
if (snapshot.claim !== null)
|
|
1403
|
+
return false;
|
|
1404
|
+
let halfCreated;
|
|
1405
|
+
try {
|
|
1406
|
+
halfCreated = await tmux.hasCreationNonce(agentId);
|
|
1407
|
+
}
|
|
1408
|
+
catch (err) {
|
|
1409
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `creation-nonce probe failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1410
|
+
}
|
|
1411
|
+
if (!halfCreated)
|
|
1412
|
+
return false;
|
|
1413
|
+
let outcome;
|
|
1414
|
+
try {
|
|
1415
|
+
// claim-still-empty rides the kill itself — a claim racing the probes makes the server refuse.
|
|
1416
|
+
outcome = await tmux.killSessionRef(snapshot.ref, { onlyIfUnclaimed: true });
|
|
1417
|
+
}
|
|
1418
|
+
catch (err) {
|
|
1419
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (half-created leftover) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1420
|
+
}
|
|
1421
|
+
if (outcome === 'refused' || outcome === 'stale-server') {
|
|
1422
|
+
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`);
|
|
1423
|
+
}
|
|
1424
|
+
if (outcome === 'killed') {
|
|
1425
|
+
console.warn(`[AgentManager] ${agentId}: killed half-created leftover session ${snapshot.ref.sessionId} (creation nonce present, claim never written)`);
|
|
1426
|
+
}
|
|
1427
|
+
else {
|
|
1428
|
+
console.warn(`[AgentManager] ${agentId}: half-created leftover session ${snapshot.ref.sessionId} already gone`);
|
|
1429
|
+
}
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
async killAdoptTarget(tmux, agentId, sessionRef, reason) {
|
|
1433
|
+
let outcome;
|
|
1434
|
+
try {
|
|
1435
|
+
outcome = await tmux.killSessionRef(sessionRef);
|
|
1436
|
+
}
|
|
1437
|
+
catch (err) {
|
|
1438
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `killSessionRef (${reason}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1439
|
+
}
|
|
1440
|
+
if (outcome === 'stale-server') {
|
|
1441
|
+
throw new EnsureSessionError({ createdSession: false, agentId }, `tmux server generation changed while replacing session (${reason}); retry to observe the new state`);
|
|
1442
|
+
}
|
|
1443
|
+
if (outcome === 'killed') {
|
|
1444
|
+
console.warn(`[AgentManager] ${agentId}: killed session ${sessionRef.sessionId} (${reason})`);
|
|
1445
|
+
}
|
|
1446
|
+
else {
|
|
1447
|
+
console.warn(`[AgentManager] ${agentId}: session ${sessionRef.sessionId} already gone (${reason})`);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1292
1450
|
async acquireAgentForTask(agentId, taskId, phase) {
|
|
1293
1451
|
const cfg = this.getAgentConfig(agentId);
|
|
1294
1452
|
if (!cfg)
|
|
@@ -2284,15 +2442,14 @@ export class AgentManager {
|
|
|
2284
2442
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
2285
2443
|
const runner = this.createRunnerFor(cfg);
|
|
2286
2444
|
const tmux = new TmuxManager(runner);
|
|
2287
|
-
const
|
|
2288
|
-
if (!
|
|
2445
|
+
const snapshot = await tmux.getSessionSnapshot(agentId);
|
|
2446
|
+
if (!snapshot) {
|
|
2289
2447
|
throw new Error(`restart-repl: tmux session ${agentId} does not exist; use retry to rebuild`);
|
|
2290
2448
|
}
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
throw new Error(`restart-repl: session claim mismatch (got "${claim ?? 'null'}"); refusing to touch foreign session`);
|
|
2449
|
+
if (snapshot.claim !== agentId) {
|
|
2450
|
+
throw new Error(`restart-repl: session claim mismatch (got "${snapshot.claim ?? 'null'}"); refusing to touch foreign session`);
|
|
2294
2451
|
}
|
|
2295
|
-
const paneId = await tmux.
|
|
2452
|
+
const paneId = await tmux.getSinglePaneIdByRef(snapshot.ref);
|
|
2296
2453
|
await tmux.sendKeysToPane(paneId, 'C-c');
|
|
2297
2454
|
const cmd = await this.pollPaneCommandStable(tmux, paneId, { timeoutMs: 2_000 });
|
|
2298
2455
|
const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
|
|
@@ -2314,10 +2471,10 @@ export class AgentManager {
|
|
|
2314
2471
|
'use retry to rebuild the session safely');
|
|
2315
2472
|
}
|
|
2316
2473
|
await this.provisionRepoSkills(runner, cfg, workdir);
|
|
2317
|
-
await
|
|
2474
|
+
await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
|
|
2318
2475
|
const runtime = agentRuntimeKindFor(cfg);
|
|
2319
2476
|
const relaunch = async () => {
|
|
2320
|
-
await tmux.sendKeysToPane(paneId, `${
|
|
2477
|
+
await tmux.sendKeysToPane(paneId, `${launchCommandIn(workdir, cfg)}\n`);
|
|
2321
2478
|
await tmux.handleTrustDialog(paneId, runtime, {
|
|
2322
2479
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
2323
2480
|
});
|
|
@@ -2325,7 +2482,7 @@ export class AgentManager {
|
|
|
2325
2482
|
timeoutMs: this.bootstrapTimeoutsMs.waitReplReady,
|
|
2326
2483
|
scrollback: 0,
|
|
2327
2484
|
});
|
|
2328
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
2485
|
+
await this.tagSessionSkillsVersion(tmux, agentId, snapshot.ref);
|
|
2329
2486
|
};
|
|
2330
2487
|
await this.runUnderSkillDirLock(this.skillDirLockKey(cfg, workdir), relaunch);
|
|
2331
2488
|
await this.agentStore.update(agentId, (state) => {
|
|
@@ -2419,17 +2576,23 @@ export class AgentManager {
|
|
|
2419
2576
|
}
|
|
2420
2577
|
}
|
|
2421
2578
|
try {
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
}
|
|
2428
|
-
else {
|
|
2579
|
+
await this.runUnderSessionLifecycle(id, async () => {
|
|
2580
|
+
const snapshot = await tmux.getSessionSnapshot(id);
|
|
2581
|
+
if (!snapshot)
|
|
2582
|
+
return;
|
|
2583
|
+
if (snapshot.claim !== id) {
|
|
2429
2584
|
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: skipping kill for ${id} ` +
|
|
2430
|
-
`(claim=${claim ?? 'null'}; not baxian-managed)`);
|
|
2585
|
+
`(claim=${snapshot.claim ?? 'null'}; not baxian-managed)`);
|
|
2586
|
+
return;
|
|
2431
2587
|
}
|
|
2432
|
-
|
|
2588
|
+
const outcome = await tmux.killSessionRef(snapshot.ref);
|
|
2589
|
+
if (outcome === 'stale-server') {
|
|
2590
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: tmux server generation changed for ${id} — leaving session untouched`);
|
|
2591
|
+
}
|
|
2592
|
+
else if (outcome === 'killed') {
|
|
2593
|
+
console.warn(`[AgentManager] cleanupRemovedAgentRuntime: killed session ${snapshot.ref.sessionId} for removed agent ${id}`);
|
|
2594
|
+
}
|
|
2595
|
+
});
|
|
2433
2596
|
}
|
|
2434
2597
|
catch (err) {
|
|
2435
2598
|
failures.push({ agentId: id, step: 'tmux', error: err });
|
|
@@ -3543,18 +3706,12 @@ export class AgentManager {
|
|
|
3543
3706
|
const stillOwner = stateNow?.taskId === taskId
|
|
3544
3707
|
&& stateNow.lockToken === lockToken
|
|
3545
3708
|
&& 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
|
-
}
|
|
3709
|
+
if (stillOwner) {
|
|
3710
|
+
await this.rollbackCreatedSession(agentId, err.partial, 'startSession ensure rollback');
|
|
3555
3711
|
}
|
|
3556
|
-
|
|
3557
|
-
console.warn(`[AgentManager] startSession ensureSession rollback
|
|
3712
|
+
else {
|
|
3713
|
+
console.warn(`[AgentManager] startSession ensureSession rollback skipped for stale owner ` +
|
|
3714
|
+
`${agentId}/${taskId}`);
|
|
3558
3715
|
}
|
|
3559
3716
|
}
|
|
3560
3717
|
throw err;
|
|
@@ -3601,7 +3758,7 @@ export class AgentManager {
|
|
|
3601
3758
|
if (!ensure.freshRuntime) {
|
|
3602
3759
|
await this.clearRuntimeForTaskBoundary(tmux, paneId, agentId, agent.runtime, assertOwner);
|
|
3603
3760
|
if (ensure.skillsStale)
|
|
3604
|
-
await this.tagSessionSkillsVersion(tmux, agentId);
|
|
3761
|
+
await this.tagSessionSkillsVersion(tmux, agentId, ensure.sessionRef);
|
|
3605
3762
|
}
|
|
3606
3763
|
}
|
|
3607
3764
|
catch (err) {
|
|
@@ -4018,18 +4175,12 @@ export class AgentManager {
|
|
|
4018
4175
|
const stillOwner = stateNow?.taskId === taskId
|
|
4019
4176
|
&& stateNow.lockToken === lockToken
|
|
4020
4177
|
&& 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
|
-
}
|
|
4178
|
+
if (stillOwner) {
|
|
4179
|
+
await this.rollbackCreatedSession(agentId, err.partial, 'continueSession ensure rollback');
|
|
4030
4180
|
}
|
|
4031
|
-
|
|
4032
|
-
console.warn(`[AgentManager] continueSession ensureSession rollback
|
|
4181
|
+
else {
|
|
4182
|
+
console.warn(`[AgentManager] continueSession ensureSession rollback skipped for stale owner ` +
|
|
4183
|
+
`${agentId}/${taskId}`);
|
|
4033
4184
|
}
|
|
4034
4185
|
}
|
|
4035
4186
|
throw err;
|
|
@@ -4463,13 +4614,7 @@ export class AgentManager {
|
|
|
4463
4614
|
continue;
|
|
4464
4615
|
}
|
|
4465
4616
|
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
|
-
}
|
|
4617
|
+
await this.rollbackCreatedSession(state.id, err.partial, 'recover ensure rollback');
|
|
4473
4618
|
}
|
|
4474
4619
|
const message = err instanceof Error ? err.message : String(err);
|
|
4475
4620
|
console.warn(`[recover] ensureSession failed for agent=${state.id}: ${message}`);
|