baxian 2.0.27 → 2.0.29
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/bootstrap.js +2 -2
- package/dist/agent/bootstrap.js.map +1 -1
- package/dist/agent/dispatch-reconciler.d.ts.map +1 -1
- package/dist/agent/dispatch-reconciler.js +16 -7
- package/dist/agent/dispatch-reconciler.js.map +1 -1
- package/dist/agent/manager.d.ts +40 -2
- package/dist/agent/manager.d.ts.map +1 -1
- package/dist/agent/manager.js +366 -114
- package/dist/agent/manager.js.map +1 -1
- package/dist/agent/phase-signal-watcher.d.ts +14 -1
- package/dist/agent/phase-signal-watcher.d.ts.map +1 -1
- package/dist/agent/phase-signal-watcher.js +96 -30
- package/dist/agent/phase-signal-watcher.js.map +1 -1
- package/dist/agent/phase-signal.d.ts +11 -0
- package/dist/agent/phase-signal.d.ts.map +1 -1
- package/dist/agent/phase-signal.js +19 -0
- package/dist/agent/phase-signal.js.map +1 -1
- package/dist/agent/prompt.js +9 -2
- package/dist/agent/prompt.js.map +1 -1
- package/dist/agent/tmux-probe-poller.d.ts +12 -2
- package/dist/agent/tmux-probe-poller.d.ts.map +1 -1
- package/dist/agent/tmux-probe-poller.js +199 -57
- package/dist/agent/tmux-probe-poller.js.map +1 -1
- package/dist/agent/tmux.d.ts +16 -5
- package/dist/agent/tmux.d.ts.map +1 -1
- package/dist/agent/tmux.js +251 -39
- package/dist/agent/tmux.js.map +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +38 -4
- package/dist/api/config.js.map +1 -1
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +112 -92
- package/dist/api/projects.js.map +1 -1
- package/dist/api/tasks.js +1 -2
- package/dist/api/tasks.js.map +1 -1
- package/dist/shared/constants.d.ts +1 -0
- package/dist/shared/constants.d.ts.map +1 -1
- package/dist/shared/constants.js +1 -0
- package/dist/shared/constants.js.map +1 -1
- package/dist/shared/types.d.ts +4 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js.map +1 -1
- package/dist/state/task-store.d.ts.map +1 -1
- package/dist/state/task-store.js +11 -9
- package/dist/state/task-store.js.map +1 -1
- package/dist/web/assets/{index-BTi6bMpY.css → index-B77QkCk7.css} +1 -1
- package/dist/web/assets/{index-SsSI1lEh.js → index-vQFMnvpO.js} +10 -10
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
package/dist/agent/manager.js
CHANGED
|
@@ -15,7 +15,7 @@ import { prReviewCacheRevision } from '../platform/pr-conversation-cache.js';
|
|
|
15
15
|
import { deadTokens, liveVerdictVeto } from '../platform/verdict-engine.js';
|
|
16
16
|
import { platformBindingMismatch, taskNeedsPlatformBindingAudit, } from '../platform/startup.js';
|
|
17
17
|
import { AGENT_STORE_NOOP } from '../state/agent-store.js';
|
|
18
|
-
import { createRunner, LocalRunner, shellQuote, resolveAgentHost, workdirHostGroupKey, } from './runner.js';
|
|
18
|
+
import { createRunner, LocalRunner, shellQuote, hostGroupKey, resolveAgentHost, workdirHostGroupKey, } from './runner.js';
|
|
19
19
|
import { isTransientNetworkFailure } from './net-exec.js';
|
|
20
20
|
import { classifyScreen, isMenuRule } from './detect/classify.js';
|
|
21
21
|
import { imageFilename, agentHostPath, writeImageToHost } from './image-input.js';
|
|
@@ -262,6 +262,8 @@ export class AgentManager {
|
|
|
262
262
|
compactIdleWaitMs = 5 * 60_000;
|
|
263
263
|
compactIdlePollMs = 2_000;
|
|
264
264
|
manualCompactWaitMs = 5_000;
|
|
265
|
+
restartInterruptWaitMs = 10_000;
|
|
266
|
+
replExitWaitMs = 30_000;
|
|
265
267
|
runtimeLivenessProbeMs = 700;
|
|
266
268
|
cleanComposerWaitMs = 5_000;
|
|
267
269
|
platformVerificationRetryDelayMs = 2_000;
|
|
@@ -278,6 +280,7 @@ export class AgentManager {
|
|
|
278
280
|
divergedAgents = new Set();
|
|
279
281
|
deletionGeneration = new Map();
|
|
280
282
|
compactInFlight = new Set();
|
|
283
|
+
maintenanceInFlight = new Set();
|
|
281
284
|
platformBindingInterventionKeys = new Set();
|
|
282
285
|
constructor(deps) {
|
|
283
286
|
const config = prepareConfig(deps.config);
|
|
@@ -299,12 +302,23 @@ export class AgentManager {
|
|
|
299
302
|
eventBus: deps.eventBus,
|
|
300
303
|
resolveAgent: (id) => this.getAgentConfig(id),
|
|
301
304
|
commitNeedInputWatermark: (intent) => this.commitNeedInputWatermark(intent),
|
|
305
|
+
spawnTask: (intent) => this.spawnTaskFromSignal(intent),
|
|
302
306
|
})
|
|
303
307
|
: undefined);
|
|
304
308
|
this.needInputRetryIntervalMs = deps.needInputRetryIntervalMs ?? DEFAULT_NEED_INPUT_RETRY_INTERVAL_MS;
|
|
305
309
|
this.dispatchAckTimeoutMs = deps.dispatchAckTimeoutMs ?? DEFAULT_DISPATCH_ACK_TIMEOUT_MS;
|
|
306
310
|
this.dispatchSettleTimeoutMs = deps.dispatchSettleTimeoutMs ?? DEFAULT_DISPATCH_SETTLE_TIMEOUT_MS;
|
|
307
|
-
this.cancelInterruptGuardWaitMs = this.dispatchAckTimeoutMs + 5_000;
|
|
311
|
+
this.cancelInterruptGuardWaitMs = deps.cancelInterruptGuardWaitMs ?? this.dispatchAckTimeoutMs + 5_000;
|
|
312
|
+
this.restartInterruptWaitMs = deps.restartInterruptWaitMs ?? this.restartInterruptWaitMs;
|
|
313
|
+
this.replExitWaitMs = deps.replExitWaitMs ?? this.replExitWaitMs;
|
|
314
|
+
this.dispatchAckResendIntervalMs = deps.dispatchAckResendIntervalMs ?? this.dispatchAckResendIntervalMs;
|
|
315
|
+
this.compactIdleWaitMs = deps.compactIdleWaitMs ?? this.compactIdleWaitMs;
|
|
316
|
+
this.manualCompactWaitMs = deps.manualCompactWaitMs ?? this.manualCompactWaitMs;
|
|
317
|
+
this.runtimeMenuPollIntervalMs = deps.runtimeMenuPollIntervalMs ?? this.runtimeMenuPollIntervalMs;
|
|
318
|
+
this.runtimeLivenessProbeMs = deps.runtimeLivenessProbeMs ?? this.runtimeLivenessProbeMs;
|
|
319
|
+
this.cleanComposerWaitMs = deps.cleanComposerWaitMs ?? this.cleanComposerWaitMs;
|
|
320
|
+
this.compactIdlePollMs = deps.compactIdlePollMs ?? this.compactIdlePollMs;
|
|
321
|
+
this.readyStableSpacingMs = deps.readyStableSpacingMs ?? this.readyStableSpacingMs;
|
|
308
322
|
this.agentIndex = buildAgentIndex(config);
|
|
309
323
|
this.platformRunner = deps.platformRunner ?? new LocalRunner();
|
|
310
324
|
this.platformDriverExec = makeDriverExec(this.platformRunner);
|
|
@@ -896,7 +910,7 @@ export class AgentManager {
|
|
|
896
910
|
console.warn(`[bootstrap] greeting attempt ${attempt}/${this.greetingMaxAttempts} for ${agentId}: ${outcome}`);
|
|
897
911
|
if (outcome === 'no-agent')
|
|
898
912
|
break;
|
|
899
|
-
if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, pane, agentId))) {
|
|
913
|
+
if (attempt < this.greetingMaxAttempts && !(await this.clearComposerForReuse(tmux, pane, agentId, agent.runtime))) {
|
|
900
914
|
break;
|
|
901
915
|
}
|
|
902
916
|
}
|
|
@@ -2089,6 +2103,105 @@ export class AgentManager {
|
|
|
2089
2103
|
}
|
|
2090
2104
|
}
|
|
2091
2105
|
}
|
|
2106
|
+
spawnRetry = new Map();
|
|
2107
|
+
spawnRetryInFlight = new Set();
|
|
2108
|
+
spawnRetryTimer = null;
|
|
2109
|
+
async spawnTaskFromSignal(intent) {
|
|
2110
|
+
const key = `${intent.taskId}\0${intent.title}`;
|
|
2111
|
+
let outcome;
|
|
2112
|
+
try {
|
|
2113
|
+
outcome = await this.withTaskLock(() => this.trySpawnTask(intent));
|
|
2114
|
+
}
|
|
2115
|
+
catch (err) {
|
|
2116
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2117
|
+
const previous = this.spawnRetry.get(key);
|
|
2118
|
+
const entry = { ...intent, lastError: message, notified: previous?.notified ?? false };
|
|
2119
|
+
this.spawnRetry.set(key, entry);
|
|
2120
|
+
this.ensureSpawnRetryTimer();
|
|
2121
|
+
if (!previous) {
|
|
2122
|
+
console.error(`[AgentManager] task-create from ${intent.taskId} failed, queued for retry: ${message}`);
|
|
2123
|
+
}
|
|
2124
|
+
else if (previous.lastError !== message) {
|
|
2125
|
+
console.warn(`[AgentManager] task-create from ${intent.taskId} still failing: ${message}`);
|
|
2126
|
+
}
|
|
2127
|
+
if (!entry.notified) {
|
|
2128
|
+
entry.notified = await this.emitIntervention(intent.projectId, intent.agentId, intent.taskId, {
|
|
2129
|
+
phase: 'task-create-failed', title: intent.title, token: intent.token, error: message,
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
this.spawnRetry.delete(key);
|
|
2135
|
+
this.ensureSpawnRetryTimer();
|
|
2136
|
+
if (outcome === 'source-missing') {
|
|
2137
|
+
console.warn(`[AgentManager] task-create dropped: source ${intent.taskId} no longer exists`);
|
|
2138
|
+
}
|
|
2139
|
+
else if (outcome === 'project-missing') {
|
|
2140
|
+
console.error(`[AgentManager] task-create from ${intent.taskId} abandoned: project ${intent.projectId} no longer configured`);
|
|
2141
|
+
const persisted = await this.emitFinalSpawnFailure(intent);
|
|
2142
|
+
if (!persisted && !this.spawnRetry.has(key)) {
|
|
2143
|
+
this.spawnRetry.set(key, { ...intent, lastError: 'project-missing', notified: false, final: 'project-missing' });
|
|
2144
|
+
this.ensureSpawnRetryTimer();
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
// A final verdict only retries its notification; the intent itself is closed and must never create.
|
|
2149
|
+
emitFinalSpawnFailure(intent) {
|
|
2150
|
+
return this.emitIntervention(intent.projectId, intent.agentId, intent.taskId, {
|
|
2151
|
+
phase: 'task-create-failed', reason: 'project-missing', title: intent.title, token: intent.token,
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
async trySpawnTask(intent) {
|
|
2155
|
+
const source = await this.taskStore.get(intent.taskId);
|
|
2156
|
+
if (!source)
|
|
2157
|
+
return 'source-missing';
|
|
2158
|
+
if (!this.getProjectConfig(source.projectId))
|
|
2159
|
+
return 'project-missing';
|
|
2160
|
+
const child = await this.createTask(source.projectId, {
|
|
2161
|
+
title: intent.title,
|
|
2162
|
+
description: '',
|
|
2163
|
+
preferredAgentId: '',
|
|
2164
|
+
origin: { taskId: source.id, title: intent.title },
|
|
2165
|
+
});
|
|
2166
|
+
console.info(`[AgentManager] task-create from ${source.id} via ${intent.agentId} -> ${child.id}`);
|
|
2167
|
+
return 'done';
|
|
2168
|
+
}
|
|
2169
|
+
ensureSpawnRetryTimer() {
|
|
2170
|
+
if (this.spawnRetry.size === 0) {
|
|
2171
|
+
if (this.spawnRetryTimer) {
|
|
2172
|
+
clearInterval(this.spawnRetryTimer);
|
|
2173
|
+
this.spawnRetryTimer = null;
|
|
2174
|
+
}
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
if (this.spawnRetryTimer)
|
|
2178
|
+
return;
|
|
2179
|
+
this.spawnRetryTimer = setInterval(() => {
|
|
2180
|
+
void this.outsideTaskMutationScope(() => this.spawnRetryPass());
|
|
2181
|
+
}, this.needInputRetryIntervalMs);
|
|
2182
|
+
this.spawnRetryTimer.unref?.();
|
|
2183
|
+
}
|
|
2184
|
+
async spawnRetryPass() {
|
|
2185
|
+
for (const [key, entry] of [...this.spawnRetry.entries()]) {
|
|
2186
|
+
if (this.spawnRetryInFlight.has(key))
|
|
2187
|
+
continue;
|
|
2188
|
+
this.spawnRetryInFlight.add(key);
|
|
2189
|
+
try {
|
|
2190
|
+
if (entry.final) {
|
|
2191
|
+
const persisted = await this.emitFinalSpawnFailure(entry);
|
|
2192
|
+
if (persisted && this.spawnRetry.get(key) === entry)
|
|
2193
|
+
this.spawnRetry.delete(key);
|
|
2194
|
+
}
|
|
2195
|
+
else {
|
|
2196
|
+
await this.spawnTaskFromSignal(entry);
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
finally {
|
|
2200
|
+
this.spawnRetryInFlight.delete(key);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
this.ensureSpawnRetryTimer();
|
|
2204
|
+
}
|
|
2092
2205
|
needInputRetry = new Map();
|
|
2093
2206
|
needInputRetryInFlight = new Set();
|
|
2094
2207
|
gitReviewRecoveryAlerted = new Set();
|
|
@@ -3044,7 +3157,7 @@ export class AgentManager {
|
|
|
3044
3157
|
}
|
|
3045
3158
|
async clearComposerAndConfirmReady(tmux, pane, runtime) {
|
|
3046
3159
|
try {
|
|
3047
|
-
await tmux.clearComposerDraft(pane);
|
|
3160
|
+
await tmux.clearComposerDraft(pane, runtime);
|
|
3048
3161
|
}
|
|
3049
3162
|
catch (err) {
|
|
3050
3163
|
console.warn(`[AgentManager] interruptPaneAndWaitReady: composer clear failed for pane ${pane.paneId}:`, err);
|
|
@@ -3114,10 +3227,10 @@ export class AgentManager {
|
|
|
3114
3227
|
}
|
|
3115
3228
|
return true;
|
|
3116
3229
|
}
|
|
3117
|
-
async paneReachedReplReady(tmux, pane, runtime, timeoutMs) {
|
|
3230
|
+
async paneReachedReplReady(tmux, pane, runtime, timeoutMs, opts = {}) {
|
|
3118
3231
|
const paneId = pane.paneId;
|
|
3119
3232
|
try {
|
|
3120
|
-
await tmux.waitReplReady(pane, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true });
|
|
3233
|
+
await tmux.waitReplReady(pane, runtime, { timeoutMs, scrollback: 0, titleIdleFastPath: true, ...opts });
|
|
3121
3234
|
return true;
|
|
3122
3235
|
}
|
|
3123
3236
|
catch (err) {
|
|
@@ -3223,23 +3336,28 @@ export class AgentManager {
|
|
|
3223
3336
|
});
|
|
3224
3337
|
}
|
|
3225
3338
|
async failTasksForAgent(agentId, reason) {
|
|
3226
|
-
const failed = await this.withTaskLock(
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3339
|
+
const failed = await this.withTaskLock(() => this.failBoundTasksLocked(agentId));
|
|
3340
|
+
return this.publishFailedTasks(agentId, reason, failed);
|
|
3341
|
+
}
|
|
3342
|
+
async failBoundTasksLocked(agentId) {
|
|
3343
|
+
const tasks = await this.taskStore.list({});
|
|
3344
|
+
const out = [];
|
|
3345
|
+
for (const t of tasks) {
|
|
3346
|
+
const bound = t.agentId === agentId || t.qaAgentId === agentId;
|
|
3347
|
+
if (t.status === 'merge-ready')
|
|
3348
|
+
continue;
|
|
3349
|
+
if (ACTIVE_TASK_STATUSES.has(t.status) && bound) {
|
|
3350
|
+
const failedTask = this.stripGitStatusScopedState(t, 'failed');
|
|
3351
|
+
failedTask.status = 'failed';
|
|
3352
|
+
failedTask.updatedAt = new Date().toISOString();
|
|
3353
|
+
await this.taskStore.set(failedTask);
|
|
3354
|
+
out.push(failedTask);
|
|
3240
3355
|
}
|
|
3241
|
-
|
|
3242
|
-
|
|
3356
|
+
}
|
|
3357
|
+
return out;
|
|
3358
|
+
}
|
|
3359
|
+
// 事件与伙伴释放/排空留在 task lock 外:排空可能等待需要 task lock 的操作
|
|
3360
|
+
async publishFailedTasks(agentId, reason, failed) {
|
|
3243
3361
|
for (const t of failed) {
|
|
3244
3362
|
await this.safeEmit({
|
|
3245
3363
|
id: '',
|
|
@@ -3278,22 +3396,96 @@ export class AgentManager {
|
|
|
3278
3396
|
}
|
|
3279
3397
|
async pollPaneCommandStable(tmux, pane, opts) {
|
|
3280
3398
|
const deadline = Date.now() + opts.timeoutMs;
|
|
3281
|
-
const SHELL = /^(?:zsh|bash|sh|fish)$/;
|
|
3282
3399
|
let last = '';
|
|
3283
3400
|
while (Date.now() < deadline) {
|
|
3284
3401
|
await new Promise(r => setTimeout(r, 100));
|
|
3285
3402
|
const raw = await tmux.displayMessage(pane, '#{pane_current_command}');
|
|
3286
3403
|
last = raw.trim();
|
|
3287
|
-
if (opts.expectShell ?
|
|
3404
|
+
if (opts.expectShell ? isShellProcTitle(last) : last !== '')
|
|
3288
3405
|
return last;
|
|
3289
3406
|
}
|
|
3290
3407
|
return last;
|
|
3291
3408
|
}
|
|
3409
|
+
// 不盲发 C-c:空 composer 上的 C-c 会让 codex 立即退出并花数秒生成 recap,之后的 /quit 与重启命令全都打进正在退出的 TUI
|
|
3410
|
+
async exitReplForRestart(tmux, pane, cfg) {
|
|
3411
|
+
const runtime = agentRuntimeKindFor(cfg);
|
|
3412
|
+
// 已空闲就不发 Escape:codex 上它会武装 backtrack 提示,vim Insert 模式下会切到 Normal;只有 turn 进行中才需要打断
|
|
3413
|
+
let foreground = await this.waitIdleOrShell(tmux, pane, runtime);
|
|
3414
|
+
if (foreground === 'busy') {
|
|
3415
|
+
if (await this.reachedRuntime(() => tmux.sendKeysToRuntime(pane, runtime, 'Escape'))) {
|
|
3416
|
+
await new Promise(r => setTimeout(r, 200));
|
|
3417
|
+
}
|
|
3418
|
+
foreground = await this.waitIdleOrShell(tmux, pane, runtime);
|
|
3419
|
+
if (foreground === 'busy') {
|
|
3420
|
+
throw new Error(`restart-repl: ${runtime} in pane ${pane.paneId} did not return to an idle prompt after Escape; ` +
|
|
3421
|
+
'interrupt or finish the running turn via the web terminal, then retry');
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
if (foreground === 'shell')
|
|
3425
|
+
return;
|
|
3426
|
+
await tmux.clearComposerDraft(pane, runtime);
|
|
3427
|
+
// 退出文本与 Enter 是同一条守卫命令:到达前 runtime 已退到 shell 就整组不发(/exit 不会被 shell 当命令执行),前台是 vim 这类进程也不会只留半行在 composer
|
|
3428
|
+
if (!await this.reachedRuntime(() => tmux.submitToRuntime(pane, runtime, REPL_EXIT_COMMAND[cfg.runtime])))
|
|
3429
|
+
return;
|
|
3430
|
+
const after = await this.pollPaneCommandStable(tmux, pane, { timeoutMs: this.replExitWaitMs, expectShell: true });
|
|
3431
|
+
if (!isShellProcTitle(after)) {
|
|
3432
|
+
throw new Error(`restart-repl: ${runtime} in pane ${pane.paneId} did not exit within ${this.replExitWaitMs}ms ` +
|
|
3433
|
+
`(pane_current_command=${after || 'unknown'}); not relaunching over a live runtime`);
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
// 服务端因前台已是 shell 而拒绝的 runtime 写不是失败:runtime 在按键到达前自己退了,交回按 shell 处理的分支;其他前台进程的拒绝照常抛出
|
|
3437
|
+
async reachedRuntime(write) {
|
|
3438
|
+
try {
|
|
3439
|
+
await write();
|
|
3440
|
+
return true;
|
|
3441
|
+
}
|
|
3442
|
+
catch (err) {
|
|
3443
|
+
if (err instanceof ReplNotReadyError && err.shellForeground)
|
|
3444
|
+
return false;
|
|
3445
|
+
throw err;
|
|
3446
|
+
}
|
|
3447
|
+
}
|
|
3448
|
+
// 等待期间 runtime 可能自行退到 shell(例如被误杀后 recap 刚结束):此时 Escape/退出命令都会打进 shell,应直接交回 relaunch
|
|
3449
|
+
async waitIdleOrShell(tmux, pane, runtime) {
|
|
3450
|
+
if (await this.paneReachedReplReady(tmux, pane, runtime, this.restartInterruptWaitMs, { failFastOnShell: true, runtimeSeen: true })) {
|
|
3451
|
+
return 'idle';
|
|
3452
|
+
}
|
|
3453
|
+
const current = (await tmux.displayMessage(pane, '#{pane_current_command}')).trim();
|
|
3454
|
+
return isShellProcTitle(current) ? 'shell' : 'busy';
|
|
3455
|
+
}
|
|
3456
|
+
// restart-repl / retry 从退出/重建到就绪后的清理与任务提示词 replay 是一段维护操作:两段并发会各自对同一个新 runtime replay 一次
|
|
3457
|
+
tryBeginMaintenance(agentId) {
|
|
3458
|
+
if (this.maintenanceInFlight.has(agentId))
|
|
3459
|
+
return false;
|
|
3460
|
+
this.maintenanceInFlight.add(agentId);
|
|
3461
|
+
return true;
|
|
3462
|
+
}
|
|
3463
|
+
endMaintenance(agentId) {
|
|
3464
|
+
this.maintenanceInFlight.delete(agentId);
|
|
3465
|
+
}
|
|
3466
|
+
isMaintenanceInFlight(agentId) {
|
|
3467
|
+
return this.maintenanceInFlight.has(agentId);
|
|
3468
|
+
}
|
|
3292
3469
|
async restartReplOnly(agentId, opts = {}) {
|
|
3293
3470
|
const genAtEntry = opts.expectedGeneration ?? this.deletionGenerationOf(agentId);
|
|
3294
3471
|
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
3295
3472
|
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
3296
3473
|
}
|
|
3474
|
+
if (!this.getAgentConfig(agentId))
|
|
3475
|
+
throw new Error(`Unknown agent: ${agentId}`);
|
|
3476
|
+
// 退出/重启是一整段状态机,只有最后的 relaunch 在服务端原子:整段与 compact/注入/上传共用 pane 互斥,与 retry/删除共用会话生命周期链
|
|
3477
|
+
if (!this.tryAcquireCompactGuard(agentId)) {
|
|
3478
|
+
throw new ApiError(409, `Agent ${agentId} pane is busy (compact, upload, dispatch or another restart in progress); retry shortly`);
|
|
3479
|
+
}
|
|
3480
|
+
try {
|
|
3481
|
+
await this.runUnderSessionLifecycle(agentId, () => this.restartReplOnlyLocked(agentId, genAtEntry));
|
|
3482
|
+
}
|
|
3483
|
+
finally {
|
|
3484
|
+
this.compactInFlight.delete(agentId);
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
async restartReplOnlyLocked(agentId, genAtEntry) {
|
|
3488
|
+
// 配置在拿到生命周期链之后才解析:排队期间的热更新可能改了 runtime/model 等启动参数,入口捕获的旧值会拉起旧 runtime
|
|
3297
3489
|
const cfg = this.getAgentConfig(agentId);
|
|
3298
3490
|
if (!cfg)
|
|
3299
3491
|
throw new Error(`Unknown agent: ${agentId}`);
|
|
@@ -3310,23 +3502,22 @@ export class AgentManager {
|
|
|
3310
3502
|
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
3311
3503
|
}
|
|
3312
3504
|
const pane = await tmux.getSinglePaneByRef(snapshot.ref, agentId);
|
|
3313
|
-
await tmux.sendKeysToPane(pane, 'C-c');
|
|
3314
|
-
const cmd = await this.pollPaneCommandStable(tmux, pane, { timeoutMs: 2_000 });
|
|
3315
3505
|
const RUNTIME = /^(?:claude|codex|node|opencode|qodercli(?:-[\d.]+)?|\d+\.\d+\.\d+)$/;
|
|
3316
|
-
const
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3506
|
+
const readForeground = async () => {
|
|
3507
|
+
const cmd = (await tmux.displayMessage(pane, '#{pane_current_command}')).trim();
|
|
3508
|
+
if (!RUNTIME.test(cmd) && !isShellProcTitle(cmd)) {
|
|
3509
|
+
throw new Error(`restart-repl precondition failed: unexpected pane state "${cmd}"`);
|
|
3510
|
+
}
|
|
3511
|
+
return cmd;
|
|
3512
|
+
};
|
|
3513
|
+
await readForeground();
|
|
3324
3514
|
const project = this.getProjectConfig(cfg.projectId);
|
|
3325
3515
|
if (!project)
|
|
3326
3516
|
throw new Error(`restart-repl: project ${cfg.projectId} does not exist`);
|
|
3327
3517
|
if (!this.deletionGateOpen(agentId, genAtEntry)) {
|
|
3328
3518
|
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
3329
3519
|
}
|
|
3520
|
+
// 可能耗时或失败的准备(fetch、Workdir 核对)都放在退出 REPL 之前:失败时 REPL 仍活着,pane 停在 shell 的窗口只剩 relaunch 一瞬
|
|
3330
3521
|
const { workdir } = await this.ensureWorkdir(cfg, project, runner);
|
|
3331
3522
|
const paneWorkdir = await tmux.getPaneCurrentPath(pane);
|
|
3332
3523
|
if (!await sameDirOnHost(runner, paneWorkdir, workdir)) {
|
|
@@ -3337,10 +3528,25 @@ export class AgentManager {
|
|
|
3337
3528
|
throw new Error(`restart-repl: agent ${agentId} is being deleted or was recreated; aborting`);
|
|
3338
3529
|
}
|
|
3339
3530
|
await this.setSessionOptions(tmux, agentId, snapshot.ref, [[WORKDIR_SESSION_OPTION, workdir]]);
|
|
3531
|
+
// 热更新不在这条链上:退出与 relaunch 之间启动参数或连接目标又变了就停下,不能按一套配置退出、按另一套(或在另一台机器上)拉起
|
|
3532
|
+
const launchCommand = launchCommandIn(workdir, cfg);
|
|
3533
|
+
const restartTarget = (agent) => `${hostGroupKey(agent.mode, resolveAgentHost(this.config.host, agent.host))} ${launchCommandIn(workdir, agent)}`;
|
|
3534
|
+
const target = restartTarget(cfg);
|
|
3535
|
+
const assertTargetUnchanged = (stage) => {
|
|
3536
|
+
const now = this.getAgentConfig(agentId);
|
|
3537
|
+
if (!now || restartTarget(now) !== target) {
|
|
3538
|
+
throw new Error(`restart-repl: agent ${agentId} config changed while ${stage}; run Restart REPL again against the current config`);
|
|
3539
|
+
}
|
|
3540
|
+
};
|
|
3541
|
+
assertTargetUnchanged('preparing the restart');
|
|
3542
|
+
if (RUNTIME.test(await readForeground())) {
|
|
3543
|
+
await this.exitReplForRestart(tmux, pane, cfg);
|
|
3544
|
+
}
|
|
3545
|
+
assertTargetUnchanged('exiting the runtime');
|
|
3340
3546
|
const runtime = agentRuntimeKindFor(cfg);
|
|
3341
3547
|
const relaunch = async () => {
|
|
3342
|
-
|
|
3343
|
-
await tmux.
|
|
3548
|
+
// C-c 只丢弃 shell readline 里的残留输入(操作员误键、晚到的弄脏键),启动命令不能接在残留文本后面执行;前台不是 shell 则整组按键都不发
|
|
3549
|
+
await tmux.submitCommandOnShell(pane, launchCommand);
|
|
3344
3550
|
await tmux.handleTrustDialog(pane, runtime, {
|
|
3345
3551
|
timeoutMs: this.bootstrapTimeoutsMs.trustDialog,
|
|
3346
3552
|
});
|
|
@@ -5924,6 +6130,13 @@ export class AgentManager {
|
|
|
5924
6130
|
}
|
|
5925
6131
|
async createTask(projectId, input) {
|
|
5926
6132
|
return this.withTaskLock(async () => {
|
|
6133
|
+
const origin = input.origin;
|
|
6134
|
+
if (origin) {
|
|
6135
|
+
const existing = (await this.taskStore.listStrict({ projectId }))
|
|
6136
|
+
.find(task => task.origin?.taskId === origin.taskId && task.origin.title === origin.title);
|
|
6137
|
+
if (existing)
|
|
6138
|
+
return existing;
|
|
6139
|
+
}
|
|
5927
6140
|
const taskId = await this.taskStore.nextId();
|
|
5928
6141
|
const now = new Date().toISOString();
|
|
5929
6142
|
if (input.branch) {
|
|
@@ -5958,6 +6171,7 @@ export class AgentManager {
|
|
|
5958
6171
|
createdAt: now,
|
|
5959
6172
|
updatedAt: now,
|
|
5960
6173
|
...(imageFilenames ? { images: imageFilenames } : {}),
|
|
6174
|
+
...(input.origin ? { origin: input.origin } : {}),
|
|
5961
6175
|
};
|
|
5962
6176
|
if (input.preferredAgentId === '') {
|
|
5963
6177
|
const unassigned = {
|
|
@@ -6231,7 +6445,7 @@ export class AgentManager {
|
|
|
6231
6445
|
const tmux = new TmuxManager(runner);
|
|
6232
6446
|
const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
|
|
6233
6447
|
await assertUploadStillValid();
|
|
6234
|
-
await tmux.injectPrompt(pane, `${path} `, agentId);
|
|
6448
|
+
await tmux.injectPrompt(pane, `${path} `, agentId, cfg.runtime);
|
|
6235
6449
|
return { path };
|
|
6236
6450
|
}
|
|
6237
6451
|
finally {
|
|
@@ -6264,25 +6478,35 @@ export class AgentManager {
|
|
|
6264
6478
|
};
|
|
6265
6479
|
const tmux = new TmuxManager(this.createRunnerFor(cfg));
|
|
6266
6480
|
const pane = await this.resolveClaimedPane(tmux, agentId, paneId);
|
|
6267
|
-
const waitReady = async () => {
|
|
6481
|
+
const waitReady = async (stage) => {
|
|
6268
6482
|
try {
|
|
6269
6483
|
await this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.manualCompactWaitMs);
|
|
6270
6484
|
}
|
|
6271
6485
|
catch (err) {
|
|
6486
|
+
console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) ${stage}: runtime not at an idle prompt:`, err);
|
|
6272
6487
|
const detail = err instanceof Error ? err.message : String(err);
|
|
6273
6488
|
throw new ApiError(409, `Agent ${agentId} runtime is not at an idle REPL prompt: ${detail}`);
|
|
6274
6489
|
}
|
|
6275
6490
|
};
|
|
6276
|
-
await waitReady();
|
|
6491
|
+
await waitReady('before composer clear');
|
|
6277
6492
|
await assertSessionUnchanged();
|
|
6278
|
-
|
|
6279
|
-
|
|
6493
|
+
try {
|
|
6494
|
+
await tmux.clearComposerDraft(pane, cfg.runtime);
|
|
6495
|
+
}
|
|
6496
|
+
catch (err) {
|
|
6497
|
+
console.warn(`[AgentManager] sendSlashCommand(${agentId}, ${command}) composer clear failed:`, err);
|
|
6498
|
+
if (err instanceof ReplNotReadyError || err instanceof PaneGoneError) {
|
|
6499
|
+
throw new ApiError(409, `Agent ${agentId} composer could not be cleared before ${command}: ${err.message}`);
|
|
6500
|
+
}
|
|
6501
|
+
throw err;
|
|
6502
|
+
}
|
|
6503
|
+
await waitReady('after composer clear');
|
|
6280
6504
|
await assertSessionUnchanged();
|
|
6281
6505
|
if (command === '/clear') {
|
|
6282
6506
|
await this.setSessionOptions(tmux, agentId, pane.session, [[TASK_CONTEXT_SESSION_OPTION, '']]);
|
|
6283
6507
|
}
|
|
6284
|
-
await tmux.sendKeysLiteral(pane, command);
|
|
6285
|
-
await tmux.sendEnter(pane);
|
|
6508
|
+
await tmux.sendKeysLiteral(pane, command, cfg.runtime);
|
|
6509
|
+
await tmux.sendEnter(pane, cfg.runtime);
|
|
6286
6510
|
guardHandedOff = true;
|
|
6287
6511
|
void this.outsideTaskMutationScope(() => this.waitForReplPromptReady(tmux, pane, cfg.runtime, this.compactIdleWaitMs))
|
|
6288
6512
|
.catch(err => {
|
|
@@ -7116,8 +7340,8 @@ export class AgentManager {
|
|
|
7116
7340
|
if (!(await guardBeforePaste()))
|
|
7117
7341
|
return false;
|
|
7118
7342
|
if (!paneWorking)
|
|
7119
|
-
await tmux.clearComposerDraft(pane);
|
|
7120
|
-
await tmux.pasteStagedBuffer(
|
|
7343
|
+
await tmux.clearComposerDraft(pane, runtime);
|
|
7344
|
+
await tmux.pasteStagedBuffer(pane, staged.buf, runtime);
|
|
7121
7345
|
return true;
|
|
7122
7346
|
});
|
|
7123
7347
|
}
|
|
@@ -7136,7 +7360,7 @@ export class AgentManager {
|
|
|
7136
7360
|
throw new DispatchTerminalError('ack_unknown', `paste outcome unknown on working pane ${paneId}; composer left untouched to keep the live turn intact: ${message}`);
|
|
7137
7361
|
}
|
|
7138
7362
|
try {
|
|
7139
|
-
await tmux.clearComposerDraft(pane);
|
|
7363
|
+
await tmux.clearComposerDraft(pane, runtime);
|
|
7140
7364
|
}
|
|
7141
7365
|
catch (clearErr) {
|
|
7142
7366
|
console.warn(`[AgentManager] composer scrub after unknown paste outcome failed for pane ${paneId}:`, clearErr);
|
|
@@ -7156,9 +7380,9 @@ export class AgentManager {
|
|
|
7156
7380
|
}
|
|
7157
7381
|
else {
|
|
7158
7382
|
if (!paneWorking)
|
|
7159
|
-
await tmux.clearComposerDraft(pane);
|
|
7383
|
+
await tmux.clearComposerDraft(pane, runtime);
|
|
7160
7384
|
await revalidate?.();
|
|
7161
|
-
await tmux.injectPrompt(pane, prompt, agentId);
|
|
7385
|
+
await tmux.injectPrompt(pane, prompt, agentId, runtime);
|
|
7162
7386
|
}
|
|
7163
7387
|
let baseline;
|
|
7164
7388
|
let baselineTitle = '';
|
|
@@ -7169,7 +7393,7 @@ export class AgentManager {
|
|
|
7169
7393
|
const submitted = await this.withTaskLock(async () => {
|
|
7170
7394
|
if (!(await guardBeforePaste()))
|
|
7171
7395
|
return false;
|
|
7172
|
-
await tmux.sendEnter(pane);
|
|
7396
|
+
await tmux.sendEnter(pane, runtime);
|
|
7173
7397
|
return true;
|
|
7174
7398
|
});
|
|
7175
7399
|
if (!submitted) {
|
|
@@ -7177,7 +7401,7 @@ export class AgentManager {
|
|
|
7177
7401
|
throw new Error(`fence-rejected prompt stays in the composer of working pane ${paneId}`);
|
|
7178
7402
|
}
|
|
7179
7403
|
try {
|
|
7180
|
-
await tmux.clearComposerDraft(pane);
|
|
7404
|
+
await tmux.clearComposerDraft(pane, runtime);
|
|
7181
7405
|
}
|
|
7182
7406
|
catch (err) {
|
|
7183
7407
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -7187,7 +7411,7 @@ export class AgentManager {
|
|
|
7187
7411
|
}
|
|
7188
7412
|
}
|
|
7189
7413
|
else {
|
|
7190
|
-
await tmux.sendEnter(pane);
|
|
7414
|
+
await tmux.sendEnter(pane, runtime);
|
|
7191
7415
|
}
|
|
7192
7416
|
}
|
|
7193
7417
|
catch (preAckErr) {
|
|
@@ -7196,7 +7420,7 @@ export class AgentManager {
|
|
|
7196
7420
|
if (paneWorking) {
|
|
7197
7421
|
throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on working pane ${paneId}: ${message}`);
|
|
7198
7422
|
}
|
|
7199
|
-
if (await this.clearComposerForReuse(tmux, pane, agentId))
|
|
7423
|
+
if (await this.clearComposerForReuse(tmux, pane, agentId, runtime))
|
|
7200
7424
|
throw preAckErr;
|
|
7201
7425
|
throw new DispatchTerminalError('ack_unknown', `pre-ack failure left an unconfirmed composer on live pane ${paneId}: ${message}`);
|
|
7202
7426
|
}
|
|
@@ -7213,13 +7437,13 @@ export class AgentManager {
|
|
|
7213
7437
|
const resent = await this.withTaskLock(async () => {
|
|
7214
7438
|
if (!(await guardBeforePaste()))
|
|
7215
7439
|
return false;
|
|
7216
|
-
await tmux.sendEnter(pane);
|
|
7440
|
+
await tmux.sendEnter(pane, runtime);
|
|
7217
7441
|
return true;
|
|
7218
7442
|
});
|
|
7219
7443
|
if (!resent)
|
|
7220
7444
|
staleDuringResend = true;
|
|
7221
7445
|
}
|
|
7222
|
-
: () => tmux.sendEnter(pane),
|
|
7446
|
+
: () => tmux.sendEnter(pane, runtime),
|
|
7223
7447
|
resendIntervalMs: this.dispatchAckResendIntervalMs,
|
|
7224
7448
|
});
|
|
7225
7449
|
return { acked: true, composerDelivered: true };
|
|
@@ -7231,7 +7455,7 @@ export class AgentManager {
|
|
|
7231
7455
|
}
|
|
7232
7456
|
if (staleDuringResend) {
|
|
7233
7457
|
try {
|
|
7234
|
-
await tmux.clearComposerDraft(pane);
|
|
7458
|
+
await tmux.clearComposerDraft(pane, runtime);
|
|
7235
7459
|
}
|
|
7236
7460
|
catch (scrubErr) {
|
|
7237
7461
|
const scrubMessage = scrubErr instanceof Error ? scrubErr.message : String(scrubErr);
|
|
@@ -7252,10 +7476,10 @@ export class AgentManager {
|
|
|
7252
7476
|
return { acked: false, composerDelivered: true };
|
|
7253
7477
|
}
|
|
7254
7478
|
}
|
|
7255
|
-
async clearComposerForReuse(tmux, pane, agentId) {
|
|
7479
|
+
async clearComposerForReuse(tmux, pane, agentId, runtime) {
|
|
7256
7480
|
const paneId = pane.paneId;
|
|
7257
7481
|
try {
|
|
7258
|
-
await tmux.clearComposerDraft(pane);
|
|
7482
|
+
await tmux.clearComposerDraft(pane, runtime);
|
|
7259
7483
|
return true;
|
|
7260
7484
|
}
|
|
7261
7485
|
catch (err) {
|
|
@@ -7906,58 +8130,65 @@ export class AgentManager {
|
|
|
7906
8130
|
console.warn('[AgentManager] recover: pending git review retry failed:', err);
|
|
7907
8131
|
});
|
|
7908
8132
|
}
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
8133
|
+
// absent 结论在等锁期间可能已过期:与 retry/删除的会话重建同处一条生命周期链,链内重探仍 absent、binding 写入时 stillCurrent 仍真,binding 与任务才在同一临界区内一起落下
|
|
8134
|
+
async reconcileFailedAgent(agentId, opts = {}) {
|
|
8135
|
+
const stillCurrent = opts.stillCurrent ?? (() => true);
|
|
8136
|
+
const reconciled = await this.runUnderSessionLifecycle(agentId, async () => {
|
|
8137
|
+
if (!(await this.sessionStillAbsent(agentId)))
|
|
8138
|
+
return null;
|
|
8139
|
+
return this.withTaskLock(async () => {
|
|
8140
|
+
let projectId = '';
|
|
8141
|
+
let timestamp = '';
|
|
8142
|
+
let hadBinding = false;
|
|
8143
|
+
let changed = false;
|
|
8144
|
+
let taskId;
|
|
8145
|
+
await this.agentStore.update(agentId, (existing) => {
|
|
8146
|
+
if (!existing || !stillCurrent())
|
|
8147
|
+
return AGENT_STORE_NOOP;
|
|
8148
|
+
if (existing.creationToken)
|
|
8149
|
+
return AGENT_STORE_NOOP;
|
|
8150
|
+
if (existing.awaitingPhase != null && REGREET_REQUIRED_HOLD_PHASES.has(existing.awaitingPhase)) {
|
|
8151
|
+
return AGENT_STORE_NOOP;
|
|
8152
|
+
}
|
|
8153
|
+
timestamp = new Date().toISOString();
|
|
8154
|
+
projectId = existing.projectId;
|
|
8155
|
+
hadBinding = !!existing.taskId;
|
|
8156
|
+
taskId = existing.taskId;
|
|
8157
|
+
if (!existing.taskId
|
|
8158
|
+
&& !existing.startedAt
|
|
8159
|
+
&& !existing.paneId
|
|
8160
|
+
&& !existing.creationToken) {
|
|
8161
|
+
return AGENT_STORE_NOOP;
|
|
8162
|
+
}
|
|
8163
|
+
changed = true;
|
|
8164
|
+
if (existing.taskId) {
|
|
8165
|
+
return {
|
|
8166
|
+
...existing,
|
|
8167
|
+
paneId: undefined,
|
|
8168
|
+
status: 'awaiting_human',
|
|
8169
|
+
awaitingPhase: 'runtime-missing',
|
|
8170
|
+
awaitingReason: 'The tmux session is missing; restart the REPL before releasing this task binding.',
|
|
8171
|
+
awaitingSince: timestamp,
|
|
8172
|
+
updatedAt: timestamp,
|
|
8173
|
+
};
|
|
8174
|
+
}
|
|
7936
8175
|
return {
|
|
7937
|
-
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
awaitingPhase: 'runtime-missing',
|
|
7941
|
-
awaitingReason: 'The tmux session is missing; restart the REPL before releasing this task binding.',
|
|
7942
|
-
awaitingSince: timestamp,
|
|
8176
|
+
id: existing.id,
|
|
8177
|
+
projectId: existing.projectId,
|
|
8178
|
+
...(existing.workdir !== undefined ? { workdir: existing.workdir } : {}),
|
|
7943
8179
|
updatedAt: timestamp,
|
|
7944
8180
|
};
|
|
7945
|
-
}
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
updatedAt: timestamp,
|
|
7951
|
-
};
|
|
8181
|
+
});
|
|
8182
|
+
if (!projectId || !changed)
|
|
8183
|
+
return null;
|
|
8184
|
+
const failed = hadBinding ? await this.failBoundTasksLocked(agentId) : [];
|
|
8185
|
+
return { projectId, timestamp, hadBinding, taskId, failed };
|
|
7952
8186
|
});
|
|
7953
|
-
if (!projectId || !changed)
|
|
7954
|
-
return null;
|
|
7955
|
-
return { projectId, timestamp, hadBinding, taskId };
|
|
7956
8187
|
});
|
|
7957
8188
|
if (!reconciled)
|
|
7958
8189
|
return false;
|
|
7959
8190
|
if (reconciled.hadBinding) {
|
|
7960
|
-
await this.
|
|
8191
|
+
await this.publishFailedTasks(agentId, 'tmux-probe=absent', reconciled.failed);
|
|
7961
8192
|
}
|
|
7962
8193
|
await this.recordError({
|
|
7963
8194
|
agentId,
|
|
@@ -7980,6 +8211,21 @@ export class AgentManager {
|
|
|
7980
8211
|
});
|
|
7981
8212
|
return true;
|
|
7982
8213
|
}
|
|
8214
|
+
// 链内重探与探测器同一口径:没有同名会话、或同名会话的 claim 不是本 agent 才算 absent;探不到(含超时)不能按 absent 落 hold,那会把刚重建好的 REPL 上的任务标成失败
|
|
8215
|
+
async sessionStillAbsent(agentId) {
|
|
8216
|
+
const cfg = this.getAgentConfig(agentId);
|
|
8217
|
+
if (!cfg)
|
|
8218
|
+
return true;
|
|
8219
|
+
try {
|
|
8220
|
+
const snapshot = await new TmuxManager(this.createRunnerFor(cfg))
|
|
8221
|
+
.getSessionSnapshot(agentId, { timeout: this.config.server.tmuxProbeTimeoutMs });
|
|
8222
|
+
return snapshot === null || snapshot.claim !== agentId;
|
|
8223
|
+
}
|
|
8224
|
+
catch (err) {
|
|
8225
|
+
console.warn(`[AgentManager] reconcileFailedAgent(${agentId}): session re-probe inconclusive; leaving the binding untouched:`, err);
|
|
8226
|
+
return false;
|
|
8227
|
+
}
|
|
8228
|
+
}
|
|
7983
8229
|
async cancelTask(taskId) {
|
|
7984
8230
|
let devToRelease;
|
|
7985
8231
|
let qaToRelease;
|
|
@@ -8428,12 +8674,9 @@ export class AgentManager {
|
|
|
8428
8674
|
}
|
|
8429
8675
|
opts.onSideEffect?.();
|
|
8430
8676
|
lease = begun.task.reviewDispatch;
|
|
8431
|
-
|
|
8432
|
-
|
|
8433
|
-
|
|
8434
|
-
signalToken: begun.task.signalToken,
|
|
8435
|
-
};
|
|
8436
|
-
}
|
|
8677
|
+
// 调用方那一代已在 beginGitReviewPass 内校验过,此后要挡的是 pass 开出去之后的改动
|
|
8678
|
+
if (opts.expectedTask)
|
|
8679
|
+
dispatchExpectedTask = taskGenerationGuard(begun.task);
|
|
8437
8680
|
}
|
|
8438
8681
|
const dispatched = await this.dispatchGitReviewLease(taskId, {
|
|
8439
8682
|
expectedGeneration: lease.generation,
|
|
@@ -9197,11 +9440,20 @@ export class AgentManager {
|
|
|
9197
9440
|
});
|
|
9198
9441
|
}
|
|
9199
9442
|
}
|
|
9200
|
-
|
|
9201
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
9204
|
-
|
|
9443
|
+
let released = false;
|
|
9444
|
+
let qaBusy = false;
|
|
9445
|
+
try {
|
|
9446
|
+
// the reconciler may have released the QA while the dev was parking; an unbound QA is a success, not a failure
|
|
9447
|
+
released = await this.releaseAgentIfBound(qaAgentId, taskId, {
|
|
9448
|
+
expectedTask: parkedGeneration,
|
|
9449
|
+
deferWhenBusy: true,
|
|
9450
|
+
});
|
|
9451
|
+
}
|
|
9452
|
+
catch (err) {
|
|
9453
|
+
// a QA still printing after its APPROVE stays bound without a hold; the reconciler releases it once idle
|
|
9454
|
+
qaBusy = err instanceof ReplNotReadyError;
|
|
9455
|
+
}
|
|
9456
|
+
if (!released && !qaBusy) {
|
|
9205
9457
|
await this.emitIntervention(parked.projectId, qaAgentId, taskId, {
|
|
9206
9458
|
phase: 'spec-ready-qa-release-failed',
|
|
9207
9459
|
qaAgentId,
|
|
@@ -9943,8 +10195,8 @@ export class AgentManager {
|
|
|
9943
10195
|
|| (taskId !== undefined && afterResolve.lockToken !== lockToken)) {
|
|
9944
10196
|
throw new Error(`injectTextToAgent: agent ${agentId} binding changed before paste`);
|
|
9945
10197
|
}
|
|
9946
|
-
await tmux.injectPrompt(pane, text, agentId);
|
|
9947
|
-
await tmux.sendEnter(pane);
|
|
10198
|
+
await tmux.injectPrompt(pane, text, agentId, cfg.runtime);
|
|
10199
|
+
await tmux.sendEnter(pane, cfg.runtime);
|
|
9948
10200
|
}
|
|
9949
10201
|
finally {
|
|
9950
10202
|
this.compactInFlight.delete(agentId);
|