@zhuxixi/pi-agent-board 0.5.2 → 0.6.1
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 +44 -0
- package/README.md +43 -5
- package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
- package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
- package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
- package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
- package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
- package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
- package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
- package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
- package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
- package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
- package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +30 -0
- package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +68 -0
- package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +125 -0
- package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +56 -0
- package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +96 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
- package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
- package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
- package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
- package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
- package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
- package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
- package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
- package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
- package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +58 -0
- package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +52 -0
- package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +87 -0
- package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +56 -0
- package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +79 -0
- package/package.json +83 -81
- package/runner/job-runner.mjs +2 -2
- package/runner/pty-runner.mjs +626 -3
- package/runner/state-runner.mjs +3 -0
- package/runner/title-runner.mjs +1 -1
- package/scripts/release_helper.mjs +277 -0
- package/src/commands/agent-board.ts +47 -35
- package/src/commands/attach-decision.mjs +66 -0
- package/src/commands/attach-flow.ts +45 -39
- package/src/commands/bg.ts +9 -0
- package/src/core/code-refs.mjs +85 -33
- package/src/core/evidence.mjs +2 -2
- package/src/core/heuristics.mjs +75 -2
- package/src/core/host-coordination.mjs +182 -0
- package/src/core/host-crash.mjs +43 -3
- package/src/core/host-probe.mjs +196 -0
- package/src/core/launch-options.mjs +17 -0
- package/src/core/launch.mjs +35 -35
- package/src/core/locks.mjs +196 -1
- package/src/core/paths.mjs +24 -0
- package/src/core/store.mjs +164 -5
- package/src/core/types.mjs +17 -1
- package/src/core/warm-host-sweeper.mjs +150 -0
- package/src/index.ts +40 -3
- package/src/runtime/service.mjs +1071 -108
- package/src/ui/dashboard-decisions.mjs +55 -0
- package/src/ui/dashboard.ts +67 -13
- package/src/ui/pty-attach.ts +23 -5
package/src/runtime/service.mjs
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
* safety rule. Pure node + core modules; the Pi-coupled bits (attach, dialogs) live in
|
|
5
5
|
* the command handler. The pi invocation + runner path are injected (resolved in index.ts).
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
8
9
|
import { createRequire } from "node:module";
|
|
9
10
|
import { createConnection } from "node:net";
|
|
10
11
|
import { resolve } from "node:path";
|
|
11
12
|
import { applyAutoStateToStatus, autoStateEnabled, heuristicAutoState } from "../core/auto-state.mjs";
|
|
12
|
-
import { appendLine } from "../core/atomic.mjs";
|
|
13
|
+
import { appendLine, atomicWriteJson, removeFile } from "../core/atomic.mjs";
|
|
13
14
|
import { finalizeRun, projectViewState, reduceEvent } from "../core/events.mjs";
|
|
14
15
|
import { clearDiagnostics, appendDiagnostic, tailDiagnostics } from "../core/diagnostics.mjs";
|
|
15
16
|
import { emptyEvidenceSnapshot, finalizeEvidence, readEvidence, reduceEvidence, summarizeEvidence, writeEvidence } from "../core/evidence.mjs";
|
|
@@ -22,18 +23,25 @@ import { firstSentence, truncate } from "../core/heuristics.mjs";
|
|
|
22
23
|
import { newRunId, newViewId, slugifyTask } from "../core/ids.mjs";
|
|
23
24
|
import { launchAutoState as launchAutoStateProcess, launchHost as launchHostProcess, launchRun, launchTitle as launchTitleProcess } from "../core/launch.mjs";
|
|
24
25
|
import { gitRepoRoot } from "../core/repo.mjs";
|
|
25
|
-
import { killProcess } from "../core/pid.mjs";
|
|
26
|
+
import { isAlive, killProcess } from "../core/pid.mjs";
|
|
27
|
+
import { acquireOwnedViewLock, tryAcquireOwnedViewLock } from "../core/locks.mjs";
|
|
28
|
+
import { canFinalizeLegacyHost, canReplaceHost } from "../core/host-coordination.mjs";
|
|
29
|
+
import { modelRefAvailable } from "../core/launch-options.mjs";
|
|
30
|
+
import { HOST_PROBE_RETRY_MS, probeHost } from "../core/host-probe.mjs";
|
|
26
31
|
import * as P from "../core/paths.mjs";
|
|
27
32
|
import {
|
|
33
|
+
claimHost,
|
|
28
34
|
createView,
|
|
29
35
|
listRows,
|
|
30
36
|
loadRow,
|
|
37
|
+
readHost,
|
|
38
|
+
readHostPid,
|
|
31
39
|
readLaunchPrefs,
|
|
32
40
|
readPid,
|
|
33
41
|
readState,
|
|
34
42
|
readStatus,
|
|
43
|
+
updateOwnedHost,
|
|
35
44
|
writeHost,
|
|
36
|
-
writeHostPid,
|
|
37
45
|
writeLaunchPrefs,
|
|
38
46
|
writeMeta,
|
|
39
47
|
writeState,
|
|
@@ -41,9 +49,21 @@ import {
|
|
|
41
49
|
} from "../core/store.mjs";
|
|
42
50
|
import { diagnoseNodePtyFailure, ensureNodePtySpawnHelperExecutable, nodePtyFallbackMessage, probeNodePtyEnvironment } from "../core/pty-support.mjs";
|
|
43
51
|
import { normalizeScreenLogMaxBytes, pruneScreenLogs } from "../core/screen-log-gc.mjs";
|
|
52
|
+
import { hasPendingQuestions, isAgentBusy, selectIdleHostsToEvict } from "../core/warm-host-sweeper.mjs";
|
|
44
53
|
|
|
45
54
|
/** @typedef {import("../core/types.mjs").RunKind} RunKind */
|
|
46
55
|
|
|
56
|
+
/** A fresh `starting` claim is a normal cold start for this long — never an orphan (issue #70). */
|
|
57
|
+
export const HOST_START_GRACE_MS = 10_000;
|
|
58
|
+
/** Bounded wait for the per-view host-start lease on explicit user actions (dispatch/reply). */
|
|
59
|
+
export const HOST_START_LOCK_WAIT_MS = 500;
|
|
60
|
+
/** Bounded wait for a revoked host's runner/child to end before recovery escalates (issue #70). */
|
|
61
|
+
export const HOST_RECOVERY_GRACE_MS = 5_000;
|
|
62
|
+
/** Poll cadence while waiting out a host recovery window. */
|
|
63
|
+
const HOST_RECOVERY_POLL_MS = 150;
|
|
64
|
+
/** Default wall-clock budget for one attach resolution (issue #70 Task 12). */
|
|
65
|
+
const ATTACH_RESOLVE_TIMEOUT_MS = 120_000;
|
|
66
|
+
|
|
47
67
|
/**
|
|
48
68
|
* @param {{
|
|
49
69
|
* root: string,
|
|
@@ -60,6 +80,15 @@ import { normalizeScreenLogMaxBytes, pruneScreenLogs } from "../core/screen-log-
|
|
|
60
80
|
* launchAutoState?: typeof launchAutoStateProcess,
|
|
61
81
|
* ptySupport?: (opts?: { refresh?: boolean, maxAgeMs?: number }) => { ok: boolean, reason?: string|null, issue?: any },
|
|
62
82
|
* pruneScreenLogs?: typeof pruneScreenLogs,
|
|
83
|
+
* randomId?: () => string,
|
|
84
|
+
* now?: () => number,
|
|
85
|
+
* acquireLock?: typeof acquireOwnedViewLock,
|
|
86
|
+
* tryAcquireLock?: typeof tryAcquireOwnedViewLock,
|
|
87
|
+
* observeProcess?: (identity: {pid: number, startToken: string|null}|null) => "not_started"|"dead"|"owned"|"foreign"|"unknown",
|
|
88
|
+
* signalOwnedProcess?: (identity: {pid: number, startToken: string|null}, signal: string) => void,
|
|
89
|
+
* probeHostFn?: typeof probeHost,
|
|
90
|
+
* sleepFn?: (ms: number) => Promise<void>,
|
|
91
|
+
* availableModels?: () => Array<{ provider: string, id: string }> | undefined,
|
|
63
92
|
* }} opts
|
|
64
93
|
*/
|
|
65
94
|
export function createService(opts) {
|
|
@@ -71,6 +100,26 @@ export function createService(opts) {
|
|
|
71
100
|
const ptySupport = opts.ptySupport ?? ptyHostAvailability;
|
|
72
101
|
const ptyRunnerScript = opts.ptyRunnerScript ?? opts.runnerScript;
|
|
73
102
|
const titleRunnerScript = opts.titleRunnerScript ?? null;
|
|
103
|
+
const randomIdImpl = opts.randomId ?? (() => randomBytes(16).toString("hex"));
|
|
104
|
+
const nowImpl = opts.now ?? Date.now;
|
|
105
|
+
const acquireLockImpl = opts.acquireLock ?? acquireOwnedViewLock;
|
|
106
|
+
const tryAcquireLockImpl = opts.tryAcquireLock ?? tryAcquireOwnedViewLock;
|
|
107
|
+
// Identity-aware observation/signalling for host recovery (issue #70). Callers must
|
|
108
|
+
// only signal after observeProcess returned "owned" for that exact identity.
|
|
109
|
+
const observeProcessImpl = opts.observeProcess ?? defaultObserveProcess;
|
|
110
|
+
const signalOwnedProcessImpl = opts.signalOwnedProcess ?? defaultSignalOwnedProcess;
|
|
111
|
+
// Attach-resolver dependencies (issue #70 Task 12): a real socket probe and an
|
|
112
|
+
// awaitable sleep. The resolver runs on the UI thread, so its waits MUST go
|
|
113
|
+
// through sleepFn (never a blocking acquire / Atomics.wait).
|
|
114
|
+
const probeHostImpl = opts.probeHostFn ?? probeHost;
|
|
115
|
+
const sleepFnImpl = opts.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
116
|
+
// Live model list for the stale-defaultModel launch guard (issue #90). Called
|
|
117
|
+
// at validation time (never cached at startup) and wrapped defensively by the
|
|
118
|
+
// caller; undefined/absent skips validation entirely.
|
|
119
|
+
const availableModelsImpl = opts.availableModels ?? null;
|
|
120
|
+
// In-flight attach resolutions, keyed by viewId: concurrent resolver calls for
|
|
121
|
+
// the same view share one promise (issue #70 Task 12).
|
|
122
|
+
const inflightAttachResolvers = new Map();
|
|
74
123
|
|
|
75
124
|
const pruneScreenLogsImpl = opts.pruneScreenLogs ?? pruneScreenLogs;
|
|
76
125
|
// Reclaim replay logs of long-ended views on dashboard startup. Deferred via
|
|
@@ -121,12 +170,88 @@ export function createService(opts) {
|
|
|
121
170
|
}
|
|
122
171
|
|
|
123
172
|
/**
|
|
124
|
-
* Launch a durable interactive PTY host for a view
|
|
173
|
+
* Launch a durable interactive PTY host for a view — two-phase claim protocol
|
|
174
|
+
* (issue #70). Everything (claim → spawn → runnerPid merge) happens while the
|
|
175
|
+
* caller holds the per-view `host-start` lease, so concurrent service processes
|
|
176
|
+
* can never stack a second host on top of a live claim.
|
|
125
177
|
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
126
178
|
* @param {string|null} initialPrompt
|
|
127
|
-
* @returns {{ pid: number|null }}
|
|
179
|
+
* @returns {{ ok: true, status: "started"|"pending"|"reused", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }}
|
|
128
180
|
*/
|
|
129
181
|
function launchHost(meta, initialPrompt, launchOpts = {}) {
|
|
182
|
+
let lease;
|
|
183
|
+
try {
|
|
184
|
+
lease = acquireLockImpl(root, meta.id, "host-start", { waitMs: HOST_START_LOCK_WAIT_MS, identity: serviceIdentity() });
|
|
185
|
+
} catch {
|
|
186
|
+
// LOCK_TIMEOUT means lease contention (another live process holds it);
|
|
187
|
+
// an IO-class failure means the lock path itself is unusable. Both map
|
|
188
|
+
// to "cannot launch now": surface the existing claim as pending, never stack.
|
|
189
|
+
const existing = readHost(root, meta.id);
|
|
190
|
+
return pendingLaunchResult(existing);
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
return startHostUnderLease(meta, initialPrompt, launchOpts);
|
|
194
|
+
} finally {
|
|
195
|
+
try { lease.release(); } catch { /* best effort */ }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** @param {import("../core/types.mjs").HostStatus|null} host */
|
|
200
|
+
function pendingLaunchResult(host) {
|
|
201
|
+
return { ok: true, status: "pending", pid: null, socketPath: host?.socketPath ?? null, instanceId: host?.instanceId ?? null };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Actionable error when the view's defaultModel is provably unavailable
|
|
206
|
+
* (issue #90): launching a child with a dead model id makes it exit 1 on
|
|
207
|
+
* every start, so attach would loop cold starts forever. Only fires when a
|
|
208
|
+
* model is configured AND a live model list is injectable; anything
|
|
209
|
+
* uncertain (no list, list call failure) conservatively allows the launch.
|
|
210
|
+
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
211
|
+
* @returns {string|null}
|
|
212
|
+
*/
|
|
213
|
+
function validateViewModelMeta(meta) {
|
|
214
|
+
const model = meta.defaultModel ?? null;
|
|
215
|
+
if (!model || !availableModelsImpl) return null;
|
|
216
|
+
let available;
|
|
217
|
+
try {
|
|
218
|
+
available = availableModelsImpl();
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
if (modelRefAvailable(model, available)) return null;
|
|
223
|
+
return `Model "${model}" configured for this session is no longer available — update the view's model (or clear defaultModel) and retry attach.`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Claim → spawn → merge flow. The CALLER must already hold the `host-start`
|
|
228
|
+
* lease; this function never acquires or releases it.
|
|
229
|
+
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
230
|
+
* @param {string|null} initialPrompt
|
|
231
|
+
* @returns {ReturnType<typeof launchHost>}
|
|
232
|
+
*/
|
|
233
|
+
function startHostUnderLease(meta, initialPrompt, launchOpts = {}) {
|
|
234
|
+
const existing = readHost(root, meta.id);
|
|
235
|
+
if (existing && (existing.state === "starting" || existing.state === "alive" || existing.state === "stopping")) {
|
|
236
|
+
// An active claim already exists — reuse it, never a second spawn.
|
|
237
|
+
return existing.state === "alive"
|
|
238
|
+
? { ok: true, status: "reused", pid: null, socketPath: existing.socketPath ?? null, instanceId: existing.instanceId ?? null }
|
|
239
|
+
: pendingLaunchResult(existing);
|
|
240
|
+
}
|
|
241
|
+
// No record is trivially replaceable; a terminal record only when nothing
|
|
242
|
+
// it names can still be alive (conservative: a live pid is "unknown" and blocks).
|
|
243
|
+
if (existing && !canReplaceHost(observeHostForReplace(existing))) {
|
|
244
|
+
return pendingLaunchResult(existing);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Fail fast BEFORE claiming (issue #90): no claim record, no config file,
|
|
248
|
+
// no spawn — the resolver surfaces the error via its pending reason.
|
|
249
|
+
const modelError = validateViewModelMeta(meta);
|
|
250
|
+
if (modelError) return { ok: false, error: modelError };
|
|
251
|
+
|
|
252
|
+
const instanceId = randomIdImpl();
|
|
253
|
+
const configPath = P.hostConfigPathFor(root, meta.id, instanceId);
|
|
254
|
+
const socketPath = P.hostEndpointPathFor(process.platform, root, meta.id, instanceId);
|
|
130
255
|
/** @type {import("../core/types.mjs").HostConfig} */
|
|
131
256
|
const config = {
|
|
132
257
|
root,
|
|
@@ -143,30 +268,44 @@ export function createService(opts) {
|
|
|
143
268
|
cols: Number(process.env.COLUMNS || 120),
|
|
144
269
|
rows: Number(process.env.LINES || 36),
|
|
145
270
|
screenLogMaxBytes: normalizeScreenLogMaxBytes(readLaunchPrefs(root).screenLogMaxSize),
|
|
271
|
+
instanceId,
|
|
272
|
+
configPath,
|
|
273
|
+
socketPath,
|
|
146
274
|
};
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
writeHost(root, {
|
|
150
|
-
version: 1,
|
|
275
|
+
const claimIdentity = serviceIdentity();
|
|
276
|
+
const claimed = claimHost(root, {
|
|
151
277
|
viewId: meta.id,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
childPid: null,
|
|
278
|
+
instanceId,
|
|
279
|
+
configPath,
|
|
155
280
|
socketPath,
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
endedAt: null,
|
|
160
|
-
exitCode: null,
|
|
161
|
-
error: null,
|
|
281
|
+
claimAt: nowImpl(),
|
|
282
|
+
claimPid: process.pid,
|
|
283
|
+
claimIdentity,
|
|
162
284
|
cols: config.cols,
|
|
163
285
|
rows: config.rows,
|
|
164
|
-
attachedClients: 0,
|
|
165
286
|
});
|
|
166
|
-
|
|
167
|
-
|
|
287
|
+
if (!claimed.claimed) {
|
|
288
|
+
// Lost the host-meta race: another launch owns the claim now.
|
|
289
|
+
return pendingLaunchResult(claimed.host);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let pid = null;
|
|
293
|
+
let spawnError = null;
|
|
294
|
+
try {
|
|
295
|
+
({ pid } = launchHostImpl(root, config, { runnerScript: ptyRunnerScript }));
|
|
296
|
+
} catch (err) {
|
|
297
|
+
spawnError = err instanceof Error ? err.message : String(err);
|
|
298
|
+
}
|
|
299
|
+
if (pid == null) {
|
|
300
|
+
const message = spawnError ?? "PTY host runner failed to spawn";
|
|
301
|
+
updateOwnedHost(root, meta.id, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: message }));
|
|
302
|
+
removeFile(configPath);
|
|
303
|
+
return { ok: false, error: message, fallbackReason: message };
|
|
304
|
+
}
|
|
305
|
+
updateOwnedHost(root, meta.id, instanceId, (h) => ({ ...h, runnerPid: pid, runnerSpawnedAt: nowImpl() }));
|
|
306
|
+
appendDiagnostic(root, meta.id, { source: "service", code: "launch_host", message: "PTY host launched", details: { pid, instanceId, hasInitialPrompt: Boolean(initialPrompt) } });
|
|
168
307
|
if (launchOpts.markQueued !== false) markQueued(meta.id, null);
|
|
169
|
-
return { pid, socketPath };
|
|
308
|
+
return { ok: true, status: "started", pid, socketPath, instanceId };
|
|
170
309
|
}
|
|
171
310
|
|
|
172
311
|
/**
|
|
@@ -292,7 +431,8 @@ export function createService(opts) {
|
|
|
292
431
|
function archiveView(viewId) {
|
|
293
432
|
const row = loadRow(root, viewId);
|
|
294
433
|
if (!row) return { ok: false, error: "Unknown session" };
|
|
295
|
-
if (row.
|
|
434
|
+
if (row.host?.instanceId) stopHostRow(row, "archive");
|
|
435
|
+
else if (row.hostAlive) sendHostMessage(row, { type: "terminate" });
|
|
296
436
|
if (row.alive && row.state?.currentRunId) {
|
|
297
437
|
const pid = readPid(root, viewId, row.state.currentRunId);
|
|
298
438
|
if (pid) killProcess(pid);
|
|
@@ -401,32 +541,505 @@ export function createService(opts) {
|
|
|
401
541
|
}
|
|
402
542
|
|
|
403
543
|
/**
|
|
404
|
-
*
|
|
405
|
-
*
|
|
544
|
+
* Evict idle warm PTY hosts past TTL / over the warm pool cap.
|
|
545
|
+
* Idle = host alive, not agent-busy, no attached clients.
|
|
546
|
+
* Called lazily from dispatch/ensureHost (keepViewId = the view being used)
|
|
547
|
+
* and periodically / on shutdown by the warm-host sweeper (issue #75).
|
|
406
548
|
* @param {{ keepViewId?: string|null }} [pruneOpts]
|
|
407
549
|
*/
|
|
408
550
|
function pruneWarmHosts(pruneOpts = {}) {
|
|
409
551
|
const maxWarm = envInt("AGENT_BOARD_MAX_WARM_HOSTS", 4, 0, 50, "AGENT_VIEW_MAX_WARM_HOSTS");
|
|
410
552
|
const ttlMs = envInt("AGENT_BOARD_WARM_HOST_TTL_MS", 10 * 60 * 1000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_WARM_HOST_TTL_MS");
|
|
411
553
|
if (maxWarm === 0 && ttlMs === 0) return;
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
.
|
|
415
|
-
|
|
554
|
+
const graceMs = envInt("AGENT_BOARD_WARM_HOST_GRACE_MS", 30 * 1000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_WARM_HOST_GRACE_MS");
|
|
555
|
+
const { ttlEvicted, excessEvicted } = selectIdleHostsToEvict(listRows(root), {
|
|
556
|
+
now: Date.now(),
|
|
557
|
+
maxWarm,
|
|
558
|
+
ttlMs,
|
|
559
|
+
graceMs,
|
|
560
|
+
keepViewId: pruneOpts.keepViewId ?? null,
|
|
561
|
+
});
|
|
562
|
+
for (const viewId of [...ttlEvicted, ...excessEvicted]) {
|
|
563
|
+
// Re-verify against fresh state before terminating: the selection ran
|
|
564
|
+
// on a listRows snapshot, and the host may have been dispatched (busy)
|
|
565
|
+
// or attached (attachedClients > 0) since. Never kill a busy/attached
|
|
566
|
+
// host; graceMs also re-checked in case the host was just restarted.
|
|
567
|
+
const row = loadRow(root, viewId);
|
|
568
|
+
if (!row?.hostAlive) continue;
|
|
569
|
+
if (isAgentBusy(row)) continue;
|
|
570
|
+
if ((row.host?.attachedClients ?? 0) !== 0) continue;
|
|
571
|
+
if (graceMs > 0 && row.host?.startedAt != null && Date.now() - row.host.startedAt < graceMs) continue;
|
|
572
|
+
stopHostRow(row, "warm_prune");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Stop a host row by policy (issue #70): new-protocol hosts get the authoritative
|
|
577
|
+
* file-based revoke (works even when the control socket is dead); legacy hosts
|
|
578
|
+
* without an instanceId keep the old socket-terminate fallback. */
|
|
579
|
+
function stopHostRow(row, reason) {
|
|
580
|
+
const instanceId = row.host?.instanceId ?? null;
|
|
581
|
+
if (instanceId) return requestHostStop(row.meta.id, instanceId, reason);
|
|
582
|
+
return sendHostMessage(row, { type: "terminate" });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* File-based host revoke (issue #70): marks the host record `stopping` with a
|
|
587
|
+
* fresh revoke token so the runner's heartbeat shuts it down cooperatively.
|
|
588
|
+
* Never signals, never spawns a replacement. Idempotent: terminal or
|
|
589
|
+
* already-stopping states are left untouched (`requested: false`).
|
|
590
|
+
* @param {string} viewId
|
|
591
|
+
* @param {string|null} expectedInstanceId
|
|
592
|
+
* @param {string} reason
|
|
593
|
+
* @returns {{ ok: boolean, requested?: boolean, error?: string }}
|
|
594
|
+
*/
|
|
595
|
+
function requestHostStop(viewId, expectedInstanceId, reason) {
|
|
596
|
+
let wasRequested = false;
|
|
597
|
+
const res = updateOwnedHost(root, viewId, expectedInstanceId, (h) => {
|
|
598
|
+
if (h.state === "exited" || h.state === "failed" || h.state === "stopping") return h;
|
|
599
|
+
wasRequested = true;
|
|
600
|
+
return { ...h, state: "stopping", stopRequestedAt: nowImpl(), revokeToken: randomIdImpl(), stopReason: reason };
|
|
601
|
+
});
|
|
602
|
+
if (!res.updated) return { ok: false, error: "owner_changed" };
|
|
603
|
+
return { ok: true, requested: wasRequested };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Observe one host role: a recorded identity goes through identity-aware
|
|
607
|
+
* observation; a missing identity means `not_started` (never spawned) or
|
|
608
|
+
* `unknown` (spawn attempted, identity unavailable) — never `dead`. */
|
|
609
|
+
function observeHostRole(host, role) {
|
|
610
|
+
const identity = host?.[`${role}Identity`] ?? null;
|
|
611
|
+
if (identity) return observeProcessImpl(identity);
|
|
612
|
+
return host?.[`${role}SpawnedAt`] == null ? "not_started" : "unknown";
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Revoke + wait + replace a broken host (issue #70). The caller (attach
|
|
617
|
+
* resolver) has already probed and decided the instance is unrecoverable.
|
|
618
|
+
* Escalation signals go ONLY to identities observed as "owned" at that
|
|
619
|
+
* moment, each guarded by an instance re-read; unknown identities leave the
|
|
620
|
+
* host pending instead of guessing. The replacement claim is created only
|
|
621
|
+
* after runner and child are both confirmed ended.
|
|
622
|
+
* @param {string} viewId
|
|
623
|
+
* @param {string} expectedInstanceId
|
|
624
|
+
* @returns {Promise<{ ok: boolean, recovered?: boolean, instanceId?: string, socketPath?: string|null, error?: string }>}
|
|
625
|
+
*/
|
|
626
|
+
/** A host role observation is "ended" when it is provably not running:
|
|
627
|
+
* never-spawned, confirmed dead, or a reused PID (foreign). */
|
|
628
|
+
const hostRoleEnded = (o) => o === "not_started" || o === "dead" || o === "foreign";
|
|
629
|
+
|
|
630
|
+
async function recoverHost(viewId, expectedInstanceId) {
|
|
631
|
+
const ended = hostRoleEnded;
|
|
632
|
+
const instanceStillExpected = () => {
|
|
633
|
+
const h = readHost(root, viewId);
|
|
634
|
+
return h != null && h.instanceId === expectedInstanceId;
|
|
635
|
+
};
|
|
416
636
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
637
|
+
// Gate: another launch/recovery in flight, or the instance already moved on.
|
|
638
|
+
const gate = tryAcquireLockImpl(root, viewId, "host-start", { identity: serviceIdentity() });
|
|
639
|
+
if (!gate.acquired) return { ok: false, error: "busy_retry_later" };
|
|
640
|
+
try {
|
|
641
|
+
const current = readHost(root, viewId);
|
|
642
|
+
if (!current || current.instanceId !== expectedInstanceId) return { ok: false, error: "already_recovered" };
|
|
643
|
+
if (current.state === "exited" || current.state === "failed") return { ok: false, error: "already_recovered" };
|
|
644
|
+
} finally {
|
|
645
|
+
try { gate.lease.release(); } catch { /* best effort */ }
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// File-based revoke under the lease is done; release before waiting so the
|
|
649
|
+
// dying runner's own finish path is never blocked by this recovery.
|
|
650
|
+
requestHostStop(viewId, expectedInstanceId, "recovery");
|
|
651
|
+
|
|
652
|
+
// Bounded wait for runner/child to end, with identity-checked escalation.
|
|
653
|
+
const startedAt = nowImpl();
|
|
654
|
+
let runnerTermSent = false;
|
|
655
|
+
let runnerKillSent = false;
|
|
656
|
+
let childTermSent = false;
|
|
657
|
+
let childKillSent = false;
|
|
658
|
+
let childTermAt = 0;
|
|
659
|
+
while (nowImpl() - startedAt < HOST_RECOVERY_GRACE_MS) {
|
|
660
|
+
const host = readHost(root, viewId);
|
|
661
|
+
if (!host || host.instanceId !== expectedInstanceId) return { ok: false, error: "already_recovered" };
|
|
662
|
+
const runnerObservation = observeHostRole(host, "runner");
|
|
663
|
+
const childObservation = observeHostRole(host, "child");
|
|
664
|
+
if (ended(runnerObservation) && ended(childObservation)) break;
|
|
665
|
+
// Alive-but-unverifiable: never signal, never claim — stay pending.
|
|
666
|
+
if (runnerObservation === "unknown" || childObservation === "unknown") return { ok: false, error: "recovery_pending" };
|
|
667
|
+
if (!instanceStillExpected()) return { ok: false, error: "already_recovered" };
|
|
668
|
+
const elapsed = nowImpl() - startedAt;
|
|
669
|
+
if (runnerObservation === "owned") {
|
|
670
|
+
if (!runnerTermSent) {
|
|
671
|
+
signalOwnedProcessImpl(host.runnerIdentity, "SIGTERM");
|
|
672
|
+
runnerTermSent = true;
|
|
673
|
+
} else if (elapsed >= HOST_RECOVERY_GRACE_MS / 2 && !runnerKillSent) {
|
|
674
|
+
signalOwnedProcessImpl(host.runnerIdentity, "SIGKILL");
|
|
675
|
+
runnerKillSent = true;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
// The child is only escalated once its runner is confirmed gone.
|
|
679
|
+
if (childObservation === "owned" && runnerObservation !== "owned") {
|
|
680
|
+
if (!childTermSent) {
|
|
681
|
+
signalOwnedProcessImpl(host.childIdentity, "SIGTERM");
|
|
682
|
+
childTermSent = true;
|
|
683
|
+
childTermAt = elapsed;
|
|
684
|
+
} else if (elapsed - childTermAt >= 1_000 && !childKillSent) {
|
|
685
|
+
signalOwnedProcessImpl(host.childIdentity, "SIGKILL");
|
|
686
|
+
childKillSent = true;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
await sleepFnImpl(HOST_RECOVERY_POLL_MS);
|
|
690
|
+
}
|
|
691
|
+
// Grace exhausted or loop broke — verify both roles are confirmed ended before claiming.
|
|
692
|
+
const finalHost = readHost(root, viewId);
|
|
693
|
+
if (!finalHost || finalHost.instanceId !== expectedInstanceId) return { ok: false, error: "already_recovered" };
|
|
694
|
+
if (!ended(observeHostRole(finalHost, "runner")) || !ended(observeHostRole(finalHost, "child"))) {
|
|
695
|
+
return { ok: false, error: "recovery_pending" };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Claim the replacement under the host-start lease: finalize the old record
|
|
699
|
+
// (clearing its process fields — they are confirmed gone) and claim fresh.
|
|
700
|
+
let claimLease;
|
|
701
|
+
try {
|
|
702
|
+
claimLease = acquireLockImpl(root, viewId, "host-start", { waitMs: HOST_START_LOCK_WAIT_MS, identity: serviceIdentity() });
|
|
703
|
+
} catch {
|
|
704
|
+
return { ok: false, error: "busy_retry_later" };
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
const current = readHost(root, viewId);
|
|
708
|
+
if (!current || current.instanceId !== expectedInstanceId) return { ok: false, error: "already_recovered" };
|
|
709
|
+
const finalized = updateOwnedHost(root, viewId, expectedInstanceId, (h) => ({
|
|
710
|
+
...h,
|
|
711
|
+
state: "exited",
|
|
712
|
+
endedAt: h.endedAt ?? nowImpl(),
|
|
713
|
+
exitCode: h.exitCode ?? 0,
|
|
714
|
+
runnerPid: null,
|
|
715
|
+
runnerIdentity: null,
|
|
716
|
+
runnerSpawnedAt: null,
|
|
717
|
+
childPid: null,
|
|
718
|
+
childIdentity: null,
|
|
719
|
+
childSpawnedAt: null,
|
|
720
|
+
}));
|
|
721
|
+
if (!finalized.updated) return { ok: false, error: "owner_changed" };
|
|
722
|
+
const instanceId = randomIdImpl();
|
|
723
|
+
// claimPid stays null: this recovery transaction is COMPLETE once the claim
|
|
724
|
+
// is on disk (nothing of ours is still between claim and spawn — spawning is
|
|
725
|
+
// deliberately the adopter's job). A live claimPid would make ensureHost /
|
|
726
|
+
// the resolver treat the replacement as a launcher mid-transaction and wait
|
|
727
|
+
// out HOST_START_GRACE_MS before adopting, which starves the recovery chain
|
|
728
|
+
// on slow machines (PR #84 A10 CI flake). Null = no launcher to protect:
|
|
729
|
+
// claimerGone holds immediately, so adoption can fire on the next tick.
|
|
730
|
+
const claimed = claimHost(root, {
|
|
731
|
+
viewId,
|
|
732
|
+
instanceId,
|
|
733
|
+
configPath: P.hostConfigPathFor(root, viewId, instanceId),
|
|
734
|
+
socketPath: P.hostEndpointPathFor(process.platform, root, viewId, instanceId),
|
|
735
|
+
claimAt: nowImpl(),
|
|
736
|
+
claimPid: null,
|
|
737
|
+
claimIdentity: null,
|
|
738
|
+
cols: Number(process.env.COLUMNS || 120),
|
|
739
|
+
rows: Number(process.env.LINES || 36),
|
|
740
|
+
});
|
|
741
|
+
if (!claimed.claimed) return { ok: false, error: "claim_lost", socketPath: claimed.host?.socketPath ?? null };
|
|
742
|
+
appendDiagnostic(root, viewId, { source: "service", code: "host_recovered", message: "PTY host recovered after revoke", details: { oldInstanceId: expectedInstanceId, instanceId } });
|
|
743
|
+
return { ok: true, recovered: true, instanceId, socketPath: claimed.host?.socketPath ?? null };
|
|
744
|
+
} finally {
|
|
745
|
+
try { claimLease.release(); } catch { /* best effort */ }
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* ensureHost implementation (issue #70 Task 10 + Task 12 adopt branch).
|
|
751
|
+
* @param {string} viewId
|
|
752
|
+
* @returns {{ ok: boolean, pending?: boolean, started?: boolean, socketPath?: string|null, instanceId?: string|null, error?: string, fallbackReason?: string }}
|
|
753
|
+
*/
|
|
754
|
+
function ensureHostImpl(viewId) {
|
|
755
|
+
const row = loadRow(root, viewId);
|
|
756
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
757
|
+
const host = row.host ?? null;
|
|
758
|
+
// Adopt-and-spawn (issue #70 Task 12): a `starting` claim whose runner was never
|
|
759
|
+
// spawned (the launcher crashed between claim and spawn, or recoverHost claimed
|
|
760
|
+
// a replacement without spawning) would pend forever. Take it over ONLY when the
|
|
761
|
+
// claim is provably stale or its claimer is gone. Recovery-created claims carry
|
|
762
|
+
// claimPid:null (their transaction is complete; spawning is the adopter's job),
|
|
763
|
+
// so claimerGone holds immediately and they adopt without a grace wait. Fresh
|
|
764
|
+
// claims with a live claimPid stay untouched: their launcher may legitimately be
|
|
765
|
+
// between claim and spawn inside the grace window.
|
|
766
|
+
if (host?.state === "starting" && host.runnerSpawnedAt == null && host.instanceId != null) {
|
|
767
|
+
const claimStale = nowImpl() - (host.claimAt ?? 0) > HOST_START_GRACE_MS;
|
|
768
|
+
const claimerGone = !isAlive(host.claimPid ?? null);
|
|
769
|
+
if (claimStale || claimerGone) return adoptClaimedHost(viewId, row.meta, host);
|
|
770
|
+
}
|
|
771
|
+
if (row.hostActive) {
|
|
772
|
+
return { ok: true, pending: true, socketPath: row.host?.socketPath ?? null, instanceId: row.host?.instanceId ?? null };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Default probe semantics: success is cached for the process lifetime and a
|
|
776
|
+
// failed probe retries on a short TTL. Forcing refresh here would spawn a
|
|
777
|
+
// probe process on every keypress-driven prewarm when PTY support is broken.
|
|
778
|
+
const pty = ptySupport();
|
|
779
|
+
if (!pty.ok) return { ok: false, error: "PTY unavailable", fallbackReason: nodePtyFallbackMessage(pty) };
|
|
780
|
+
if (isAgentBusy(row)) return { ok: false, error: "A non-live background run is active for this session" };
|
|
781
|
+
if (!existsSync(row.meta.sessionFile)) return { ok: false, error: "Session file isn't ready yet" };
|
|
782
|
+
|
|
783
|
+
const lock = tryAcquireLockImpl(root, viewId, "host-start", { identity: serviceIdentity() });
|
|
784
|
+
if (!lock.acquired) {
|
|
785
|
+
// Someone else (another board/service process) is mid-launch — its claim
|
|
786
|
+
// will appear on disk; treat that as an in-flight start, not a failure.
|
|
787
|
+
return { ok: true, pending: true, socketPath: null, instanceId: null };
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
const inner = readHost(root, viewId);
|
|
791
|
+
if (inner && (inner.state === "starting" || inner.state === "alive" || inner.state === "stopping")) {
|
|
792
|
+
return { ok: true, pending: true, socketPath: inner.socketPath ?? null, instanceId: inner.instanceId ?? null };
|
|
793
|
+
}
|
|
794
|
+
const res = startHostUnderLease(row.meta, null, { markQueued: false });
|
|
795
|
+
if (res.ok && res.status === "started") pruneWarmHosts({ keepViewId: viewId });
|
|
796
|
+
return res.ok
|
|
797
|
+
? { ok: true, started: res.status === "started", pending: res.status !== "started", socketPath: res.socketPath, instanceId: res.instanceId }
|
|
798
|
+
: res;
|
|
799
|
+
} finally {
|
|
800
|
+
try { lock.lease.release(); } catch { /* best effort */ }
|
|
420
801
|
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Finish an abandoned host claim: spawn its runner under the `host-start` lease
|
|
806
|
+
* (issue #70 Task 12). The claim's config was never written (the claimer died
|
|
807
|
+
* before spawn, or recoverHost claims without writing one — Task 11 rider a), so
|
|
808
|
+
* it is rebuilt from the view meta here. A failed spawn marks the claim failed
|
|
809
|
+
* fenced (clearing the dead claimer's fields) so the next ensure can claim anew.
|
|
810
|
+
* @param {string} viewId
|
|
811
|
+
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
812
|
+
* @param {import("../core/types.mjs").HostStatus} host
|
|
813
|
+
*/
|
|
814
|
+
function adoptClaimedHost(viewId, meta, host) {
|
|
815
|
+
const instanceId = host.instanceId;
|
|
816
|
+
const lock = tryAcquireLockImpl(root, viewId, "host-start", { identity: serviceIdentity() });
|
|
817
|
+
if (!lock.acquired) {
|
|
818
|
+
// Another adopter/spawner is mid-transaction — its result lands on disk.
|
|
819
|
+
return { ok: true, pending: true, socketPath: host.socketPath ?? null, instanceId };
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
const current = readHost(root, viewId);
|
|
823
|
+
if (!current || current.instanceId !== instanceId || current.state !== "starting" || current.runnerSpawnedAt != null) {
|
|
824
|
+
// The record moved on (adopted elsewhere, revoked, or replaced) — surface it.
|
|
825
|
+
return { ok: true, pending: true, socketPath: current?.socketPath ?? host.socketPath ?? null, instanceId: current?.instanceId ?? instanceId };
|
|
826
|
+
}
|
|
827
|
+
// Stale-model guard (issue #90): the claim is an abandoned record, so mark
|
|
828
|
+
// it failed fenced (same shape as the spawn-failure path below — the next
|
|
829
|
+
// ensure can claim anew) instead of spawning a child doomed to exit 1.
|
|
830
|
+
const modelError = validateViewModelMeta(meta);
|
|
831
|
+
if (modelError) {
|
|
832
|
+
updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: modelError, claimPid: null, claimIdentity: null }));
|
|
833
|
+
return { ok: true, pending: true, socketPath: null, instanceId };
|
|
834
|
+
}
|
|
835
|
+
const configPath = current.configPath ?? P.hostConfigPathFor(root, viewId, instanceId);
|
|
836
|
+
const socketPath = current.socketPath ?? P.hostEndpointPathFor(process.platform, root, viewId, instanceId);
|
|
837
|
+
/** @type {import("../core/types.mjs").HostConfig} */
|
|
838
|
+
const config = {
|
|
839
|
+
root,
|
|
840
|
+
viewId,
|
|
841
|
+
sessionFile: meta.sessionFile,
|
|
842
|
+
cwd: meta.cwd,
|
|
843
|
+
initialPrompt: null,
|
|
844
|
+
piCommand: opts.piCommand,
|
|
845
|
+
piArgsPrefix: opts.piArgsPrefix,
|
|
846
|
+
model: meta.defaultModel ?? null,
|
|
847
|
+
thinkingLevel: meta.defaultThinking ?? null,
|
|
848
|
+
tools: null,
|
|
849
|
+
env: {},
|
|
850
|
+
cols: current.cols || 120,
|
|
851
|
+
rows: current.rows || 36,
|
|
852
|
+
screenLogMaxBytes: normalizeScreenLogMaxBytes(readLaunchPrefs(root).screenLogMaxSize),
|
|
853
|
+
instanceId,
|
|
854
|
+
configPath,
|
|
855
|
+
socketPath,
|
|
856
|
+
};
|
|
857
|
+
if (!existsSync(configPath)) atomicWriteJson(configPath, config);
|
|
858
|
+
let pid = null;
|
|
859
|
+
let spawnError = null;
|
|
860
|
+
try {
|
|
861
|
+
({ pid } = launchHostImpl(root, config, { runnerScript: ptyRunnerScript }));
|
|
862
|
+
} catch (err) {
|
|
863
|
+
spawnError = err instanceof Error ? err.message : String(err);
|
|
864
|
+
}
|
|
865
|
+
if (pid == null) {
|
|
866
|
+
const message = spawnError ?? "PTY host runner failed to spawn (adopted claim)";
|
|
867
|
+
// Clear the dead claimer's fields so canReplaceHost sees the claim as
|
|
868
|
+
// ended — a retry (fresh ensure) must not pend on a gone claimer pid.
|
|
869
|
+
updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: message, claimPid: null, claimIdentity: null }));
|
|
870
|
+
removeFile(configPath);
|
|
871
|
+
return { ok: true, pending: true, socketPath: null, instanceId };
|
|
872
|
+
}
|
|
873
|
+
updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, runnerPid: pid, runnerSpawnedAt: nowImpl() }));
|
|
874
|
+
appendDiagnostic(root, viewId, { source: "service", code: "host_adopted", message: "Abandoned host claim adopted; runner spawned", details: { pid, instanceId } });
|
|
875
|
+
return { ok: true, started: true, socketPath, instanceId };
|
|
876
|
+
} finally {
|
|
877
|
+
try { lock.lease.release(); } catch { /* best effort */ }
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* The single attach authority (issue #70 Task 12): resolves a view to a PTY
|
|
883
|
+
* target via REAL socket probes, waiting out cold starts, adopting abandoned
|
|
884
|
+
* claims, and running one bounded recovery per invocation. Never downgrades to
|
|
885
|
+
* `session` while a host claim is active. All waits go through sleepFn — the
|
|
886
|
+
* resolver runs on the UI thread and must never block on a lock.
|
|
887
|
+
* @param {string} viewId
|
|
888
|
+
* @param {number} timeoutMs
|
|
889
|
+
* @returns {Promise<{kind:"pty",socketPath:string|null,sessionFile:string,instanceId:string|null}|{kind:"session",sessionFile:string}|{kind:"pending",sessionFile:string,reason:string}|{kind:"missing"}>}
|
|
890
|
+
*/
|
|
891
|
+
async function resolveAttachTargetInner(viewId, timeoutMs) {
|
|
892
|
+
const deadline = nowImpl() + timeoutMs;
|
|
893
|
+
let ensured = false;
|
|
894
|
+
let recovered = false;
|
|
895
|
+
// Last adopt-and-spawn attempt (issue #70 CI wave 2): adoption is retried
|
|
896
|
+
// across loop iterations while the stale-claim conditions hold — a single
|
|
897
|
+
// failed/contended attempt must not pend the resolver to its deadline. The
|
|
898
|
+
// host-start lease inside adoptClaimedHost serializes attempts (idempotent:
|
|
899
|
+
// a successful adoption flips runnerSpawnedAt and the conditions stop
|
|
900
|
+
// holding), and this timestamp rate-limits post-adopt re-ensures.
|
|
901
|
+
let lastAdoptAt = 0;
|
|
902
|
+
/** @param {string} reason @param {string} sessionFile */
|
|
903
|
+
const pending = (sessionFile, reason) => ({ kind: "pending", sessionFile, reason });
|
|
904
|
+
for (;;) {
|
|
905
|
+
const row = loadRow(root, viewId);
|
|
906
|
+
if (!row) return { kind: "missing" };
|
|
907
|
+
const sessionFile = row.meta.sessionFile;
|
|
908
|
+
if (nowImpl() >= deadline) return pending(sessionFile, "host start timed out");
|
|
909
|
+
const host = row.host ?? null;
|
|
910
|
+
|
|
911
|
+
// No active claim: live JSON runners pend (no parallel PTY child); otherwise
|
|
912
|
+
// ensure exactly one host start per invocation, then fall through to probing.
|
|
913
|
+
if (!host || !row.hostActive) {
|
|
914
|
+
if (isAgentBusy(row)) return pending(sessionFile, "background run active");
|
|
915
|
+
// A claim that turned terminal AFTER our adopt attempt (spawn failed —
|
|
916
|
+
// adoptClaimedHost marks it failed fenced) may be re-claimed, at most
|
|
917
|
+
// once per grace window: the fresh claim's own grace period spaces the
|
|
918
|
+
// attempts out and the resolver deadline bounds the total, preserving the
|
|
919
|
+
// once-per-invocation ensure guarantee as a rate limit rather than a
|
|
920
|
+
// pend-forever on one bad attempt.
|
|
921
|
+
if (
|
|
922
|
+
(host?.state === "exited" || host?.state === "failed") &&
|
|
923
|
+
lastAdoptAt > 0 &&
|
|
924
|
+
nowImpl() - lastAdoptAt >= HOST_START_GRACE_MS
|
|
925
|
+
) {
|
|
926
|
+
ensured = false;
|
|
927
|
+
}
|
|
928
|
+
if (!ensured) {
|
|
929
|
+
ensured = true;
|
|
930
|
+
const res = ensureHostImpl(viewId);
|
|
931
|
+
if (!res.ok) {
|
|
932
|
+
if (res.fallbackReason != null) {
|
|
933
|
+
// PTY unavailable and nothing active — safe to fall back to session attach.
|
|
934
|
+
const fresh = loadRow(root, viewId);
|
|
935
|
+
return fresh && !fresh.hostActive ? { kind: "session", sessionFile: fresh.meta.sessionFile } : pending(sessionFile, res.error ?? "host ensure failed");
|
|
936
|
+
}
|
|
937
|
+
return pending(sessionFile, res.error ?? "host ensure failed");
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// A revoke is in flight — wait it out; never revoke twice. But a stale
|
|
945
|
+
// `stopping` record whose processes are provably gone is a stranded
|
|
946
|
+
// deadlock (SIGKILLed runner never finalizes; nothing else covers stopping)
|
|
947
|
+
// — finalize it through the same bounded recovery path (CR r1 f2).
|
|
948
|
+
if (host.state === "stopping") {
|
|
949
|
+
const staleStop = host.instanceId != null && nowImpl() - (host.stopRequestedAt ?? host.claimAt ?? 0) >= HOST_RECOVERY_GRACE_MS;
|
|
950
|
+
if (staleStop && !recovered) {
|
|
951
|
+
const runnerObs = observeHostRole(host, "runner");
|
|
952
|
+
const childObs = observeHostRole(host, "child");
|
|
953
|
+
if (runnerObs === "unknown" || childObs === "unknown") {
|
|
954
|
+
// Unverifiable processes on a stranded record — never force.
|
|
955
|
+
return pending(sessionFile, "recovery_pending: host identity unverifiable");
|
|
956
|
+
}
|
|
957
|
+
if (hostRoleEnded(runnerObs) && hostRoleEnded(childObs)) {
|
|
958
|
+
recovered = true;
|
|
959
|
+
try {
|
|
960
|
+
const rec = await recoverHost(viewId, /** @type {string} */ (host.instanceId));
|
|
961
|
+
if (!rec.ok && rec.error === "recovery_pending") {
|
|
962
|
+
return pending(sessionFile, "recovery_pending: host identity unverifiable");
|
|
963
|
+
}
|
|
964
|
+
} catch {
|
|
965
|
+
// recoverHost rethrows non-ESRCH signal failures — surface, don't crash.
|
|
966
|
+
}
|
|
967
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const legacy = host.instanceId == null;
|
|
976
|
+
const withinGrace = host.state === "starting" && (legacy || (host.claimPid != null && nowImpl() - (host.claimAt ?? 0) < HOST_START_GRACE_MS));
|
|
977
|
+
const probe = await probeHostImpl(host.socketPath ?? "", { expectedViewId: viewId, expectedInstanceId: host.instanceId ?? null });
|
|
978
|
+
if (probe.classification === "ready") {
|
|
979
|
+
return { kind: "pty", socketPath: host.socketPath ?? null, sessionFile, instanceId: host.instanceId ?? null };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (host.state === "starting") {
|
|
983
|
+
// Gap-closer: a claim whose runner was never spawned (recovery claim or an
|
|
984
|
+
// abandoned launch) is adopted past grace. Recovery-created claims carry
|
|
985
|
+
// claimPid:null — no launcher to protect — so they are NOT within grace and
|
|
986
|
+
// adopt immediately; claims with a live claimPid wait out the window like
|
|
987
|
+
// any launcher mid-transaction (contract §6).
|
|
988
|
+
// Retried per iteration: a contended or failed attempt must not pend
|
|
989
|
+
// the resolver to its deadline (issue #70 CI wave 2).
|
|
990
|
+
if (!legacy && host.runnerSpawnedAt == null && probe.classification === "missing" && !withinGrace) {
|
|
991
|
+
lastAdoptAt = nowImpl();
|
|
992
|
+
ensureHostImpl(viewId);
|
|
993
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
if (withinGrace || legacy) {
|
|
997
|
+
// A legacy starting host whose runner is provably dead would wait out
|
|
998
|
+
// the grace window forever (withinGrace is always true for legacy) —
|
|
999
|
+
// finalize it now instead (issue #87).
|
|
1000
|
+
if (legacy && finalizeDeadLegacyHost(root, viewId, host, probe.classification, nowImpl(), tryAcquireLockImpl)) {
|
|
1001
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
// Normal cold start (or a legacy starting host — legacy is never recovered,
|
|
1005
|
+
// spec §10.1): wait out the grace window.
|
|
1006
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
} else if (probe.classification === "starting" || probe.classification === "occupied") {
|
|
1010
|
+
// Alive record, mid-transition or a foreign listener — wait, don't touch.
|
|
1011
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1012
|
+
continue;
|
|
1013
|
+
} else if (legacy) {
|
|
1014
|
+
// Legacy host unreachable. spec §10.1 says never recover — unless the
|
|
1015
|
+
// runner pid is provably dead, in which case finalize and self-heal
|
|
1016
|
+
// (issue #87).
|
|
1017
|
+
if (finalizeDeadLegacyHost(root, viewId, host, probe.classification, nowImpl(), tryAcquireLockImpl)) {
|
|
1018
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
return pending(sessionFile, "legacy host unreachable — manual restart needed");
|
|
1022
|
+
}
|
|
421
1023
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
1024
|
+
// Unhealthy new-protocol host past grace: one bounded recovery per invocation.
|
|
1025
|
+
if (!recovered) {
|
|
1026
|
+
recovered = true;
|
|
1027
|
+
try {
|
|
1028
|
+
const rec = await recoverHost(viewId, /** @type {string} */ (host.instanceId));
|
|
1029
|
+
if (!rec.ok && rec.error === "recovery_pending") {
|
|
1030
|
+
return pending(sessionFile, "recovery_pending: host identity unverifiable");
|
|
1031
|
+
}
|
|
1032
|
+
// recovered / busy_retry_later / already_recovered — loop and re-evaluate.
|
|
1033
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1034
|
+
continue;
|
|
1035
|
+
} catch {
|
|
1036
|
+
// recoverHost rethrows non-ESRCH signal failures (Task 11 rider b) — never
|
|
1037
|
+
// guess at process state; surface as pending for the next attempt.
|
|
1038
|
+
return pending(sessionFile, "recovery signal failed");
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1042
|
+
}
|
|
430
1043
|
}
|
|
431
1044
|
|
|
432
1045
|
/** @param {import("../core/types.mjs").FollowUpKind|import("../core/types.mjs").RunKind|string} kind */
|
|
@@ -467,8 +1080,14 @@ export function createService(opts) {
|
|
|
467
1080
|
}
|
|
468
1081
|
}
|
|
469
1082
|
|
|
470
|
-
/**
|
|
471
|
-
|
|
1083
|
+
/**
|
|
1084
|
+
* Claim the next queued follow-up and deliver it. Host-path delivery is
|
|
1085
|
+
* async: the item is only completed on input_ack; any failure releases it
|
|
1086
|
+
* back to queued for a later retry (issue #70 A13).
|
|
1087
|
+
* @param {string} viewId
|
|
1088
|
+
* @returns {Promise<{ ok: boolean, error?: string, sent?: boolean, started?: boolean, pending?: boolean, item?: any }>}
|
|
1089
|
+
*/
|
|
1090
|
+
async function drainNextFollowUp(viewId) {
|
|
472
1091
|
const row = loadRow(root, viewId);
|
|
473
1092
|
if (!row) return { ok: false, error: "Unknown session" };
|
|
474
1093
|
if (!canAutoDrain(row)) return { ok: false, error: "Session is not ready to drain queued follow-ups" };
|
|
@@ -478,29 +1097,64 @@ export function createService(opts) {
|
|
|
478
1097
|
const prompt = promptForFollowUp(viewId, item);
|
|
479
1098
|
try {
|
|
480
1099
|
if (item.kind === "plan_approval") markExecutingApprovedPlan(root, viewId);
|
|
481
|
-
if (row.
|
|
482
|
-
const
|
|
483
|
-
|
|
1100
|
+
if (row.hostActive) {
|
|
1101
|
+
const socketPath = row.host?.socketPath;
|
|
1102
|
+
// Legacy host (pre-upgrade runner, no instanceId): it never sends
|
|
1103
|
+
// input_ack, so the ack path would loop forever. Deliver with the
|
|
1104
|
+
// old fire-and-forget semantics and complete on connect (final
|
|
1105
|
+
// review finding 1).
|
|
1106
|
+
if (row.host?.instanceId == null) {
|
|
1107
|
+
const sent = sendHostMessage(row, { type: "input", data: `${prompt}\r` });
|
|
1108
|
+
if (sent.ok) {
|
|
1109
|
+
completeFollowUp(root, viewId, item.id);
|
|
1110
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind: item.kind, legacy: true } });
|
|
1111
|
+
return { ok: true, sent: true, item };
|
|
1112
|
+
}
|
|
484
1113
|
releaseFollowUp(root, viewId, item.id);
|
|
485
|
-
|
|
1114
|
+
appendDiagnostic(root, viewId, { source: "queue", level: "warn", code: "follow_up_send_failed", message: "Queued follow-up could not be delivered to the host", details: { kind: item.kind, error: sent.error ?? "unknown", legacy: true } });
|
|
1115
|
+
return { ok: true, pending: true, item };
|
|
486
1116
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
1117
|
+
const sent = socketPath
|
|
1118
|
+
? await sendHostInput(socketPath, `${prompt}\r`, { requestId: item.id })
|
|
1119
|
+
: { ok: false, error: "No host socket", retryable: true };
|
|
1120
|
+
if (sent.ok) {
|
|
1121
|
+
completeFollowUp(root, viewId, item.id);
|
|
1122
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind: item.kind } });
|
|
1123
|
+
return { ok: true, sent: true, item };
|
|
1124
|
+
}
|
|
1125
|
+
releaseFollowUp(root, viewId, item.id);
|
|
1126
|
+
appendDiagnostic(root, viewId, { source: "queue", level: "warn", code: "follow_up_send_failed", message: "Queued follow-up could not be delivered to the host", details: { kind: item.kind, error: sent.error ?? "unknown", retryable: sent.retryable !== false } });
|
|
1127
|
+
return { ok: true, pending: true, item };
|
|
490
1128
|
}
|
|
491
|
-
|
|
1129
|
+
// Poll-path drain (reconcile): cached probe semantics — success cached for
|
|
1130
|
+
// process lifetime, failures retried on a 2s TTL; never force a real
|
|
1131
|
+
// pty.spawn on every 700ms cycle when a follow-up keeps failing to start.
|
|
1132
|
+
const pty = ptySupport();
|
|
492
1133
|
let runId = null;
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
1134
|
+
let hostMode = "pty";
|
|
1135
|
+
if (!pty.ok) hostMode = "json-runner";
|
|
1136
|
+
if (pty.ok) {
|
|
1137
|
+
const launched = launchHost(row.meta, prompt);
|
|
1138
|
+
if (launched.ok && launched.status !== "started") {
|
|
1139
|
+
// An existing claim owns the view; the item stays queued and retries
|
|
1140
|
+
// after that host becomes ready (issue #70 prompt-not-lost invariant).
|
|
496
1141
|
releaseFollowUp(root, viewId, item.id);
|
|
497
|
-
|
|
498
|
-
return { ok: false, error: "PTY is required to drain adopted session follow-ups safely" };
|
|
1142
|
+
return { ok: true, pending: true };
|
|
499
1143
|
}
|
|
1144
|
+
if (!launched.ok) hostMode = "json-runner";
|
|
1145
|
+
}
|
|
1146
|
+
if (hostMode === "json-runner" && isExternalSession(row.meta)) {
|
|
1147
|
+
// Adopted (external) sessions must never continue via the json runner —
|
|
1148
|
+
// only a PTY host can resume them safely (pre-existing guard).
|
|
1149
|
+
releaseFollowUp(root, viewId, item.id);
|
|
1150
|
+
appendDiagnostic(root, viewId, { source: "queue", level: "warn", code: "follow_up_waiting_for_pty", message: "Adopted session follow-up is waiting for PTY support", details: {} });
|
|
1151
|
+
return { ok: false, error: "PTY is required to drain adopted session follow-ups safely" };
|
|
1152
|
+
}
|
|
1153
|
+
if (hostMode === "json-runner") {
|
|
500
1154
|
runId = launchForView(row.meta, prompt, runKindForFollowUp(item)).runId;
|
|
501
1155
|
}
|
|
502
1156
|
completeFollowUp(root, viewId, item.id, { runId });
|
|
503
|
-
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_started", message: "Queued follow-up started", details: { kind: item.kind, hostMode
|
|
1157
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_started", message: "Queued follow-up started", details: { kind: item.kind, hostMode } });
|
|
504
1158
|
return { ok: true, started: true, item };
|
|
505
1159
|
} catch (err) {
|
|
506
1160
|
releaseFollowUp(root, viewId, item.id);
|
|
@@ -553,7 +1207,10 @@ export function createService(opts) {
|
|
|
553
1207
|
updateCodeRefsFromEvidence(root, row.meta.id, evidence, row.meta);
|
|
554
1208
|
writeForegroundState(row, status);
|
|
555
1209
|
pruneWarmHosts({ keepViewId: row.meta.id });
|
|
556
|
-
|
|
1210
|
+
// Async delivery (ack-gated, issue #70 A13): fire-and-forget here — the
|
|
1211
|
+
// queue item's own state records the outcome, ordering is preserved by
|
|
1212
|
+
// the queue lock, and requestId dedup makes late retries harmless.
|
|
1213
|
+
void drainNextFollowUp(row.meta.id);
|
|
557
1214
|
return true;
|
|
558
1215
|
}
|
|
559
1216
|
|
|
@@ -609,24 +1266,48 @@ export function createService(opts) {
|
|
|
609
1266
|
writeCapable,
|
|
610
1267
|
});
|
|
611
1268
|
const pty = ptySupport({ refresh: true });
|
|
612
|
-
|
|
613
|
-
|
|
1269
|
+
let hostMode = "json-runner";
|
|
1270
|
+
let fallbackReason;
|
|
1271
|
+
let queued;
|
|
1272
|
+
if (pty.ok) {
|
|
1273
|
+
const launched = launchHost(meta, prompt);
|
|
1274
|
+
if (launched.ok) {
|
|
1275
|
+
hostMode = "pty";
|
|
1276
|
+
if (launched.status !== "started") {
|
|
1277
|
+
// An existing claim owns the prompt now; keep it durably queued
|
|
1278
|
+
// instead of dropping it (issue #70 prompt-not-lost invariant).
|
|
1279
|
+
const q = enqueueFollowUp(root, id, prompt, { kind: "reply", delivery: "auto", source: "user" });
|
|
1280
|
+
if (!q.ok) return q;
|
|
1281
|
+
queued = true;
|
|
1282
|
+
}
|
|
1283
|
+
} else {
|
|
1284
|
+
fallbackReason = launched.fallbackReason ?? launched.error;
|
|
1285
|
+
launchForView(meta, prompt, "dispatch");
|
|
1286
|
+
}
|
|
1287
|
+
} else {
|
|
1288
|
+
fallbackReason = nodePtyFallbackMessage(pty);
|
|
1289
|
+
launchForView(meta, prompt, "dispatch");
|
|
1290
|
+
}
|
|
614
1291
|
queueGeneratedTitle(meta, prompt);
|
|
615
1292
|
return {
|
|
616
1293
|
ok: true,
|
|
617
1294
|
viewId: id,
|
|
618
|
-
hostMode
|
|
619
|
-
|
|
1295
|
+
hostMode,
|
|
1296
|
+
...(queued ? { queued: true } : {}),
|
|
1297
|
+
...(hostMode === "json-runner" ? { fallbackReason } : {}),
|
|
620
1298
|
};
|
|
621
1299
|
},
|
|
622
1300
|
|
|
623
1301
|
/**
|
|
624
|
-
* Append a reply to an existing session
|
|
1302
|
+
* Append a reply to an existing session. Host-path delivery is
|
|
1303
|
+
* ack-gated: the prompt is first persisted as a follow-up item, then
|
|
1304
|
+
* attempted over the control socket; only input_ack completes the item,
|
|
1305
|
+
* anything else leaves it queued for the drain loop (issue #70 A13).
|
|
625
1306
|
* @param {string} viewId
|
|
626
1307
|
* @param {string} text
|
|
627
|
-
* @returns {{ ok: boolean, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }}
|
|
1308
|
+
* @returns {Promise<{ ok: boolean, error?: string, queued?: boolean, sent?: boolean, summary?: any, hostMode?: "pty"|"json-runner", fallbackReason?: string }>}
|
|
628
1309
|
*/
|
|
629
|
-
reply(viewId, text, replyOpts = {}) {
|
|
1310
|
+
async reply(viewId, text, replyOpts = {}) {
|
|
630
1311
|
const prompt = String(text || "").trim();
|
|
631
1312
|
if (!prompt) return { ok: false, error: "Empty reply" };
|
|
632
1313
|
const row = loadRow(root, viewId);
|
|
@@ -639,15 +1320,66 @@ export function createService(opts) {
|
|
|
639
1320
|
if (queued.ok) appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_queued", message: "Follow-up queued", details: { kind, queuedCount: queued.summary?.queuedCount } });
|
|
640
1321
|
return queued.ok ? { ok: true, queued: true, summary: queued.summary } : queued;
|
|
641
1322
|
}
|
|
642
|
-
if (row.
|
|
1323
|
+
if (row.hostActive) {
|
|
1324
|
+
// Durable queue-first delivery: the item id is the requestId, so a
|
|
1325
|
+
// lost ack after a child write de-dupes on retry instead of duplicating.
|
|
1326
|
+
const queued = enqueueFollowUp(root, viewId, prompt, { kind, delivery: "auto", source: "user" });
|
|
1327
|
+
if (!queued.ok) return queued;
|
|
1328
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_queued", message: "Follow-up queued", details: { kind, queuedCount: queued.summary?.queuedCount } });
|
|
1329
|
+
// Legacy host: no instanceId → no input_ack → fire-and-forget (final
|
|
1330
|
+
// review finding 1); connect success completes the item.
|
|
1331
|
+
if (row.host?.instanceId == null) {
|
|
1332
|
+
const sent = sendHostMessage(row, { type: "input", data: `${prompt}\r` });
|
|
1333
|
+
if (sent.ok) {
|
|
1334
|
+
completeFollowUp(root, viewId, queued.item.id);
|
|
1335
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind, legacy: true } });
|
|
1336
|
+
return { ok: true, sent: true, item: queued.item };
|
|
1337
|
+
}
|
|
1338
|
+
return { ok: true, queued: true, summary: queued.summary };
|
|
1339
|
+
}
|
|
1340
|
+
const socketPath = row.host?.socketPath;
|
|
1341
|
+
const sent = socketPath
|
|
1342
|
+
? await sendHostInput(socketPath, `${prompt}\r`, { requestId: queued.item.id })
|
|
1343
|
+
: { ok: false, error: "No host socket", retryable: true };
|
|
1344
|
+
if (sent.ok) {
|
|
1345
|
+
completeFollowUp(root, viewId, queued.item.id);
|
|
1346
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind } });
|
|
1347
|
+
return { ok: true, sent: true, item: queued.item };
|
|
1348
|
+
}
|
|
1349
|
+
// Not an error: starting host, busy socket or lost ack — the drain
|
|
1350
|
+
// loop retries once the host acks (issue #70 prompt-not-lost).
|
|
1351
|
+
return { ok: true, queued: true, summary: queued.summary };
|
|
1352
|
+
}
|
|
643
1353
|
if (row.alive) return { ok: false, error: "A run is already active for this session" };
|
|
644
1354
|
const pty = ptySupport({ refresh: true });
|
|
645
|
-
|
|
646
|
-
|
|
1355
|
+
let hostMode = null;
|
|
1356
|
+
let fallbackReason;
|
|
1357
|
+
if (pty.ok) {
|
|
1358
|
+
const launched = launchHost(row.meta, prompt);
|
|
1359
|
+
if (launched.ok) {
|
|
1360
|
+
if (launched.status !== "started") {
|
|
1361
|
+
// The existing claim will consume the prompt later — keep it queued
|
|
1362
|
+
// instead of dropping it (issue #70 prompt-not-lost invariant).
|
|
1363
|
+
const q = enqueueFollowUp(root, viewId, prompt, { kind, delivery: "auto", source: "user" });
|
|
1364
|
+
if (q.ok) {
|
|
1365
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_queued", message: "Follow-up queued", details: { kind, queuedCount: q.summary?.queuedCount } });
|
|
1366
|
+
return { ok: true, queued: true, summary: q.summary };
|
|
1367
|
+
}
|
|
1368
|
+
return q;
|
|
1369
|
+
}
|
|
1370
|
+
hostMode = "pty";
|
|
1371
|
+
} else {
|
|
1372
|
+
fallbackReason = launched.fallbackReason ?? launched.error;
|
|
1373
|
+
}
|
|
1374
|
+
} else {
|
|
1375
|
+
fallbackReason = nodePtyFallbackMessage(pty);
|
|
1376
|
+
}
|
|
1377
|
+
if (!hostMode) {
|
|
647
1378
|
if (isExternalSession(row.meta)) return { ok: false, error: "PTY is required to continue an adopted foreground session safely" };
|
|
648
1379
|
launchForView(row.meta, prompt, runKindForKind(kind));
|
|
1380
|
+
hostMode = "json-runner";
|
|
649
1381
|
}
|
|
650
|
-
return { ok: true, hostMode
|
|
1382
|
+
return { ok: true, hostMode, ...(hostMode === "json-runner" ? { fallbackReason } : {}) };
|
|
651
1383
|
},
|
|
652
1384
|
|
|
653
1385
|
/**
|
|
@@ -657,7 +1389,11 @@ export function createService(opts) {
|
|
|
657
1389
|
*/
|
|
658
1390
|
stop(viewId) {
|
|
659
1391
|
const row = loadRow(root, viewId);
|
|
660
|
-
if (row?.
|
|
1392
|
+
if (row?.hostActive) {
|
|
1393
|
+
// Interrupt is turn-abort, not host-stop; an already-stopping host has nothing to abort.
|
|
1394
|
+
if (row.host?.state === "stopping") return { ok: true };
|
|
1395
|
+
return sendHostMessage(row, { type: "interrupt" });
|
|
1396
|
+
}
|
|
661
1397
|
const state = readState(root, viewId);
|
|
662
1398
|
if (!state?.currentRunId) return { ok: false, error: "No active run" };
|
|
663
1399
|
const pid = readPid(root, viewId, state.currentRunId);
|
|
@@ -669,34 +1405,58 @@ export function createService(opts) {
|
|
|
669
1405
|
/** @param {string} viewId */
|
|
670
1406
|
terminateHost(viewId) {
|
|
671
1407
|
const row = loadRow(root, viewId);
|
|
672
|
-
if (!row?.
|
|
1408
|
+
if (!row?.host) return { ok: false, error: "No live host" };
|
|
1409
|
+
if (row.host.instanceId) return requestHostStop(viewId, row.host.instanceId, "user");
|
|
1410
|
+
if (!row.hostAlive) return { ok: false, error: "No live host" };
|
|
673
1411
|
return sendHostMessage(row, { type: "terminate" });
|
|
674
1412
|
},
|
|
675
1413
|
|
|
676
1414
|
/**
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
* @param {string} viewId
|
|
680
|
-
* @returns {{ ok: boolean, socketPath?: string, started?: boolean, error?: string, fallbackReason?: string }}
|
|
1415
|
+
* File-based host revoke (issue #70). See the closure doc above; exposed for
|
|
1416
|
+
* the attach resolver and lifecycle callers.
|
|
1417
|
+
* @param {string} viewId @param {string|null} expectedInstanceId @param {string} reason
|
|
681
1418
|
*/
|
|
682
|
-
|
|
683
|
-
const row = loadRow(root, viewId);
|
|
684
|
-
if (!row) return { ok: false, error: "Unknown session" };
|
|
685
|
-
if (row.hostAlive && row.host?.socketPath) return { ok: true, socketPath: row.host.socketPath, started: false };
|
|
1419
|
+
requestHostStop,
|
|
686
1420
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
if (!existsSync(row.meta.sessionFile)) return { ok: false, error: "Session file isn't ready yet" };
|
|
1421
|
+
/**
|
|
1422
|
+
* Revoke + wait + replace a broken host (issue #70). Only the attach
|
|
1423
|
+
* resolver / recovery-aware reconcile should call this.
|
|
1424
|
+
* @param {string} viewId @param {string} expectedInstanceId
|
|
1425
|
+
*/
|
|
1426
|
+
recoverHost,
|
|
694
1427
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1428
|
+
/**
|
|
1429
|
+
* The single async attach authority (issue #70 Task 12): real-probes the
|
|
1430
|
+
* current claim, waits out cold starts, adopts abandoned claims, and runs one
|
|
1431
|
+
* bounded recovery per invocation. Concurrent calls for the same view share
|
|
1432
|
+
* one in-flight promise. `attachTarget()` remains a pure sync hint.
|
|
1433
|
+
* @param {string} viewId
|
|
1434
|
+
* @param {{ timeoutMs?: number }} [resolveOpts]
|
|
1435
|
+
* @returns {Promise<{kind:"pty",socketPath:string|null,sessionFile:string,instanceId:string|null}|{kind:"session",sessionFile:string}|{kind:"pending",sessionFile:string,reason:string}|{kind:"missing"}>}
|
|
1436
|
+
*/
|
|
1437
|
+
async resolveAttachTarget(viewId, resolveOpts = {}) {
|
|
1438
|
+
const existing = inflightAttachResolvers.get(viewId);
|
|
1439
|
+
if (existing) return existing;
|
|
1440
|
+
const run = resolveAttachTargetInner(viewId, resolveOpts.timeoutMs ?? ATTACH_RESOLVE_TIMEOUT_MS)
|
|
1441
|
+
.finally(() => { inflightAttachResolvers.delete(viewId); });
|
|
1442
|
+
inflightAttachResolvers.set(viewId, run);
|
|
1443
|
+
return run;
|
|
698
1444
|
},
|
|
699
1445
|
|
|
1446
|
+
/**
|
|
1447
|
+
* Ensure there is an interactive PTY host for this session. Used for fast attach
|
|
1448
|
+
* and dashboard prewarm. Idempotent (issue #70): an existing active claim — even
|
|
1449
|
+
* a fresh `starting` one with no runner pid yet — is surfaced as `pending`, never
|
|
1450
|
+
* replaced or double-spawned; a provably abandoned claim is adopted and spawned
|
|
1451
|
+
* (Task 12) instead of pending forever. Real socket health is the attach
|
|
1452
|
+
* resolver's job; this path never probes, kills, or spawns past an active claim.
|
|
1453
|
+
* @param {string} viewId
|
|
1454
|
+
* @returns {{ ok: boolean, pending?: boolean, started?: boolean, socketPath?: string|null, instanceId?: string|null, error?: string, fallbackReason?: string }}
|
|
1455
|
+
*/
|
|
1456
|
+
ensureHost(viewId) {
|
|
1457
|
+
return ensureHostImpl(viewId);
|
|
1458
|
+
},
|
|
1459
|
+
|
|
700
1460
|
/** @param {string} viewId */
|
|
701
1461
|
prewarmHost(viewId) {
|
|
702
1462
|
const row = loadRow(root, viewId);
|
|
@@ -842,11 +1602,12 @@ export function createService(opts) {
|
|
|
842
1602
|
return { queue, summary: summarizeFollowUpQueue(queue) };
|
|
843
1603
|
},
|
|
844
1604
|
|
|
1605
|
+
/** Async (ack-gated) since issue #70 A13; see the internal implementation above. @param {string} viewId */
|
|
845
1606
|
drainNextFollowUp(viewId) {
|
|
846
1607
|
return drainNextFollowUp(viewId);
|
|
847
1608
|
},
|
|
848
1609
|
|
|
849
|
-
requestPlan(viewId, text = "") {
|
|
1610
|
+
async requestPlan(viewId, text = "") {
|
|
850
1611
|
const row = loadRow(root, viewId);
|
|
851
1612
|
if (!row) return { ok: false, error: "Unknown session" };
|
|
852
1613
|
requestPlanState(root, viewId, { note: text || null });
|
|
@@ -854,7 +1615,7 @@ export function createService(opts) {
|
|
|
854
1615
|
return this.reply(viewId, buildPlanRequestPrompt(text), { delivery: "now", kind: "plan_request" });
|
|
855
1616
|
},
|
|
856
1617
|
|
|
857
|
-
approvePlan(viewId) {
|
|
1618
|
+
async approvePlan(viewId) {
|
|
858
1619
|
const row = loadRow(root, viewId);
|
|
859
1620
|
const state = readSteering(root, viewId);
|
|
860
1621
|
const approved = approvePlanState(root, viewId);
|
|
@@ -864,7 +1625,7 @@ export function createService(opts) {
|
|
|
864
1625
|
return this.reply(viewId, buildApprovePlanPrompt(state.planText), { delivery: "now", kind: "plan_approval" });
|
|
865
1626
|
},
|
|
866
1627
|
|
|
867
|
-
requestPlanChanges(viewId, feedback) {
|
|
1628
|
+
async requestPlanChanges(viewId, feedback) {
|
|
868
1629
|
const row = loadRow(root, viewId);
|
|
869
1630
|
const state = readSteering(root, viewId);
|
|
870
1631
|
const changed = requestPlanChangesState(root, viewId, feedback);
|
|
@@ -944,7 +1705,8 @@ export function createService(opts) {
|
|
|
944
1705
|
skipped += 1;
|
|
945
1706
|
continue;
|
|
946
1707
|
}
|
|
947
|
-
if (row.
|
|
1708
|
+
if (row.host?.instanceId) stopHostRow(row, "archive");
|
|
1709
|
+
else if (row.hostAlive) sendHostMessage(row, { type: "terminate" });
|
|
948
1710
|
row.meta.archived = true;
|
|
949
1711
|
writeMeta(root, row.meta);
|
|
950
1712
|
archived += 1;
|
|
@@ -966,7 +1728,10 @@ export function createService(opts) {
|
|
|
966
1728
|
const looksActive = s?.processState === "alive" || s?.semanticState === "queued" || s?.semanticState === "working";
|
|
967
1729
|
if (!s || !looksActive) continue;
|
|
968
1730
|
if (!s.currentRunId) {
|
|
969
|
-
|
|
1731
|
+
if (row.host && !row.hostAlive && (row.host.state === "starting" || row.host.state === "alive" || row.host.state === "exited" || row.host.state === "failed")) {
|
|
1732
|
+
// A starting claim inside the launch grace is a normal cold start (issue #70):
|
|
1733
|
+
// its runner pid may legitimately be absent — never finalize it to failed yet.
|
|
1734
|
+
if (row.host.state === "starting" && now - (row.host.claimAt ?? row.host.startedAt ?? 0) < HOST_START_GRACE_MS) continue;
|
|
970
1735
|
const failed = row.host.state === "starting" || row.host.state === "alive" || row.host.state === "failed" || Boolean(row.host.error) || (row.host.exitCode !== null && row.host.exitCode !== 0);
|
|
971
1736
|
s.semanticState = failed ? "failed" : "idle";
|
|
972
1737
|
s.processState = "exited";
|
|
@@ -1001,8 +1766,10 @@ export function createService(opts) {
|
|
|
1001
1766
|
}
|
|
1002
1767
|
for (const row of listRows(root)) {
|
|
1003
1768
|
if ((row.state?.followUps?.queuedCount ?? 0) > 0 && canAutoDrain(row)) {
|
|
1004
|
-
|
|
1005
|
-
|
|
1769
|
+
// Delivery is ack-gated and async now (issue #70 A13); the queue
|
|
1770
|
+
// item state, not this counter, records the outcome.
|
|
1771
|
+
void drainNextFollowUp(row.meta.id);
|
|
1772
|
+
fixed += 1;
|
|
1006
1773
|
}
|
|
1007
1774
|
}
|
|
1008
1775
|
return fixed;
|
|
@@ -1091,18 +1858,17 @@ export function createService(opts) {
|
|
|
1091
1858
|
row(viewId) {
|
|
1092
1859
|
return loadRow(root, viewId);
|
|
1093
1860
|
},
|
|
1094
|
-
};
|
|
1095
|
-
}
|
|
1096
1861
|
|
|
1097
|
-
/**
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1862
|
+
/**
|
|
1863
|
+
* Evict idle warm PTY hosts (TTL / warm-pool cap). Safe to call anytime:
|
|
1864
|
+
* busy and attached hosts are never touched.
|
|
1865
|
+
* @param {{ keepViewId?: string|null }} [pruneOpts]
|
|
1866
|
+
*/
|
|
1867
|
+
pruneWarmHosts(pruneOpts = {}) {
|
|
1868
|
+
// Delegates to the createService-closure pruneWarmHosts (same name; NOT recursive).
|
|
1869
|
+
pruneWarmHosts(pruneOpts);
|
|
1870
|
+
},
|
|
1871
|
+
};
|
|
1106
1872
|
}
|
|
1107
1873
|
|
|
1108
1874
|
/** @param {import("../core/types.mjs").ViewMeta} meta */
|
|
@@ -1136,6 +1902,117 @@ function compactSummary(text) {
|
|
|
1136
1902
|
return truncate(first.length >= 12 ? first : cleaned, 80);
|
|
1137
1903
|
}
|
|
1138
1904
|
|
|
1905
|
+
/** POSIX process start token — /proc/<pid>/stat field 22 (starttime). Mirrors the
|
|
1906
|
+
* runner's captureStartToken (issue #70) so both sides publish comparable
|
|
1907
|
+
* identities; null on failure or non-Linux platforms. Task 11 may promote this
|
|
1908
|
+
* to a shared module when identity-aware observation lands. */
|
|
1909
|
+
function readProcStartToken(pid) {
|
|
1910
|
+
if (process.platform !== "linux" || !pid) return null;
|
|
1911
|
+
try {
|
|
1912
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
1913
|
+
// comm (field 2) may contain spaces and parens — fields resume AFTER the
|
|
1914
|
+
// last ')'. fields[0] is state (field 3) → starttime (field 22) is [19].
|
|
1915
|
+
const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trimStart();
|
|
1916
|
+
return afterComm.split(/\s+/)[19] ?? null;
|
|
1917
|
+
} catch {
|
|
1918
|
+
return null;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/** Launch-time identity this service process stamps on leases and claims. */
|
|
1923
|
+
function serviceIdentity() {
|
|
1924
|
+
return { pid: process.pid, startToken: readProcStartToken(process.pid) };
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* Finalize a provably-dead legacy host as `exited` so the resolver loop's next
|
|
1929
|
+
* iteration sees hostActive=false and claims a fresh new-protocol host
|
|
1930
|
+
* (issue #87). The gate is `canFinalizeLegacyHost` — anything unverifiable
|
|
1931
|
+
* stays pending (spec §10.1 conservatism). The write itself is serialized
|
|
1932
|
+
* under the host-start lease (same gate as recoverHost/claim) with a
|
|
1933
|
+
* live-record re-check: a concurrent resolver may have already finalized and
|
|
1934
|
+
* claimed a new-protocol host, and a stale `alive` snapshot must never clobber
|
|
1935
|
+
* that replacement's record (issue #87 CR r1).
|
|
1936
|
+
* @param {string} root
|
|
1937
|
+
* @param {string} viewId
|
|
1938
|
+
* @param {import("../core/types.mjs").HostStatus} host
|
|
1939
|
+
* @param {string} probeClassification
|
|
1940
|
+
* @param {number} now
|
|
1941
|
+
* @param {(r: string, v: string, name: string, o?: object) => ({ acquired: true, lease: { release: () => void } } | { acquired: false, reason: string })} acquireHostStartLock
|
|
1942
|
+
* @returns {boolean} true when finalized (caller should `continue` the loop).
|
|
1943
|
+
*/
|
|
1944
|
+
function finalizeDeadLegacyHost(root, viewId, host, probeClassification, now, acquireHostStartLock) {
|
|
1945
|
+
// Same pid fallback as loadRow: legacy records carry no runnerPid property —
|
|
1946
|
+
// the pid lives only in the host-pid.json mirror.
|
|
1947
|
+
const hostPid = Object.hasOwn(host, "runnerPid") ? host.runnerPid : readHostPid(root, viewId);
|
|
1948
|
+
if (!canFinalizeLegacyHost({ host, hostPid, hostPidAlive: isAlive(hostPid), probeClassification })) return false;
|
|
1949
|
+
// Serialize with claims: only write while the live record still matches our
|
|
1950
|
+
// (possibly stale) snapshot.
|
|
1951
|
+
const gate = acquireHostStartLock(root, viewId, "host-start", { identity: serviceIdentity() });
|
|
1952
|
+
if (!gate.acquired) return false; // busy — record left untouched; caller pends/waits this attempt, a later one finalizes
|
|
1953
|
+
try {
|
|
1954
|
+
const cur = readHost(root, viewId);
|
|
1955
|
+
if (!cur || cur.instanceId != null) return false; // already claimed by a new-protocol host
|
|
1956
|
+
if (cur.state !== "starting" && cur.state !== "alive") return false; // already finalized
|
|
1957
|
+
if (cur.socketPath !== host.socketPath || cur.state !== host.state) return false; // snapshot drifted
|
|
1958
|
+
const finalizedAt = now;
|
|
1959
|
+
writeHost(root, { ...cur, state: "exited", endedAt: finalizedAt, lastSeenAt: finalizedAt, error: "legacy host finalized: runner pid dead" });
|
|
1960
|
+
} finally {
|
|
1961
|
+
try { gate.lease.release(); } catch { /* best effort */ }
|
|
1962
|
+
}
|
|
1963
|
+
try {
|
|
1964
|
+
appendDiagnostic(root, viewId, {
|
|
1965
|
+
source: "service",
|
|
1966
|
+
level: "info",
|
|
1967
|
+
code: "legacy_host_finalized",
|
|
1968
|
+
message: `Finalized stale legacy host (pid ${hostPid} dead, probe ${probeClassification}) — next attach claims a fresh host`,
|
|
1969
|
+
details: { hostPid, probeClassification, previousState: host.state },
|
|
1970
|
+
});
|
|
1971
|
+
} catch { /* best effort */ }
|
|
1972
|
+
return true;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/** Conservative pre-identity observation (issue #70 Task 10): a recorded pid that
|
|
1976
|
+
* is still alive is always `unknown` — never safe to release; not recorded or
|
|
1977
|
+
* provably gone is `dead`. Task 11 replaces this with identity-aware observation. */
|
|
1978
|
+
function conservativeObservation(pid) {
|
|
1979
|
+
if (pid == null) return "dead";
|
|
1980
|
+
return isAlive(pid) ? "unknown" : "dead";
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/** Identity-aware process observation (issue #70): a live pid whose /proc start
|
|
1984
|
+
* token matches the recorded identity is `owned` (safe to signal); a mismatch is
|
|
1985
|
+
* `foreign` (pid reused — the original is gone); alive-but-unverifiable is
|
|
1986
|
+
* `unknown` (recovery must stay pending, never signal). */
|
|
1987
|
+
function defaultObserveProcess(identity) {
|
|
1988
|
+
if (!identity || identity.pid == null) return "unknown";
|
|
1989
|
+
if (!isAlive(identity.pid)) return "dead";
|
|
1990
|
+
const token = readProcStartToken(identity.pid);
|
|
1991
|
+
if (identity.startToken == null || token == null) return "unknown";
|
|
1992
|
+
return token === identity.startToken ? "owned" : "foreign";
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
/** Signal an identity previously observed as "owned". ESRCH is tolerated (the
|
|
1996
|
+
* process may exit between observation and signal); anything else rethrows. */
|
|
1997
|
+
function defaultSignalOwnedProcess(identity, signal) {
|
|
1998
|
+
try {
|
|
1999
|
+
process.kill(identity.pid, signal);
|
|
2000
|
+
} catch (err) {
|
|
2001
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ESRCH") throw err;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/** @param {import("../core/types.mjs").HostStatus|null} host */
|
|
2006
|
+
function observeHostForReplace(host) {
|
|
2007
|
+
return {
|
|
2008
|
+
host,
|
|
2009
|
+
runnerObservation: conservativeObservation(host?.runnerPid ?? null),
|
|
2010
|
+
childObservation: conservativeObservation(host?.childPid ?? null),
|
|
2011
|
+
claimObservation: conservativeObservation(host?.claimPid ?? null),
|
|
2012
|
+
launchLeaseActive: false,
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
2015
|
+
|
|
1139
2016
|
/**
|
|
1140
2017
|
* Send a one-shot JSONL command to a live host socket.
|
|
1141
2018
|
* @param {import("../core/store.mjs").Row} row
|
|
@@ -1162,6 +2039,92 @@ function sendHostMessage(row, message) {
|
|
|
1162
2039
|
}
|
|
1163
2040
|
}
|
|
1164
2041
|
|
|
2042
|
+
/**
|
|
2043
|
+
* Default window service waits for the runner's input_ack before treating a
|
|
2044
|
+
* host input as undelivered (issue #70 A13).
|
|
2045
|
+
*/
|
|
2046
|
+
const HOST_INPUT_ACK_TIMEOUT_MS = 2_000;
|
|
2047
|
+
|
|
2048
|
+
/**
|
|
2049
|
+
* Send one input line to a host control socket and wait for the runner's
|
|
2050
|
+
* input_ack (issue #70 A13). Fire-and-forget "connected" no longer counts as
|
|
2051
|
+
* delivered: only `{type:"input_ack", requestId}` does.
|
|
2052
|
+
*
|
|
2053
|
+
* Delivery contract is durable at-least-once, coordinated through the queue
|
|
2054
|
+
* item id used as requestId:
|
|
2055
|
+
* - If an ack is lost AFTER the runner wrote the child, a re-send with the
|
|
2056
|
+
* same requestId hits the runner's dedup table and re-acks without a second
|
|
2057
|
+
* child write.
|
|
2058
|
+
* - If the host instance restarted, the new instance never saw the requestId
|
|
2059
|
+
* and writes the prompt once.
|
|
2060
|
+
* Legacy (pre-instanceId) runners ignore requestId and never ack: this helper
|
|
2061
|
+
* then times out and reports a retryable failure, leaving the prompt queued —
|
|
2062
|
+
* legacy hosts are attach-only by spec, so queued prompts surface on attach.
|
|
2063
|
+
* Never rejects: every failure resolves `{ok:false, error, retryable:true}`.
|
|
2064
|
+
* @param {string} socketPath
|
|
2065
|
+
* @param {string} text
|
|
2066
|
+
* @param {{ requestId?: string|null, timeoutMs?: number, connect?: (path: string) => import("node:net").Socket }} [opts]
|
|
2067
|
+
* @returns {Promise<{ ok: boolean, error?: string, retryable?: boolean }>}
|
|
2068
|
+
*/
|
|
2069
|
+
export function sendHostInput(socketPath, text, opts = {}) {
|
|
2070
|
+
const requestId = opts.requestId ?? null;
|
|
2071
|
+
const timeoutMs = opts.timeoutMs ?? HOST_INPUT_ACK_TIMEOUT_MS;
|
|
2072
|
+
const connect = opts.connect ?? createConnection;
|
|
2073
|
+
return new Promise((resolve) => {
|
|
2074
|
+
let settled = false;
|
|
2075
|
+
let buffer = "";
|
|
2076
|
+
/** @type {import("node:net").Socket|null} */
|
|
2077
|
+
let socket = null;
|
|
2078
|
+
const finish = (result) => {
|
|
2079
|
+
if (settled) return;
|
|
2080
|
+
settled = true;
|
|
2081
|
+
clearTimeout(timer);
|
|
2082
|
+
try { socket?.destroy(); } catch { /* best effort */ }
|
|
2083
|
+
resolve(result);
|
|
2084
|
+
};
|
|
2085
|
+
const timer = setTimeout(() => finish({ ok: false, error: "timeout", retryable: true }), timeoutMs);
|
|
2086
|
+
timer.unref?.();
|
|
2087
|
+
try {
|
|
2088
|
+
socket = connect(socketPath);
|
|
2089
|
+
} catch (err) {
|
|
2090
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
socket.on("connect", () => {
|
|
2094
|
+
try {
|
|
2095
|
+
socket.write(JSON.stringify({ type: "input", requestId, data: text }) + "\n");
|
|
2096
|
+
} catch (err) {
|
|
2097
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
|
|
2098
|
+
}
|
|
2099
|
+
});
|
|
2100
|
+
socket.on("data", (chunk) => {
|
|
2101
|
+
buffer += chunk.toString("utf8");
|
|
2102
|
+
const lines = buffer.split("\n");
|
|
2103
|
+
buffer = lines.pop() ?? "";
|
|
2104
|
+
for (const line of lines) {
|
|
2105
|
+
if (!line.trim()) continue;
|
|
2106
|
+
let msg;
|
|
2107
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
2108
|
+
if (msg?.type === "input_ack" && (requestId == null || msg.requestId === requestId)) {
|
|
2109
|
+
finish({ ok: true });
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
if (msg?.type === "error") {
|
|
2113
|
+
// e.g. {type:"error", code:"host_starting", requestId} while the child boots.
|
|
2114
|
+
finish({ ok: false, error: msg.code ?? msg.message ?? "host_error", retryable: true });
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
// hello/status/output lines are ignored — keep waiting for the ack.
|
|
2118
|
+
}
|
|
2119
|
+
});
|
|
2120
|
+
socket.on("error", (err) => {
|
|
2121
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
2122
|
+
finish({ ok: false, error: code ?? (err instanceof Error ? err.message : String(err)), retryable: true });
|
|
2123
|
+
});
|
|
2124
|
+
socket.on("close", () => finish({ ok: false, error: "socket_closed", retryable: true }));
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
|
|
1165
2128
|
let cachedPtySupport;
|
|
1166
2129
|
const requireForPty = createRequire(import.meta.url);
|
|
1167
2130
|
const PTY_SUPPORT_ERROR_TTL_MS = 2_000;
|
|
@@ -1174,7 +2137,7 @@ function ptyHostAvailability(opts = {}) {
|
|
|
1174
2137
|
return ptySpawnSupported(opts);
|
|
1175
2138
|
}
|
|
1176
2139
|
|
|
1177
|
-
function envInt(name, fallback, min, max, legacyName) {
|
|
2140
|
+
export function envInt(name, fallback, min, max, legacyName) {
|
|
1178
2141
|
const raw = process.env[name] ?? (legacyName ? process.env[legacyName] : undefined);
|
|
1179
2142
|
if (raw === undefined || raw === "") return fallback;
|
|
1180
2143
|
const n = Number(raw);
|