@zhuxixi/pi-agent-board 0.6.2 → 0.8.0
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/CHANGELOG.md +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Detached View State Coordinator (issue #91, spec D3) — the single logical
|
|
4
|
+
* writer of state.json/status.json for one board root.
|
|
5
|
+
*
|
|
6
|
+
* Usage: node state-coordinator.mjs <root>
|
|
7
|
+
*
|
|
8
|
+
* Design contract:
|
|
9
|
+
* - Exactly one instance per root: a token-fenced lease (acquireOwnedViewLock on
|
|
10
|
+
* the pseudo-view "_coordinator") is grabbed before anything else; a second
|
|
11
|
+
* instance exits 0 immediately ("busy" is an expected, idempotent outcome).
|
|
12
|
+
* - Every semantic mutation arrives as a `state_command` over the JSONL control
|
|
13
|
+
* socket and is decided by the pure layer (state-commands.mjs). This process
|
|
14
|
+
* owns ALL side effects: durable journal append (fsync BEFORE materialize, so
|
|
15
|
+
* boot replay can repair the crash window), materialization under a per-view
|
|
16
|
+
* file lock, checkpoint + journal GC, and socket lifecycle.
|
|
17
|
+
* - Transient kinds (TRANSIENT_KINDS, run_progress) bypass the durable
|
|
18
|
+
* machinery: validate → decide → materialize → reply, with no journal
|
|
19
|
+
* append, no dedupe, and no checkpoint trigger — a periodic snapshot whose
|
|
20
|
+
* next beat supersedes it. They still bump materializedRevision.
|
|
21
|
+
* - Idempotency: a processed commandId returns its original result forever —
|
|
22
|
+
* from the in-memory ring first (bounded, covers the post-GC window), then
|
|
23
|
+
* from the journal (findProcessedCommand). Duplicates never re-append.
|
|
24
|
+
* - Revisions: one global monotonic materializedRevision counter, initialized at
|
|
25
|
+
* boot to max(checkpoint, max journal revision, max revision across views'
|
|
26
|
+
* state.json), incremented once per applied command, and stamped identically
|
|
27
|
+
* on state.json and the command's run status.json (spec 根治条件 5 scope).
|
|
28
|
+
* - Boot replay re-materializes every applied journal record from its stored
|
|
29
|
+
* patches; per-file guards stamp only files strictly behind the recorded
|
|
30
|
+
* revision, so a half-materialized pair (state.json written, status.json not)
|
|
31
|
+
* repairs just the lagging half without moving the other one backwards. The
|
|
32
|
+
* decision-time status binding (statusRunId) is recorded in the journal, so
|
|
33
|
+
* replay is deterministic — no re-decision, no re-binding.
|
|
34
|
+
* - Legacy adoption: a view without materializedRevision is stamped as part of
|
|
35
|
+
* its first applied command's single materialization write (no extra write).
|
|
36
|
+
* - AGENT_BOARD_COORDINATOR=off makes the process exit 0 immediately (tests and
|
|
37
|
+
* the client's degradation path).
|
|
38
|
+
*/
|
|
39
|
+
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
40
|
+
import { randomBytes } from "node:crypto";
|
|
41
|
+
import { createServer } from "node:net";
|
|
42
|
+
import { readJson } from "../src/core/atomic.mjs";
|
|
43
|
+
import {
|
|
44
|
+
appendCommand,
|
|
45
|
+
findProcessedCommand,
|
|
46
|
+
gcJournal,
|
|
47
|
+
readCheckpoint,
|
|
48
|
+
readJournal,
|
|
49
|
+
repairJournalTail,
|
|
50
|
+
writeCheckpoint,
|
|
51
|
+
} from "../src/core/coordinator-journal.mjs";
|
|
52
|
+
import { ownsEndpoint } from "../src/core/host-coordination.mjs";
|
|
53
|
+
import { acquireOwnedViewLock, withViewLockSync } from "../src/core/locks.mjs";
|
|
54
|
+
import * as P from "../src/core/paths.mjs";
|
|
55
|
+
import { readState, readStatus, writeState, writeStatus } from "../src/core/store.mjs";
|
|
56
|
+
import { TRANSIENT_KINDS, decideStateTransition, validateCommand } from "../src/core/state-commands.mjs";
|
|
57
|
+
import { COORDINATOR_PROTOCOL_VERSION } from "../src/core/coordinator-protocol.mjs";
|
|
58
|
+
|
|
59
|
+
/** In-memory processed-command ring size (FIFO). Beyond the journal, this covers
|
|
60
|
+
* idempotency when the journal prefix has already been GC'd away. */
|
|
61
|
+
const PROCESSED_RING_MAX = 1000;
|
|
62
|
+
/** Journal growth (bytes) that triggers checkpoint + GC. Keeps boot replay bounded. */
|
|
63
|
+
const CHECKPOINT_THRESHOLD_BYTES = 262_144;
|
|
64
|
+
/** Lease heartbeat — matches pty-runner's host-start lease cadence. */
|
|
65
|
+
const HEARTBEAT_MS = 1000;
|
|
66
|
+
/** Lifecycle kinds whose legacy write sites stamped state.lastActivityAt on
|
|
67
|
+
* every persist (service markQueued/archive/adopt, job-runner plan-ready,
|
|
68
|
+
* reconcile terminal sites). The decision layer stays clock-free, so the
|
|
69
|
+
* shell adds the wall-clock stamp — into the patch BEFORE the journal
|
|
70
|
+
* append, so boot replay re-applies the exact same fields deterministically
|
|
71
|
+
* (there is no clock at replay time either). (Task 1 review F5.) */
|
|
72
|
+
const LAST_ACTIVITY_STAMP_KINDS = new Set(["mark_queued", "archive_view", "adopt_session", "reconcile_finalize", "plan_ready", "mark_completed", "host_run_failed"]);
|
|
73
|
+
/** Kinds whose status patch may CREATE the run's status file: they carry the
|
|
74
|
+
* full status content for a run that has no materialized status yet. Every
|
|
75
|
+
* other kind keeps PR #1's "patch presence ≠ file requirement" semantics
|
|
76
|
+
* (e.g. mark_completed's `status: {autoState: null}` on a legacy row without
|
|
77
|
+
* a status file must not fabricate one).
|
|
78
|
+
*
|
|
79
|
+
* `run_progress` joins them (issue #91 hard-down residual closure): its beat
|
|
80
|
+
* patch IS the runner's full in-memory status, and when the coordinator was
|
|
81
|
+
* down through the runner's boot window (mark_queued applied, run_started
|
|
82
|
+
* lost) the first post-recovery beat is the only surviving source that can
|
|
83
|
+
* recreate the file — without it beats and finalize reject stale_run forever
|
|
84
|
+
* and the row can never converge. The decision layer gates this on a
|
|
85
|
+
* qualified live-run beat (full-shaped patch, matching string runId). */
|
|
86
|
+
const STATUS_BOOTSTRAP_KINDS = new Set(["run_started", "followup_started", "run_progress"]);
|
|
87
|
+
|
|
88
|
+
const root = process.argv[2];
|
|
89
|
+
if (!root) {
|
|
90
|
+
process.stderr.write("state-coordinator: missing root argument\n");
|
|
91
|
+
process.exit(2);
|
|
92
|
+
}
|
|
93
|
+
if (process.env.AGENT_BOARD_COORDINATOR === "off") process.exit(0);
|
|
94
|
+
|
|
95
|
+
main().catch((err) => {
|
|
96
|
+
process.stderr.write(`state-coordinator: fatal ${err?.stack || err}\n`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
async function main() {
|
|
101
|
+
const instanceId = randomBytes(8).toString("hex");
|
|
102
|
+
const startedAt = Date.now();
|
|
103
|
+
|
|
104
|
+
// 1. Lease first: a second coordinator is an expected, idempotent no-op.
|
|
105
|
+
// Identity carries a POSIX startToken (pty-runner pattern) so a SIGKILLed
|
|
106
|
+
// coordinator's lease can be reclaimed by the next instance after pid reuse.
|
|
107
|
+
/** @type {import("../src/core/locks.mjs").Lease} */
|
|
108
|
+
let lease;
|
|
109
|
+
try {
|
|
110
|
+
lease = acquireOwnedViewLock(root, "_coordinator", "state-coordinator", {
|
|
111
|
+
waitMs: 500,
|
|
112
|
+
identity: { pid: process.pid, startToken: captureStartToken(process.pid) },
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
process.stderr.write(`state-coordinator: lease unavailable (${err?.code ?? err?.message ?? "unknown"}); another instance may own it; exiting\n`);
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
const startTouchTimer = setInterval(() => {
|
|
119
|
+
try { lease?.touch(); } catch { /* best effort */ }
|
|
120
|
+
}, HEARTBEAT_MS);
|
|
121
|
+
startTouchTimer.unref?.();
|
|
122
|
+
|
|
123
|
+
// 2. Boot state: journal summary, processed-command ring, replay, counter init.
|
|
124
|
+
// Repair a crash-torn tail FIRST: without it the first post-restart append
|
|
125
|
+
// concatenates onto the torn line and that fsynced record becomes invisible
|
|
126
|
+
// to every readJournal / replay / revision scan below.
|
|
127
|
+
repairJournalTail(root);
|
|
128
|
+
const checkpoint = readCheckpoint(root) ?? { materializedRevision: 0, journalBytes: 0 };
|
|
129
|
+
const journal = readJournal(root);
|
|
130
|
+
/** @type {Map<string, { result: { status: string, reason: string|null }, materializedRevision: number }>} */
|
|
131
|
+
const processedCommands = new Map();
|
|
132
|
+
for (const record of journal) {
|
|
133
|
+
const commandId = record?.command?.commandId;
|
|
134
|
+
if (commandId && !processedCommands.has(commandId)) {
|
|
135
|
+
processedCommands.set(commandId, {
|
|
136
|
+
result: record.result ?? { status: "rejected", reason: "unknown" },
|
|
137
|
+
materializedRevision: record.materializedRevision ?? 0,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Replay in journal order: re-materialize every applied record from its stored
|
|
143
|
+
// patches; materialize's per-file guards write only files strictly behind the
|
|
144
|
+
// recorded revision, so this repairs just the half that missed its write in a
|
|
145
|
+
// crash window and never moves the other half backwards.
|
|
146
|
+
for (const record of journal) {
|
|
147
|
+
if (record?.result?.status !== "applied") continue;
|
|
148
|
+
const viewId = record?.command?.viewId;
|
|
149
|
+
if (!viewId) continue;
|
|
150
|
+
materialize(viewId, record.command ?? {}, record.mutate ?? {}, record.materializedRevision ?? 0, record.statusRunId ?? null);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Global revision counter: never reissue a revision that exists anywhere.
|
|
154
|
+
let revisionCounter = Math.max(checkpoint.materializedRevision ?? 0, 0);
|
|
155
|
+
for (const record of journal) revisionCounter = Math.max(revisionCounter, record.materializedRevision ?? 0);
|
|
156
|
+
if (existsSync(P.viewsDir(root))) {
|
|
157
|
+
for (const entry of readdirSync(P.viewsDir(root))) {
|
|
158
|
+
const state = readJson(P.statePath(root, entry), null);
|
|
159
|
+
revisionCounter = Math.max(revisionCounter, state?.materializedRevision ?? 0);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
let lastCheckpointBytes = 0;
|
|
163
|
+
|
|
164
|
+
// 3. Control socket (JSONL, pty-runner's line-buffered server shape).
|
|
165
|
+
const socketPath = P.coordinatorEndpointPathFor(process.platform, root);
|
|
166
|
+
if (process.platform !== "win32" && existsSync(socketPath)) {
|
|
167
|
+
// Lease is ours: any leftover socket file belongs to a dead coordinator.
|
|
168
|
+
try { unlinkSync(socketPath); } catch { /* best effort */ }
|
|
169
|
+
}
|
|
170
|
+
/** {dev,ino} recorded at bind time; cleanup unlinks only this exact inode. */
|
|
171
|
+
let boundSocketIdentity = null;
|
|
172
|
+
|
|
173
|
+
const server = createServer((socket) => {
|
|
174
|
+
let buffer = "";
|
|
175
|
+
socket.on("data", (chunk) => {
|
|
176
|
+
buffer += chunk.toString("utf8");
|
|
177
|
+
const lines = buffer.split("\n");
|
|
178
|
+
buffer = lines.pop() ?? "";
|
|
179
|
+
for (const line of lines) handleClientLine(line, socket);
|
|
180
|
+
});
|
|
181
|
+
socket.on("error", () => {}); // client vanished mid-request; nothing to answer
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
server.on("error", (err) => {
|
|
185
|
+
process.stderr.write(`state-coordinator: server error ${err instanceof Error ? err.message : String(err)}\n`);
|
|
186
|
+
shutdown(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
server.listen(socketPath, () => {
|
|
190
|
+
if (process.platform !== "win32") {
|
|
191
|
+
try {
|
|
192
|
+
const st = statSync(socketPath);
|
|
193
|
+
boundSocketIdentity = { dev: st.dev, ino: st.ino };
|
|
194
|
+
} catch { /* without the identity, cleanup degrades to no-op */ }
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
let shuttingDown = false;
|
|
199
|
+
process.on("SIGTERM", () => shutdown(0));
|
|
200
|
+
process.on("SIGINT", () => shutdown(0));
|
|
201
|
+
|
|
202
|
+
function shutdown(code) {
|
|
203
|
+
if (shuttingDown) return;
|
|
204
|
+
shuttingDown = true;
|
|
205
|
+
try { clearInterval(startTouchTimer); } catch { /* best effort */ }
|
|
206
|
+
try { server.close(); } catch { /* already closed */ }
|
|
207
|
+
if (process.platform !== "win32" && boundSocketIdentity) {
|
|
208
|
+
try {
|
|
209
|
+
const st = statSync(socketPath);
|
|
210
|
+
if (ownsEndpoint(boundSocketIdentity, { dev: st.dev, ino: st.ino })) {
|
|
211
|
+
try { unlinkSync(socketPath); } catch { /* best effort */ }
|
|
212
|
+
}
|
|
213
|
+
} catch { /* path gone — nothing to clean */ }
|
|
214
|
+
}
|
|
215
|
+
try { lease.release(); } catch { /* best effort */ }
|
|
216
|
+
process.exit(code);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function send(socket, msg) {
|
|
220
|
+
socket.write(JSON.stringify(msg) + "\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function handleClientLine(line, socket) {
|
|
224
|
+
if (!line.trim()) return;
|
|
225
|
+
let msg;
|
|
226
|
+
try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
|
|
227
|
+
switch (msg?.type) {
|
|
228
|
+
case "ping":
|
|
229
|
+
send(socket, { type: "pong", instanceId, startedAt, protocolVersion: COORDINATOR_PROTOCOL_VERSION });
|
|
230
|
+
break;
|
|
231
|
+
case "state_command":
|
|
232
|
+
send(socket, { type: "state_command_result", ...processStateCommand(msg) });
|
|
233
|
+
break;
|
|
234
|
+
default:
|
|
235
|
+
send(socket, { type: "error", message: "unknown type" });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The command loop: validate → dedupe → decide → journal (fsync) → materialize
|
|
241
|
+
* → remember → reply. Rejections are journaled too, so their exact reason is
|
|
242
|
+
* replayable; duplicates short-circuit before any append.
|
|
243
|
+
* @param {object} msg
|
|
244
|
+
* @returns {{ commandId: string|null, status: string, reason: string|null, materializedRevision: number }}
|
|
245
|
+
*/
|
|
246
|
+
function processStateCommand(msg) {
|
|
247
|
+
const checked = validateCommand(msg);
|
|
248
|
+
if (!checked.ok) {
|
|
249
|
+
return {
|
|
250
|
+
commandId: typeof msg?.commandId === "string" ? msg.commandId : null,
|
|
251
|
+
status: "rejected",
|
|
252
|
+
reason: checked.error,
|
|
253
|
+
materializedRevision: 0,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const command = checked.command;
|
|
257
|
+
const transient = TRANSIENT_KINDS.includes(command.kind);
|
|
258
|
+
|
|
259
|
+
if (!transient) {
|
|
260
|
+
const known = lookupProcessed(command.commandId);
|
|
261
|
+
if (known) {
|
|
262
|
+
return { commandId: command.commandId, status: known.result.status, reason: known.result.reason, materializedRevision: known.materializedRevision };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const state = readState(root, command.viewId);
|
|
267
|
+
// Status consistency only binds the view's current run (spec 根治条件 5):
|
|
268
|
+
// no currentRunId → status.json plays no role in this command.
|
|
269
|
+
// Exception (Task 1 review F1): followup_started re-points the row at a
|
|
270
|
+
// NEW run carried in payload.newRunId (command.runId is deliberately null
|
|
271
|
+
// so the stale-run guard cannot fire against the finished parent run).
|
|
272
|
+
// Resolving the status from the parent here would overwrite the parent's
|
|
273
|
+
// status.json with the new run's bootstrap patch; resolving from newRunId
|
|
274
|
+
// finds no file and cleanly bootstraps the new run's status instead.
|
|
275
|
+
const statusRunId = command.kind === "followup_started"
|
|
276
|
+
? (typeof command.payload?.newRunId === "string" ? command.payload.newRunId : null)
|
|
277
|
+
: (command.runId ?? state?.currentRunId ?? null);
|
|
278
|
+
const status = statusRunId ? readStatus(root, command.viewId, statusRunId) : null;
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
const decision = stampLegacyTimestamps(command, decideStateTransition(command, state, status, now), now);
|
|
281
|
+
const currentRevision = state?.materializedRevision ?? 0;
|
|
282
|
+
|
|
283
|
+
if (decision.action === "reject") {
|
|
284
|
+
if (transient) {
|
|
285
|
+
// Transient rejections are not replayable either — nothing was
|
|
286
|
+
// mutated, and journaling them would grow the journal for noise.
|
|
287
|
+
return { commandId: command.commandId ?? null, status: "rejected", reason: decision.reason, materializedRevision: currentRevision };
|
|
288
|
+
}
|
|
289
|
+
const result = { status: "rejected", reason: decision.reason };
|
|
290
|
+
const record = { command, result, materializedRevision: currentRevision, at: now };
|
|
291
|
+
const journalBytes = appendCommand(root, record);
|
|
292
|
+
rememberProcessed(command.commandId, result, currentRevision);
|
|
293
|
+
maybeCheckpoint(journalBytes);
|
|
294
|
+
return { commandId: command.commandId, status: "rejected", reason: decision.reason, materializedRevision: currentRevision };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const newRevision = revisionCounter + 1;
|
|
298
|
+
const result = { status: "applied", reason: decision.reason };
|
|
299
|
+
if (transient) {
|
|
300
|
+
// No journal, no processed ring, no checkpoint: a periodic snapshot —
|
|
301
|
+
// the next beat supersedes it.
|
|
302
|
+
materialize(command.viewId, command, decision.mutate, newRevision, statusRunId);
|
|
303
|
+
revisionCounter = newRevision;
|
|
304
|
+
return { commandId: command.commandId ?? null, status: "applied", reason: decision.reason, materializedRevision: newRevision };
|
|
305
|
+
}
|
|
306
|
+
// Journal first (fsync inside), materialize second: boot replay repairs
|
|
307
|
+
// the window between the two using the stored patches.
|
|
308
|
+
const record = { command, result, materializedRevision: newRevision, at: now, mutate: decision.mutate, statusRunId };
|
|
309
|
+
const journalBytes = appendCommand(root, record);
|
|
310
|
+
materialize(command.viewId, command, decision.mutate, newRevision, statusRunId);
|
|
311
|
+
revisionCounter = newRevision;
|
|
312
|
+
rememberProcessed(command.commandId, result, newRevision);
|
|
313
|
+
maybeCheckpoint(journalBytes);
|
|
314
|
+
return { commandId: command.commandId, status: "applied", reason: decision.reason, materializedRevision: newRevision };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Merge patches onto state.json (and the run's status.json when it exists)
|
|
319
|
+
* under the view's materialize lock, stamping the shared revision. Per-file
|
|
320
|
+
* guards stamp only files strictly behind the record revision: the live path
|
|
321
|
+
* always qualifies (the global counter is monotonic), while replay repairs
|
|
322
|
+
* just the half that missed its write in a crash window and never moves the
|
|
323
|
+
* other half backwards.
|
|
324
|
+
*
|
|
325
|
+
* Pairing invariant (issue #91 final review): EVERY applied revision pairs
|
|
326
|
+
* BOTH files. A state-only patch (e.g. patch_fields markVisited) restamps
|
|
327
|
+
* the existing status file's revision with no content change, and a
|
|
328
|
+
* status-only patch restamps the state file the same way — a healthy
|
|
329
|
+
* coordinator can never produce the asymmetric pair the reader-side
|
|
330
|
+
* revision check (reconcile) treats as a crash signature. Journaled
|
|
331
|
+
* state-only records heal on replay through this shared path. Residual:
|
|
332
|
+
* a torn TRANSIENT beat (crash between the two writes, never journaled)
|
|
333
|
+
* stays half-paired — ms-window × adjacent-crash, documented in the spec.
|
|
334
|
+
*
|
|
335
|
+
* A status patch without a status file is skipped — patch presence ≠ file
|
|
336
|
+
* requirement (mark_completed always emits `status: {autoState: null}`).
|
|
337
|
+
* The status run binding is the decision-time one (`statusRunId`, recorded
|
|
338
|
+
* in the journal) so replay cannot re-bind a runId-less patch to whatever
|
|
339
|
+
* run is current on disk at replay time; `state.currentRunId` remains only
|
|
340
|
+
* as a legacy fallback for records written before statusRunId existed.
|
|
341
|
+
*/
|
|
342
|
+
function materialize(viewId, command, mutate, revision, statusRunId) {
|
|
343
|
+
withViewLockSync(root, viewId, "state-materialize", () => {
|
|
344
|
+
const state = readState(root, viewId);
|
|
345
|
+
if (state && (state.materializedRevision ?? 0) < revision) {
|
|
346
|
+
writeState(root, mutate?.state
|
|
347
|
+
? { ...state, ...mutate.state, materializedRevision: revision }
|
|
348
|
+
: { ...state, materializedRevision: revision });
|
|
349
|
+
}
|
|
350
|
+
const runId = command?.runId ?? statusRunId ?? state?.currentRunId ?? null;
|
|
351
|
+
if (!runId) return;
|
|
352
|
+
const status = readStatus(root, viewId, runId);
|
|
353
|
+
if (mutate?.status) {
|
|
354
|
+
if (!status && STATUS_BOOTSTRAP_KINDS.has(command?.kind)) {
|
|
355
|
+
// run_started / followup_started / (bootstrapping) run_progress
|
|
356
|
+
// carry the full status content for a run that has no status
|
|
357
|
+
// file yet — create it. Identity fields come from the command
|
|
358
|
+
// context; the patch carries everything meaningful.
|
|
359
|
+
writeStatus(root, { version: 1, runId, viewId, ...mutate.status, materializedRevision: revision });
|
|
360
|
+
} else if (status && (status.materializedRevision ?? 0) < revision) {
|
|
361
|
+
writeStatus(root, { ...status, ...mutate.status, materializedRevision: revision });
|
|
362
|
+
}
|
|
363
|
+
} else if (status && (status.materializedRevision ?? 0) < revision) {
|
|
364
|
+
// State-only patch: restamp the existing status file's revision with
|
|
365
|
+
// no content change (pairing invariant above).
|
|
366
|
+
writeStatus(root, { ...status, materializedRevision: revision });
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Add the legacy wall-clock stamp to a decided patch (F5): see
|
|
372
|
+
* LAST_ACTIVITY_STAMP_KINDS. Applied before the journal append so the
|
|
373
|
+
* stored patches are exactly what replay re-materializes. */
|
|
374
|
+
function stampLegacyTimestamps(command, decision, now) {
|
|
375
|
+
if (decision.action !== "apply" || !LAST_ACTIVITY_STAMP_KINDS.has(command.kind)) return decision;
|
|
376
|
+
return { ...decision, mutate: { ...decision.mutate, state: { ...(decision.mutate.state ?? {}), lastActivityAt: now } } };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Memory ring first (covers the post-GC window), journal second. */
|
|
380
|
+
function lookupProcessed(commandId) {
|
|
381
|
+
const inMemory = processedCommands.get(commandId);
|
|
382
|
+
if (inMemory) return inMemory;
|
|
383
|
+
const result = findProcessedCommand(root, commandId);
|
|
384
|
+
if (!result) return null;
|
|
385
|
+
const entry = { result, materializedRevision: revisionOf(commandId) };
|
|
386
|
+
rememberProcessed(commandId, result, entry.materializedRevision);
|
|
387
|
+
return entry;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Revision recorded for a known commandId (journal scan; 0 if unrecorded). */
|
|
391
|
+
function revisionOf(commandId) {
|
|
392
|
+
for (const record of readJournal(root)) {
|
|
393
|
+
if (record?.command?.commandId === commandId) return record.materializedRevision ?? 0;
|
|
394
|
+
}
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function rememberProcessed(commandId, result, materializedRevision) {
|
|
399
|
+
processedCommands.set(commandId, { result, materializedRevision });
|
|
400
|
+
while (processedCommands.size > PROCESSED_RING_MAX) {
|
|
401
|
+
processedCommands.delete(processedCommands.keys().next().value);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Checkpoint + GC once the journal outgrows the threshold. GC only runs when
|
|
406
|
+
* the checkpoint write provably succeeded (journal module enforces this). */
|
|
407
|
+
function maybeCheckpoint(journalBytes) {
|
|
408
|
+
if (journalBytes - lastCheckpointBytes < CHECKPOINT_THRESHOLD_BYTES) return;
|
|
409
|
+
if (!writeCheckpoint(root, { materializedRevision: revisionCounter, journalBytes })) return;
|
|
410
|
+
lastCheckpointBytes = gcJournal(root);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** POSIX process start token — /proc/<pid>/stat field 22 (starttime), stable
|
|
415
|
+
* across exec(2). Same recipe as pty-runner: lets locks.mjs reclaim this
|
|
416
|
+
* coordinator's lease after a SIGKILL even if the pid was reused. null on
|
|
417
|
+
* failure or non-Linux platforms (reclaim degrades to "blocked" there — same
|
|
418
|
+
* platform parity as PTY hosts). */
|
|
419
|
+
function captureStartToken(pid) {
|
|
420
|
+
if (process.platform !== "linux" || !pid) return null;
|
|
421
|
+
try {
|
|
422
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
423
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trimStart();
|
|
424
|
+
const fields = afterComm.split(/\s+/);
|
|
425
|
+
return fields[19] ?? null;
|
|
426
|
+
} catch {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
}
|
package/runner/state-runner.mjs
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { readJson } from "../src/core/atomic.mjs";
|
|
11
11
|
import { appendDiagnostic } from "../src/core/diagnostics.mjs";
|
|
12
|
-
import { applyAutoStateToStatus, applyAutoStateToViewState, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState } from "../src/core/auto-state.mjs";
|
|
12
|
+
import { applyAutoStateToStatus, applyAutoStateToViewState, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState, isManualCompletion } from "../src/core/auto-state.mjs";
|
|
13
13
|
import { finalizeEvidence, readEvidence, summarizeEvidence, writeEvidence } from "../src/core/evidence.mjs";
|
|
14
14
|
import { updateCodeRefsFromEvidence } from "../src/core/code-refs-store.mjs";
|
|
15
15
|
import { readState, readStatus, readMeta, writeState, writeStatus } from "../src/core/store.mjs";
|
|
16
|
+
import { sendStateCommand } from "../src/core/coordinator-client.mjs";
|
|
17
|
+
import { commandRejectDiagnostic } from "../src/core/state-commands.mjs";
|
|
16
18
|
|
|
17
19
|
async function main() {
|
|
18
20
|
const configPath = process.argv[2];
|
|
@@ -26,6 +28,10 @@ async function main() {
|
|
|
26
28
|
|
|
27
29
|
const state = readState(config.root, config.viewId);
|
|
28
30
|
if (!state || state.processState === "alive" || state.semanticState === "failed" || state.semanticState === "stopped") process.exit(0);
|
|
31
|
+
// Cheap pre-check (optimization only): skip a pointless command when the
|
|
32
|
+
// manual verdict is already materialized. The coordinator's manual_fence
|
|
33
|
+
// stays the authoritative guard for races after this read.
|
|
34
|
+
if (isManualCompletion(state)) process.exit(0);
|
|
29
35
|
|
|
30
36
|
const evidence = readEvidence(config.root, config.viewId);
|
|
31
37
|
const latest = latestEvidenceText(evidence) || state.latestAssistantPreview || state.summary || "";
|
|
@@ -45,24 +51,93 @@ async function main() {
|
|
|
45
51
|
classification = autoStateFromModelOrHeuristic(out, latest, { lastAgentActivityAt: state.lastAgentActivityAt ?? null });
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
// Issue #91 (A8 path 2): the classification lands through the View State
|
|
55
|
+
// Coordinator — the single writer of state.json/status.json. A manual
|
|
56
|
+
// completion is fenced by the coordinator (manual_fence), so this late pass
|
|
57
|
+
// can no longer clobber the user's verdict (#46 class). Ambiguous transport
|
|
58
|
+
// outcomes (timeout / connection_reset) NEVER fall back to a direct write:
|
|
59
|
+
// the command may already be journaled, and the coordinator's boot replay
|
|
60
|
+
// is the recovery path.
|
|
61
|
+
const result = await sendStateCommand(config.root, {
|
|
62
|
+
type: "state_command",
|
|
63
|
+
viewId: config.viewId,
|
|
64
|
+
runId: config.runId ?? null,
|
|
65
|
+
source: "state-runner",
|
|
66
|
+
kind: "auto_state_classified",
|
|
67
|
+
expectedRevision: null,
|
|
68
|
+
payload: { classification },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (result.status === "applied") {
|
|
72
|
+
appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
|
|
73
|
+
} else if (result.reason !== "coordinator_disabled") {
|
|
74
|
+
// Unified decided/ambiguous classification (CR r2 issue-3): decided
|
|
75
|
+
// rejects (manual_fence/stale_run/…) log as info *_skipped — the
|
|
76
|
+
// coordinator's verdict is authoritative; only genuinely ambiguous
|
|
77
|
+
// outcomes (timeout/connection_reset) keep the recovery-path warn.
|
|
78
|
+
appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, ...commandRejectDiagnostic("auto_state", "Auto-state classification", result.reason, "otherwise the next classification pass will converge the row"), details: { reason: result.reason } });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (result.reason === "coordinator_disabled") {
|
|
82
|
+
// Legacy escape hatch (AGENT_BOARD_COORDINATOR=off): pre-coordinator
|
|
83
|
+
// direct-write behavior, unchanged.
|
|
84
|
+
let changed = false;
|
|
85
|
+
if (config.runId) {
|
|
86
|
+
const status = readStatus(config.root, config.viewId, config.runId);
|
|
87
|
+
if (status) {
|
|
88
|
+
changed = applyAutoStateToStatus(status, classification, Date.now()) || changed;
|
|
89
|
+
status.evidenceSummary = summarizeEvidence(finalizeEvidence(evidence, status, Date.now()));
|
|
90
|
+
writeStatus(config.root, status);
|
|
91
|
+
}
|
|
55
92
|
}
|
|
93
|
+
const latestState = readState(config.root, config.viewId) ?? state;
|
|
94
|
+
changed = applyAutoStateToViewState(latestState, classification, Date.now()) || changed;
|
|
95
|
+
finalizeEvidence(evidence, { semanticState: latestState.semanticState, usage: null }, Date.now());
|
|
96
|
+
latestState.review = summarizeEvidence(evidence);
|
|
97
|
+
writeEvidence(config.root, evidence);
|
|
98
|
+
updateCodeRefsFromEvidence(config.root, config.viewId, evidence, meta);
|
|
99
|
+
writeState(config.root, latestState);
|
|
100
|
+
if (changed) {
|
|
101
|
+
appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
|
|
102
|
+
}
|
|
103
|
+
process.exit(0);
|
|
56
104
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
105
|
+
|
|
106
|
+
// Evidence pipeline (coordinator-independent) stays direct: finalize the
|
|
107
|
+
// evidence snapshot from a FRESH state read (reads are not writes — the
|
|
108
|
+
// coordinator owns state.json/status.json writes, not reads), then persist
|
|
109
|
+
// the evidence artifacts and code-refs.
|
|
110
|
+
const postState = readState(config.root, config.viewId);
|
|
111
|
+
finalizeEvidence(evidence, { semanticState: postState?.semanticState ?? state.semanticState, usage: null }, Date.now());
|
|
61
112
|
writeEvidence(config.root, evidence);
|
|
62
113
|
updateCodeRefsFromEvidence(config.root, config.viewId, evidence, meta);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
114
|
+
|
|
115
|
+
// Issue #91 PR #2 (Task 4): the evidence mirrors move behind the coordinator
|
|
116
|
+
// too — one patch_fields command materializes review/evidenceSummary on both
|
|
117
|
+
// files under a shared materializedRevision (previously two separately
|
|
118
|
+
// fenced direct writes). The coordinator's generic manual_fence / stale_run
|
|
119
|
+
// guards are authoritative; ambiguous outcomes (timeout / connection_reset)
|
|
120
|
+
// NEVER fall back to a direct write — if the command was journaled, boot
|
|
121
|
+
// replay recovers it, and the next classification pass re-derives mirrors
|
|
122
|
+
// from the evidence files either way.
|
|
123
|
+
const mirrorSummary = summarizeEvidence(evidence);
|
|
124
|
+
const patch = await sendStateCommand(config.root, {
|
|
125
|
+
type: "state_command",
|
|
126
|
+
viewId: config.viewId,
|
|
127
|
+
runId: config.runId ?? null,
|
|
128
|
+
source: "state-runner",
|
|
129
|
+
kind: "patch_fields",
|
|
130
|
+
expectedRevision: null,
|
|
131
|
+
payload: { state: { review: mirrorSummary }, status: { evidenceSummary: mirrorSummary } },
|
|
132
|
+
});
|
|
133
|
+
if (patch.status === "applied") {
|
|
134
|
+
// Quiet success — mirrors materialized by the coordinator.
|
|
135
|
+
} else if (patch.reason === "manual_fence" || patch.reason === "stale_run" || patch.reason === "no_change") {
|
|
136
|
+
// Designed fences — informational: a manual verdict or a newer run owns
|
|
137
|
+
// the row, or the mirrors already match.
|
|
138
|
+
appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "evidence_mirror_patch_skipped", message: `Evidence mirror patch not applied (${patch.reason})`, details: { reason: patch.reason } });
|
|
139
|
+
} else {
|
|
140
|
+
appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, level: "warn", code: "evidence_mirror_patch_ambiguous", message: `Evidence mirror patch outcome unknown (${patch.reason}); if the command was journaled, coordinator replay will recover it; the next classification pass re-derives the mirrors from the evidence files`, details: { reason: patch.reason } });
|
|
66
141
|
}
|
|
67
142
|
}
|
|
68
143
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Authoritative entry for the A11 perf gate (issue #121).
|
|
4
|
+
*
|
|
5
|
+
* Loudly refuses under coverage instrumentation: c8 sets NODE_V8_COVERAGE
|
|
6
|
+
* for its whole process tree, and instrumented latency measurements are
|
|
7
|
+
* invalid (2.5–6× inflation). Silently skipping here would manufacture a
|
|
8
|
+
* fake-green gate, so refusal is a nonzero exit with an explicit message.
|
|
9
|
+
*
|
|
10
|
+
* This script is the side-effect layer only — no decision logic, no
|
|
11
|
+
* measurement. The pure gate decision lives in test-support/perf-gate.mjs
|
|
12
|
+
* and serves the test file; do not merge the layers (spec §3.2/§4).
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
if (process.env.NODE_V8_COVERAGE !== undefined) {
|
|
18
|
+
console.error(
|
|
19
|
+
"run-perf-gate: refusing to measure under coverage instrumentation " +
|
|
20
|
+
"(NODE_V8_COVERAGE is set). Perf assertions are only valid without c8; " +
|
|
21
|
+
"run `npm run test:perf` directly, outside any coverage wrapper.",
|
|
22
|
+
);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const perfTest = fileURLToPath(new URL("../test/terminal-model-perf.test.mjs", import.meta.url));
|
|
27
|
+
// No --test-concurrency=1: the flag requires Node 21+ but package.json
|
|
28
|
+
// declares engines >=20, and it is a no-op while the gate runs a single
|
|
29
|
+
// file (spec D3, revised 2026-09-20). Re-add it when a second perf file
|
|
30
|
+
// lands — parallel measurement across files must stay impossible.
|
|
31
|
+
const child = spawn(
|
|
32
|
+
process.execPath,
|
|
33
|
+
["--test", perfTest],
|
|
34
|
+
{ stdio: "inherit", env: { ...process.env, AGENT_BOARD_PERF_GATE: "1" } },
|
|
35
|
+
);
|
|
36
|
+
child.on("error", (err) => {
|
|
37
|
+
console.error(`run-perf-gate: failed to spawn the perf suite: ${err.message}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
});
|
|
40
|
+
child.on("exit", (code) => process.exit(code ?? 1));
|
|
@@ -106,7 +106,7 @@ export async function openDashboard(
|
|
|
106
106
|
currentThinkingLevel: options.currentThinkingLevel ?? "off",
|
|
107
107
|
});
|
|
108
108
|
interval = setInterval(() => {
|
|
109
|
-
service.reconcile();
|
|
109
|
+
void service.reconcile().catch(() => {});
|
|
110
110
|
comp.refresh();
|
|
111
111
|
requestDashboardRender(tui);
|
|
112
112
|
}, POLL_MS);
|
|
@@ -146,8 +146,8 @@ export async function dashboardAttachLoop(
|
|
|
146
146
|
let again = true;
|
|
147
147
|
while (again) {
|
|
148
148
|
const currentId = currentViewId(ctx, service);
|
|
149
|
-
if (currentId) service.markVisited?.(currentId);
|
|
150
|
-
service.reconcile();
|
|
149
|
+
if (currentId) void service.markVisited?.(currentId)?.catch(() => {});
|
|
150
|
+
void service.reconcile().catch(() => {});
|
|
151
151
|
const result = await openDashboard(ctx, service, {
|
|
152
152
|
initialSelectedId: selectedId,
|
|
153
153
|
currentThinkingLevel: getThinkingLevel?.(),
|
|
@@ -185,9 +185,9 @@ async function attach(
|
|
|
185
185
|
// Single async resolver entry: real probe + bounded recovery decide readiness (issue #70).
|
|
186
186
|
const plan = planAttachResolved(await service.resolveAttachTarget(viewId));
|
|
187
187
|
if (plan.plan === "open-pty") {
|
|
188
|
-
service.markVisited?.(viewId);
|
|
188
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
189
189
|
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
|
|
190
|
-
service.markVisited?.(viewId);
|
|
190
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
191
191
|
return { action: result.action === "closed" ? "closed" : "detached" };
|
|
192
192
|
}
|
|
193
193
|
if (plan.plan === "session-switch") {
|
|
@@ -197,7 +197,7 @@ async function attach(
|
|
|
197
197
|
return { action: "none" };
|
|
198
198
|
}
|
|
199
199
|
const name = latest.meta.name;
|
|
200
|
-
service.markVisited?.(viewId);
|
|
200
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
201
201
|
const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable");
|
|
202
202
|
const result = await ctx.switchSession(latest.meta.sessionFile, {
|
|
203
203
|
withSession: async (replaced) => {
|
|
@@ -296,8 +296,8 @@ function installBackToDashboard(ctx: ExtensionCommandContext, service: ReturnTyp
|
|
|
296
296
|
try {
|
|
297
297
|
let selectedId = currentViewId(ctx, service);
|
|
298
298
|
while (true) {
|
|
299
|
-
if (selectedId) service.markVisited?.(selectedId);
|
|
300
|
-
service.reconcile();
|
|
299
|
+
if (selectedId) void service.markVisited?.(selectedId)?.catch(() => {});
|
|
300
|
+
void service.reconcile().catch(() => {});
|
|
301
301
|
const result = await openDashboard(ctx, service, { initialSelectedId: selectedId });
|
|
302
302
|
if (result.action !== "attach") return;
|
|
303
303
|
selectedId = result.viewId;
|