@co0ontty/wand 4.67.0 → 4.68.0-beta.gb5f5a70
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/README.md +1 -0
- package/dist/build-info.json +4 -4
- package/dist/server-file-routes.js +5 -1
- package/dist/server-workspace-routes.js +18 -5
- package/dist/server.js +2 -2
- package/dist/session-registry.js +2 -6
- package/dist/storage.d.ts +2 -0
- package/dist/storage.js +12 -7
- package/dist/structured-pi-adapter.js +19 -2
- package/dist/structured-runner.d.ts +2 -0
- package/dist/structured-session-manager.d.ts +6 -1
- package/dist/structured-session-manager.js +108 -33
- package/dist/terminal-daemon-client.d.ts +1 -1
- package/dist/terminal-daemon-client.js +18 -13
- package/dist/types.d.ts +2 -0
- package/dist/web-ui/content/scripts.js +58 -58
- package/dist/web-ui/content/tailwind.css +18 -432
- package/dist/web-ui/embedded-assets.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-09-
|
|
4
|
-
"version": "4.
|
|
5
|
-
"channel": "
|
|
2
|
+
"commit": "b5f5a70e88309c0a06e988480b76760251c1935b",
|
|
3
|
+
"builtAt": "2026-09-17T01:40:08.544Z",
|
|
4
|
+
"version": "4.68.0-beta.gb5f5a70",
|
|
5
|
+
"channel": "beta"
|
|
6
6
|
}
|
|
@@ -182,7 +182,11 @@ export function registerFileRoutes(app, deps) {
|
|
|
182
182
|
return;
|
|
183
183
|
}
|
|
184
184
|
try {
|
|
185
|
-
const fileStat = await
|
|
185
|
+
const fileStat = await lstat(resolvedPath);
|
|
186
|
+
if (fileStat.isSymbolicLink()) {
|
|
187
|
+
res.status(400).json({ error: "无法保存符号链接,请打开目标文件后编辑。" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
186
190
|
if (fileStat.isDirectory()) {
|
|
187
191
|
res.status(400).json({ error: "目标是目录,无法写入。" });
|
|
188
192
|
return;
|
|
@@ -485,8 +485,20 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
|
|
|
485
485
|
groups.set(id, created);
|
|
486
486
|
return created;
|
|
487
487
|
};
|
|
488
|
+
const tasksByWorkspace = new Map(workspaces.map((workspace) => [
|
|
489
|
+
workspace.id, storage.listWorkspaceTasks(workspace.id),
|
|
490
|
+
]));
|
|
491
|
+
const sessionsByTask = new Map();
|
|
492
|
+
const taskSessions = (taskId) => {
|
|
493
|
+
let snapshots = sessionsByTask.get(taskId);
|
|
494
|
+
if (!snapshots) {
|
|
495
|
+
snapshots = storage.listSessionsByWorkspaceTask(taskId);
|
|
496
|
+
sessionsByTask.set(taskId, snapshots);
|
|
497
|
+
}
|
|
498
|
+
return snapshots;
|
|
499
|
+
};
|
|
488
500
|
const appendTask = (target, workspace, task) => {
|
|
489
|
-
const allSessions =
|
|
501
|
+
const allSessions = taskSessions(task.id);
|
|
490
502
|
const sessions = allSessions
|
|
491
503
|
.slice(0, sessionLimit ?? undefined)
|
|
492
504
|
.map((session) => ({
|
|
@@ -508,7 +520,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
|
|
|
508
520
|
if (!base)
|
|
509
521
|
continue;
|
|
510
522
|
const global = isGlobalWorkspace(workspace);
|
|
511
|
-
for (const task of
|
|
523
|
+
for (const task of tasksByWorkspace.get(workspace.id).slice(0, taskLimit ?? undefined)) {
|
|
512
524
|
const taskCwd = taskRuntimeCwd(task, workspace);
|
|
513
525
|
const target = global && taskCwd
|
|
514
526
|
&& normalizeProjectCwd(taskCwd) !== normalizeProjectCwd(workspace.cwd)
|
|
@@ -518,9 +530,10 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
|
|
|
518
530
|
}
|
|
519
531
|
}
|
|
520
532
|
const taskBoundSessionIds = new Set();
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
533
|
+
// Membership must include tasks/sessions hidden by the response limits.
|
|
534
|
+
for (const tasks of tasksByWorkspace.values()) {
|
|
535
|
+
for (const task of tasks) {
|
|
536
|
+
for (const session of taskSessions(task.id))
|
|
524
537
|
taskBoundSessionIds.add(session.id);
|
|
525
538
|
}
|
|
526
539
|
}
|
package/dist/server.js
CHANGED
|
@@ -319,8 +319,8 @@ export async function startServer(config, configPath, options = {}) {
|
|
|
319
319
|
const terminalHost = options.terminalHost ?? await createTerminalHost(configPath);
|
|
320
320
|
const processes = new ProcessManager(config, storage, configDir, terminalHost);
|
|
321
321
|
const structuredLogger = new SessionLogger(configDir, config.shortcutLogMaxBytes);
|
|
322
|
-
//
|
|
323
|
-
//
|
|
322
|
+
// Production startup requires the daemon-backed host so CLI runs outlive web
|
|
323
|
+
// restarts. In-process hosts are retained only for explicit test injection.
|
|
324
324
|
const structuredExecHost = terminalHost instanceof TerminalDaemonClient ? terminalHost : undefined;
|
|
325
325
|
const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger, undefined, {}, structuredExecHost);
|
|
326
326
|
const sessionRegistry = new SessionRegistry(processes, structuredSessions, storage);
|
package/dist/session-registry.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { cleanupWorktreeSync } from "./git-worktree.js";
|
|
2
2
|
import { inferProviderFromCommand } from "./session-provider.js";
|
|
3
|
-
function slimSnapshot(snapshot) {
|
|
4
|
-
const { output: _output, messages: _messages, ...slim } = snapshot;
|
|
5
|
-
return { ...slim, output: "" };
|
|
6
|
-
}
|
|
7
3
|
/** 读取 `hidden_claude_session_ids`;坏数据 / 空值按空集合处理,不抛错。 */
|
|
8
4
|
export function readHiddenSessionIds(storage) {
|
|
9
5
|
const raw = storage.getConfigValue("hidden_claude_session_ids");
|
|
@@ -82,9 +78,9 @@ export class SessionRegistry {
|
|
|
82
78
|
if (!byId.has(snapshot.id))
|
|
83
79
|
byId.set(snapshot.id, snapshot);
|
|
84
80
|
}
|
|
85
|
-
for (const snapshot of this.storage.
|
|
81
|
+
for (const snapshot of this.storage.loadSessionsSlim()) {
|
|
86
82
|
if (!byId.has(snapshot.id))
|
|
87
|
-
byId.set(snapshot.id,
|
|
83
|
+
byId.set(snapshot.id, snapshot);
|
|
88
84
|
}
|
|
89
85
|
return Array.from(byId.values()).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
90
86
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -229,6 +229,8 @@ export declare class WandStorage {
|
|
|
229
229
|
getSession(id: string): SessionSnapshot | null;
|
|
230
230
|
getLatestSessionByClaudeSessionId(claudeSessionId: string): SessionSnapshot | null;
|
|
231
231
|
loadSessions(): SessionSnapshot[];
|
|
232
|
+
/** List durable metadata without reading or parsing output/messages payloads. */
|
|
233
|
+
loadSessionsSlim(): SessionSnapshot[];
|
|
232
234
|
private mapSessionRow;
|
|
233
235
|
deleteSession(id: string): void;
|
|
234
236
|
}
|
package/dist/storage.js
CHANGED
|
@@ -251,8 +251,8 @@ function mapWorktreeMergeFields(row) {
|
|
|
251
251
|
worktreeMergeInfo: parseWorktreeMergeInfo(row.worktree_merge_info) ?? null,
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
|
-
function sessionSelectFields() {
|
|
255
|
-
return `id, session_source, automation_id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, pty_output_seq, archived, archived_at, claude_session_id, messages, queued_messages, queued_message_skills, structured_state
|
|
254
|
+
function sessionSelectFields(slim = false) {
|
|
255
|
+
return `id, session_source, automation_id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, ${slim ? "'' AS output" : "output"}, pty_output_seq, archived, archived_at, claude_session_id, ${slim ? "NULL AS messages" : "messages"}, queued_messages, queued_message_skills, structured_state
|
|
256
256
|
, resumed_from_session_id, auto_recovered, worktree_enabled, worktree_info, worktree_merge_status, worktree_merge_info, title, description, session_options, workspace_id, workspace_task_id`;
|
|
257
257
|
}
|
|
258
258
|
function mapWorkspaceKind(raw) {
|
|
@@ -1149,11 +1149,11 @@ export class WandStorage {
|
|
|
1149
1149
|
COALESCE(MAX(ended_at), '') AS ended,
|
|
1150
1150
|
COALESCE(MAX(title), '') AS title
|
|
1151
1151
|
FROM command_sessions`).get();
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
FROM workspace_tasks`).
|
|
1152
|
+
// Per-task metadata catches edits even when another task owns the maximum
|
|
1153
|
+
// timestamp/revision. Layout payloads are represented by their own revision.
|
|
1154
|
+
const tasks = this.db.prepare(`SELECT id, workspace_id, name, status, cwd, worktree_json, milestone_id,
|
|
1155
|
+
created_at, last_opened_at, layout_revision
|
|
1156
|
+
FROM workspace_tasks ORDER BY id`).all();
|
|
1157
1157
|
const workspaces = this.db.prepare(`SELECT COUNT(*) AS count, COALESCE(MAX(last_opened_at), '') AS opened FROM workspaces`).get();
|
|
1158
1158
|
// 名称也进指纹:改名(含工作区/项目名与目录自定义名)后客户端才肯重拉任务列表。
|
|
1159
1159
|
const names = this.db.prepare(`SELECT
|
|
@@ -1725,6 +1725,11 @@ export class WandStorage {
|
|
|
1725
1725
|
.all();
|
|
1726
1726
|
return rows.map((row) => this.mapSessionRow(row));
|
|
1727
1727
|
}
|
|
1728
|
+
/** List durable metadata without reading or parsing output/messages payloads. */
|
|
1729
|
+
loadSessionsSlim() {
|
|
1730
|
+
const rows = this.db.prepare(`SELECT ${sessionSelectFields(true)} FROM command_sessions ORDER BY started_at DESC`).all();
|
|
1731
|
+
return rows.map((row) => this.mapSessionRow(row));
|
|
1732
|
+
}
|
|
1728
1733
|
mapSessionRow(row) {
|
|
1729
1734
|
return mapSessionCore(row);
|
|
1730
1735
|
}
|
|
@@ -95,7 +95,11 @@ export function applyPiEvent(state, event) {
|
|
|
95
95
|
// created when an assistant message is saved, so this ID is not resumable yet.
|
|
96
96
|
if (event.type === "session" && typeof event.id === "string")
|
|
97
97
|
state.pendingSessionId = event.id;
|
|
98
|
+
if (event.type === "turn_start" || event.type === "message_start" || event.type === "tool_execution_start") {
|
|
99
|
+
state.phase = "responding";
|
|
100
|
+
}
|
|
98
101
|
if (event.type === "message_update") {
|
|
102
|
+
state.phase = "responding";
|
|
99
103
|
const update = asRecord(event.assistantMessageEvent);
|
|
100
104
|
const delta = typeof update?.delta === "string" ? update.delta : "";
|
|
101
105
|
if (update?.type === "text_delta" && delta) {
|
|
@@ -144,7 +148,14 @@ export function applyPiEvent(state, event) {
|
|
|
144
148
|
if (message?.role === "assistant") {
|
|
145
149
|
if (state.pendingSessionId)
|
|
146
150
|
state.sessionId = state.pendingSessionId;
|
|
147
|
-
|
|
151
|
+
const error = applyPiAssistantMessage(state, message);
|
|
152
|
+
if (event.type === "turn_end") {
|
|
153
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
154
|
+
const hasToolCall = content.some((part) => asRecord(part)?.type === "toolCall");
|
|
155
|
+
if (!hasToolCall && message.stopReason !== "toolUse")
|
|
156
|
+
state.phase = "background";
|
|
157
|
+
}
|
|
158
|
+
return error;
|
|
148
159
|
}
|
|
149
160
|
}
|
|
150
161
|
if (event.type === "agent_end" && Array.isArray(event.messages)) {
|
|
@@ -170,7 +181,13 @@ export class PiRunner {
|
|
|
170
181
|
}
|
|
171
182
|
start(context, observer) {
|
|
172
183
|
const args = buildPiArgs(context.session, context.prompt);
|
|
173
|
-
const state = {
|
|
184
|
+
const state = {
|
|
185
|
+
blocks: [],
|
|
186
|
+
result: "",
|
|
187
|
+
sessionId: context.session.claudeSessionId,
|
|
188
|
+
model: context.session.selectedModel ?? undefined,
|
|
189
|
+
phase: "responding",
|
|
190
|
+
};
|
|
174
191
|
let primaryError = null;
|
|
175
192
|
return startStructuredCli({
|
|
176
193
|
sessionId: context.session.id,
|
|
@@ -5,6 +5,8 @@ export interface StructuredRunnerTurnState {
|
|
|
5
5
|
sessionId: string | null;
|
|
6
6
|
model?: string;
|
|
7
7
|
usage?: ConversationTurn["usage"];
|
|
8
|
+
/** Provider turn phase when the CLI stays alive after its visible reply. */
|
|
9
|
+
phase?: "responding" | "background";
|
|
8
10
|
}
|
|
9
11
|
export interface StructuredRunnerContext {
|
|
10
12
|
session: SessionSnapshot;
|
|
@@ -101,14 +101,19 @@ export declare class StructuredSessionManager {
|
|
|
101
101
|
private readonly piRunner;
|
|
102
102
|
/** Structured CLI runs that were mid-flight when the previous web process died. */
|
|
103
103
|
private pendingRecoveryIds;
|
|
104
|
+
private detachedRecoveryPromise;
|
|
105
|
+
private detachedRecoveryRetryTimer;
|
|
106
|
+
private detachedRecoveryFailureLogged;
|
|
104
107
|
private disposed;
|
|
105
108
|
constructor(storage: WandStorage, config: WandConfig, logger?: SessionLogger | null, sdkQueryFactory?: typeof sdkQuery, runners?: StructuredSessionManagerRunners, execHost?: StructuredExecHost | undefined);
|
|
106
109
|
private archiveExpiredSessions;
|
|
107
110
|
setEventEmitter(emitEvent: (event: ProcessEvent) => void): void;
|
|
108
111
|
/** Stop every runner and flush terminal state before storage is closed. */
|
|
109
112
|
dispose(): void;
|
|
110
|
-
/** Called once after startup wiring;
|
|
113
|
+
/** Called once after startup wiring; transient daemon failures retry automatically. */
|
|
111
114
|
recoverDetachedRuns(): Promise<void>;
|
|
115
|
+
private recoverDetachedRunsOnce;
|
|
116
|
+
private scheduleDetachedRecovery;
|
|
112
117
|
private resumeDetachedRun;
|
|
113
118
|
private finalizeRecoveredRun;
|
|
114
119
|
private trackStreamEmitTimer;
|
|
@@ -145,6 +145,7 @@ const STREAM_EMIT_DEBOUNCE_MS = 16;
|
|
|
145
145
|
// checkpoint keeps recovery useful without rewriting megabytes five times a
|
|
146
146
|
// second on the event loop.
|
|
147
147
|
const STREAM_SAVE_THROTTLE_MS = 1_000;
|
|
148
|
+
const DETACHED_RECOVERY_RETRY_MS = 500;
|
|
148
149
|
const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
|
|
149
150
|
/**
|
|
150
151
|
* 找出最后一条 assistant turn 中尚未配对 tool_result 的 AskUserQuestion tool_use。
|
|
@@ -348,6 +349,9 @@ export class StructuredSessionManager {
|
|
|
348
349
|
piRunner;
|
|
349
350
|
/** Structured CLI runs that were mid-flight when the previous web process died. */
|
|
350
351
|
pendingRecoveryIds = [];
|
|
352
|
+
detachedRecoveryPromise = null;
|
|
353
|
+
detachedRecoveryRetryTimer = null;
|
|
354
|
+
detachedRecoveryFailureLogged = false;
|
|
351
355
|
disposed = false;
|
|
352
356
|
constructor(storage, config, logger = null, sdkQueryFactory = sdkQuery, runners = {}, execHost) {
|
|
353
357
|
this.storage = storage;
|
|
@@ -373,9 +377,11 @@ export class StructuredSessionManager {
|
|
|
373
377
|
const runner = isStructuredRunnerForProvider(provider, storedRunner)
|
|
374
378
|
? storedRunner
|
|
375
379
|
: defaultStructuredRunner(provider, this.config.structuredRunner);
|
|
376
|
-
|
|
380
|
+
const recoverableDetachedRun = snapshot.status === "running"
|
|
381
|
+
&& runner !== "claude-sdk"
|
|
382
|
+
&& this.execHost?.persistent === true;
|
|
383
|
+
if (recoverableDetachedRun)
|
|
377
384
|
this.pendingRecoveryIds.push(snapshot.id);
|
|
378
|
-
}
|
|
379
385
|
const restored = {
|
|
380
386
|
...snapshot,
|
|
381
387
|
sessionKind: "structured",
|
|
@@ -383,7 +389,7 @@ export class StructuredSessionManager {
|
|
|
383
389
|
automationId: snapshot.automationId,
|
|
384
390
|
provider,
|
|
385
391
|
runner,
|
|
386
|
-
status: restoredStatus,
|
|
392
|
+
status: recoverableDetachedRun ? "running" : restoredStatus,
|
|
387
393
|
autoApprovePermissions: snapshot.autoApprovePermissions ?? shouldAutoApproveForMode(snapshot.mode),
|
|
388
394
|
approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
|
|
389
395
|
queuedMessages: snapshot.queuedMessages ?? [],
|
|
@@ -395,16 +401,22 @@ export class StructuredSessionManager {
|
|
|
395
401
|
runner,
|
|
396
402
|
model: snapshot.structuredState?.model ?? snapshot.selectedModel ?? undefined,
|
|
397
403
|
lastError: snapshot.status === "running"
|
|
398
|
-
? "服务重启,上一轮已中断。"
|
|
404
|
+
? recoverableDetachedRun ? null : "服务重启,上一轮已中断。"
|
|
399
405
|
: snapshot.structuredState?.lastError ?? null,
|
|
400
|
-
inFlight:
|
|
401
|
-
activeRequestId:
|
|
406
|
+
inFlight: recoverableDetachedRun,
|
|
407
|
+
activeRequestId: recoverableDetachedRun
|
|
408
|
+
? snapshot.structuredState?.activeRequestId ?? `recover-pending-${snapshot.id}`
|
|
409
|
+
: null,
|
|
402
410
|
},
|
|
403
411
|
selectedModel: snapshot.selectedModel ?? null,
|
|
404
412
|
titleGenerating: false,
|
|
405
413
|
};
|
|
406
414
|
this.sessions.set(restored.id, restored);
|
|
407
|
-
|
|
415
|
+
// Keep the durable running marker until terminald inventory has been
|
|
416
|
+
// reconciled. If web is restarted again during startup, the next owner
|
|
417
|
+
// must still know there is a daemon run to adopt.
|
|
418
|
+
if (!recoverableDetachedRun)
|
|
419
|
+
this.storage.saveSession(restored);
|
|
408
420
|
}
|
|
409
421
|
this.archiveExpiredSessions();
|
|
410
422
|
this.archiveTimer = setInterval(() => {
|
|
@@ -445,6 +457,10 @@ export class StructuredSessionManager {
|
|
|
445
457
|
clearInterval(this.archiveTimer);
|
|
446
458
|
this.archiveTimer = null;
|
|
447
459
|
}
|
|
460
|
+
if (this.detachedRecoveryRetryTimer) {
|
|
461
|
+
clearTimeout(this.detachedRecoveryRetryTimer);
|
|
462
|
+
this.detachedRecoveryRetryTimer = null;
|
|
463
|
+
}
|
|
448
464
|
for (const timer of this.streamEmitTimers)
|
|
449
465
|
clearTimeout(timer);
|
|
450
466
|
this.streamEmitTimers.clear();
|
|
@@ -518,33 +534,90 @@ export class StructuredSessionManager {
|
|
|
518
534
|
// Detached-run recovery: re-attach CLI runs that kept going inside terminald
|
|
519
535
|
// while the previous web process was down.
|
|
520
536
|
// ---------------------------------------------------------------------------
|
|
521
|
-
/** Called once after startup wiring;
|
|
522
|
-
|
|
523
|
-
if (this.disposed || this.execHost?.persistent !== true || this.pendingRecoveryIds.length === 0)
|
|
537
|
+
/** Called once after startup wiring; transient daemon failures retry automatically. */
|
|
538
|
+
recoverDetachedRuns() {
|
|
539
|
+
if (this.disposed || this.execHost?.persistent !== true || this.pendingRecoveryIds.length === 0) {
|
|
540
|
+
return Promise.resolve();
|
|
541
|
+
}
|
|
542
|
+
if (this.detachedRecoveryPromise)
|
|
543
|
+
return this.detachedRecoveryPromise;
|
|
544
|
+
if (this.detachedRecoveryRetryTimer) {
|
|
545
|
+
clearTimeout(this.detachedRecoveryRetryTimer);
|
|
546
|
+
this.detachedRecoveryRetryTimer = null;
|
|
547
|
+
}
|
|
548
|
+
const recovery = this.recoverDetachedRunsOnce();
|
|
549
|
+
this.detachedRecoveryPromise = recovery;
|
|
550
|
+
const clearRecovery = () => {
|
|
551
|
+
if (this.detachedRecoveryPromise === recovery)
|
|
552
|
+
this.detachedRecoveryPromise = null;
|
|
553
|
+
};
|
|
554
|
+
void recovery.then(clearRecovery, clearRecovery);
|
|
555
|
+
return recovery;
|
|
556
|
+
}
|
|
557
|
+
async recoverDetachedRunsOnce() {
|
|
558
|
+
if (this.disposed || !this.execHost)
|
|
524
559
|
return;
|
|
525
|
-
const ids = this.pendingRecoveryIds;
|
|
526
|
-
this.pendingRecoveryIds = [];
|
|
527
560
|
let runs;
|
|
528
561
|
try {
|
|
529
562
|
runs = await this.execHost.listRuns();
|
|
563
|
+
this.detachedRecoveryFailureLogged = false;
|
|
530
564
|
}
|
|
531
565
|
catch (error) {
|
|
532
|
-
|
|
566
|
+
if (!this.detachedRecoveryFailureLogged) {
|
|
567
|
+
this.detachedRecoveryFailureLogged = true;
|
|
568
|
+
process.stderr.write(`[wand] structured run recovery waiting for terminal daemon: ${getErrorMessage(error)}\n`);
|
|
569
|
+
}
|
|
570
|
+
this.scheduleDetachedRecovery();
|
|
533
571
|
return;
|
|
534
572
|
}
|
|
573
|
+
const ids = this.pendingRecoveryIds;
|
|
574
|
+
this.pendingRecoveryIds = [];
|
|
535
575
|
const byRunId = new Map(runs.map((run) => [run.runId, run]));
|
|
536
576
|
for (const sessionId of ids) {
|
|
537
|
-
const state = byRunId.get(structuredRunId(sessionId));
|
|
538
577
|
const session = this.sessions.get(sessionId);
|
|
539
|
-
if (!
|
|
578
|
+
if (!session)
|
|
579
|
+
continue;
|
|
580
|
+
const state = byRunId.get(structuredRunId(sessionId));
|
|
581
|
+
if (!state) {
|
|
582
|
+
// Inventory was read successfully, so this is a genuinely lost run,
|
|
583
|
+
// not a temporary control-plane outage. Commit the interrupted view.
|
|
584
|
+
const interrupted = {
|
|
585
|
+
...session,
|
|
586
|
+
status: "idle",
|
|
587
|
+
exitCode: null,
|
|
588
|
+
endedAt: null,
|
|
589
|
+
structuredState: {
|
|
590
|
+
...session.structuredState,
|
|
591
|
+
inFlight: false,
|
|
592
|
+
activeRequestId: null,
|
|
593
|
+
lastError: "服务重启,上一轮已中断。",
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
this.sessions.set(sessionId, interrupted);
|
|
597
|
+
this.saveAuthoritativeSession(interrupted);
|
|
598
|
+
this.emitStructuredSnapshot(interrupted);
|
|
540
599
|
continue;
|
|
600
|
+
}
|
|
541
601
|
try {
|
|
542
602
|
await this.resumeDetachedRun(session, state);
|
|
543
603
|
}
|
|
544
604
|
catch (error) {
|
|
545
605
|
console.error(`[WAND] structured run recovery failed for ${sessionId}:`, error);
|
|
606
|
+
if (!this.disposed && !this.pendingRecoveryIds.includes(sessionId)) {
|
|
607
|
+
this.pendingRecoveryIds.push(sessionId);
|
|
608
|
+
}
|
|
546
609
|
}
|
|
547
610
|
}
|
|
611
|
+
this.scheduleDetachedRecovery();
|
|
612
|
+
}
|
|
613
|
+
scheduleDetachedRecovery() {
|
|
614
|
+
if (this.disposed || this.pendingRecoveryIds.length === 0 || this.detachedRecoveryRetryTimer)
|
|
615
|
+
return;
|
|
616
|
+
this.detachedRecoveryRetryTimer = setTimeout(() => {
|
|
617
|
+
this.detachedRecoveryRetryTimer = null;
|
|
618
|
+
void this.recoverDetachedRuns();
|
|
619
|
+
}, DETACHED_RECOVERY_RETRY_MS);
|
|
620
|
+
this.detachedRecoveryRetryTimer.unref?.();
|
|
548
621
|
}
|
|
549
622
|
async resumeDetachedRun(snapshot, initialState) {
|
|
550
623
|
const sessionId = snapshot.id;
|
|
@@ -589,6 +662,7 @@ export class StructuredSessionManager {
|
|
|
589
662
|
structuredState: {
|
|
590
663
|
...current.structuredState,
|
|
591
664
|
model: turnState.model ?? current.structuredState?.model,
|
|
665
|
+
phase: turnState.phase ?? current.structuredState?.phase,
|
|
592
666
|
},
|
|
593
667
|
};
|
|
594
668
|
this.sessions.set(sessionId, patched);
|
|
@@ -617,24 +691,12 @@ export class StructuredSessionManager {
|
|
|
617
691
|
}
|
|
618
692
|
};
|
|
619
693
|
let runningHandle = null;
|
|
620
|
-
let
|
|
621
|
-
|
|
694
|
+
let lastStdoutSeq = initialState.stdoutSeq;
|
|
695
|
+
let lastStderrSeq = initialState.stderrSeq;
|
|
622
696
|
const feedLine = (line) => {
|
|
623
697
|
onApplied(processor.feed(line));
|
|
624
698
|
};
|
|
625
699
|
const feedDelta = (text) => {
|
|
626
|
-
// Skip the portion already covered by the attach snapshot to avoid
|
|
627
|
-
// double-feeding overlapping events buffered during adoption.
|
|
628
|
-
if (fedChars < snapshotChars) {
|
|
629
|
-
const remaining = snapshotChars - fedChars;
|
|
630
|
-
if (text.length <= remaining) {
|
|
631
|
-
fedChars += text.length;
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
text = text.slice(remaining);
|
|
635
|
-
fedChars = snapshotChars;
|
|
636
|
-
}
|
|
637
|
-
fedChars += text.length;
|
|
638
700
|
carry += text;
|
|
639
701
|
const lines = carry.split("\n");
|
|
640
702
|
carry = lines.pop() ?? "";
|
|
@@ -669,7 +731,6 @@ export class StructuredSessionManager {
|
|
|
669
731
|
carry = lines.pop() ?? "";
|
|
670
732
|
for (const line of lines)
|
|
671
733
|
feedLine(line);
|
|
672
|
-
fedChars = initialState.stdoutLog.length - carry.length;
|
|
673
734
|
}
|
|
674
735
|
runningHandle = await this.execHost.adoptRun(structuredRunId(sessionId));
|
|
675
736
|
if (!runningHandle) {
|
|
@@ -685,10 +746,20 @@ export class StructuredSessionManager {
|
|
|
685
746
|
return;
|
|
686
747
|
}
|
|
687
748
|
runningHandle.onStream((event) => {
|
|
688
|
-
|
|
749
|
+
// Events may be buffered while structuredAttach is in flight. Sequence
|
|
750
|
+
// watermarks distinguish overlap with the snapshot from genuinely new
|
|
751
|
+
// output; character-count skipping corrupts a new short event.
|
|
752
|
+
if (event.stream === "stdout") {
|
|
753
|
+
if (event.seq <= lastStdoutSeq)
|
|
754
|
+
return;
|
|
755
|
+
lastStdoutSeq = event.seq;
|
|
689
756
|
feedDelta(event.data);
|
|
690
|
-
|
|
691
|
-
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
if (event.seq <= lastStderrSeq)
|
|
760
|
+
return;
|
|
761
|
+
lastStderrSeq = event.seq;
|
|
762
|
+
processor.stderr += event.data;
|
|
692
763
|
});
|
|
693
764
|
runningHandle.onExit((event) => {
|
|
694
765
|
if (carry.trim())
|
|
@@ -750,6 +821,7 @@ export class StructuredSessionManager {
|
|
|
750
821
|
inFlight: false,
|
|
751
822
|
activeRequestId: null,
|
|
752
823
|
lastError: null,
|
|
824
|
+
phase: undefined,
|
|
753
825
|
},
|
|
754
826
|
};
|
|
755
827
|
this.sessions.set(sessionId, finished);
|
|
@@ -2317,6 +2389,7 @@ export class StructuredSessionManager {
|
|
|
2317
2389
|
structuredState: {
|
|
2318
2390
|
...current.structuredState,
|
|
2319
2391
|
model: turnState.model ?? current.structuredState?.model,
|
|
2392
|
+
phase: turnState.phase ?? current.structuredState?.phase,
|
|
2320
2393
|
},
|
|
2321
2394
|
};
|
|
2322
2395
|
this.sessions.set(sessionId, patched);
|
|
@@ -2441,6 +2514,7 @@ export class StructuredSessionManager {
|
|
|
2441
2514
|
inFlight: false,
|
|
2442
2515
|
activeRequestId: null,
|
|
2443
2516
|
lastError: null,
|
|
2517
|
+
phase: undefined,
|
|
2444
2518
|
},
|
|
2445
2519
|
};
|
|
2446
2520
|
this.sessions.set(sessionId, finished);
|
|
@@ -3072,6 +3146,7 @@ export class StructuredSessionManager {
|
|
|
3072
3146
|
inFlight: false,
|
|
3073
3147
|
activeRequestId: null,
|
|
3074
3148
|
lastError: errorText,
|
|
3149
|
+
phase: undefined,
|
|
3075
3150
|
},
|
|
3076
3151
|
};
|
|
3077
3152
|
}
|
|
@@ -6,7 +6,7 @@ export declare class TerminalDaemonClient implements TerminalHost, StructuredExe
|
|
|
6
6
|
private readonly token;
|
|
7
7
|
readonly persistent = true;
|
|
8
8
|
private socket;
|
|
9
|
-
private
|
|
9
|
+
private frameParts;
|
|
10
10
|
/** Decodes daemon NDJSON frames without splitting multi-byte chars. */
|
|
11
11
|
private decoder;
|
|
12
12
|
private nextRequestId;
|
|
@@ -223,7 +223,7 @@ export class TerminalDaemonClient {
|
|
|
223
223
|
token;
|
|
224
224
|
persistent = true;
|
|
225
225
|
socket = null;
|
|
226
|
-
|
|
226
|
+
frameParts = [];
|
|
227
227
|
/** Decodes daemon NDJSON frames without splitting multi-byte chars. */
|
|
228
228
|
decoder = new StringDecoder("utf8");
|
|
229
229
|
nextRequestId = 1;
|
|
@@ -263,6 +263,7 @@ export class TerminalDaemonClient {
|
|
|
263
263
|
// Each socket decodes from a clean slate; a half-written character from a
|
|
264
264
|
// previous connection must not leak into the next frame.
|
|
265
265
|
this.decoder = new StringDecoder("utf8");
|
|
266
|
+
this.frameParts = [];
|
|
266
267
|
socket.on("data", (data) => this.consume(this.decoder.write(data)));
|
|
267
268
|
socket.on("close", () => this.handleDisconnect());
|
|
268
269
|
socket.on("error", (error) => {
|
|
@@ -563,13 +564,20 @@ export class TerminalDaemonClient {
|
|
|
563
564
|
};
|
|
564
565
|
}
|
|
565
566
|
consume(chunk) {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
567
|
+
// Replay inventories can span tens of MB. Scan only the incoming chunk;
|
|
568
|
+
// concatenating and rescanning the partial frame on every socket read is
|
|
569
|
+
// quadratic and can starve unrelated create/attach requests until timeout.
|
|
570
|
+
let offset = 0;
|
|
571
|
+
while (offset < chunk.length) {
|
|
572
|
+
const newline = chunk.indexOf("\n", offset);
|
|
573
|
+
if (newline < 0) {
|
|
574
|
+
this.frameParts.push(chunk.slice(offset));
|
|
570
575
|
return;
|
|
571
|
-
|
|
572
|
-
this.
|
|
576
|
+
}
|
|
577
|
+
this.frameParts.push(chunk.slice(offset, newline));
|
|
578
|
+
const line = this.frameParts.join("");
|
|
579
|
+
this.frameParts = [];
|
|
580
|
+
offset = newline + 1;
|
|
573
581
|
if (!line)
|
|
574
582
|
continue;
|
|
575
583
|
let message;
|
|
@@ -694,8 +702,7 @@ export async function createTerminalHost(configPath) {
|
|
|
694
702
|
const starting = await waitForTerminalDaemon(paths.socketPath, paths.tokenPath, 5_000);
|
|
695
703
|
if (starting)
|
|
696
704
|
return starting;
|
|
697
|
-
|
|
698
|
-
return new InProcessTerminalHost();
|
|
705
|
+
throw new Error("Existing terminal daemon rejected adoption; refusing non-persistent fallback because web restarts must not stop running tasks.");
|
|
699
706
|
}
|
|
700
707
|
}
|
|
701
708
|
// Service managers can start terminald and the web process at nearly the
|
|
@@ -705,8 +712,7 @@ export async function createTerminalHost(configPath) {
|
|
|
705
712
|
const adopted = await waitForTerminalDaemon(paths.socketPath, paths.tokenPath, 5_000);
|
|
706
713
|
if (adopted)
|
|
707
714
|
return adopted;
|
|
708
|
-
|
|
709
|
-
return new InProcessTerminalHost();
|
|
715
|
+
throw new Error("Terminal daemon process is alive but its socket is unavailable; refusing non-persistent fallback because web restarts must not stop running tasks.");
|
|
710
716
|
}
|
|
711
717
|
try {
|
|
712
718
|
startDetachedDaemon(configPath);
|
|
@@ -717,8 +723,7 @@ export async function createTerminalHost(configPath) {
|
|
|
717
723
|
catch (error) {
|
|
718
724
|
process.stderr.write(`[wand] Failed to start terminal daemon: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
719
725
|
}
|
|
720
|
-
|
|
721
|
-
return new InProcessTerminalHost();
|
|
726
|
+
throw new Error("Terminal daemon unavailable; refusing non-persistent fallback because web restarts must not stop running tasks.");
|
|
722
727
|
}
|
|
723
728
|
async function waitForTerminalDaemon(socketPath, tokenPath, timeoutMs) {
|
|
724
729
|
const deadline = Date.now() + timeoutMs;
|
package/dist/types.d.ts
CHANGED
|
@@ -519,6 +519,8 @@ export interface StructuredSessionState {
|
|
|
519
519
|
lastError: string | null;
|
|
520
520
|
inFlight: boolean;
|
|
521
521
|
activeRequestId: string | null;
|
|
522
|
+
/** Pi 已结束前台回复、但 CLI 仍在等待异步子任务收尾。 */
|
|
523
|
+
phase?: "responding" | "background";
|
|
522
524
|
}
|
|
523
525
|
export interface SessionSnapshot {
|
|
524
526
|
id: string;
|