@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +43 -5
  3. package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
  4. package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
  5. package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
  6. package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
  7. package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
  8. package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
  9. package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
  10. package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
  11. package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
  12. package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
  13. package/docs/superpowers/plans/2026-09-08-attach-ctrl-left-detach.md +30 -0
  14. package/docs/superpowers/plans/2026-09-08-dashboard-shrink-repaint.md +68 -0
  15. package/docs/superpowers/plans/2026-09-08-legacy-stale-host-recovery.md +125 -0
  16. package/docs/superpowers/plans/2026-09-08-spawn-async-error-swallow.md +56 -0
  17. package/docs/superpowers/plans/2026-09-08-stale-model-attach-guard.md +96 -0
  18. package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
  19. package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
  20. package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
  21. package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
  22. package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
  23. package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
  24. package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
  25. package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
  26. package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
  27. package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
  28. package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
  29. package/docs/superpowers/specs/2026-09-08-attach-ctrl-left-detach-design.md +58 -0
  30. package/docs/superpowers/specs/2026-09-08-dashboard-shrink-repaint-design.md +52 -0
  31. package/docs/superpowers/specs/2026-09-08-legacy-stale-host-recovery-design.md +87 -0
  32. package/docs/superpowers/specs/2026-09-08-spawn-async-error-swallow-design.md +56 -0
  33. package/docs/superpowers/specs/2026-09-08-stale-model-attach-guard-design.md +79 -0
  34. package/package.json +83 -81
  35. package/runner/job-runner.mjs +2 -2
  36. package/runner/pty-runner.mjs +626 -3
  37. package/runner/state-runner.mjs +3 -0
  38. package/runner/title-runner.mjs +1 -1
  39. package/scripts/release_helper.mjs +277 -0
  40. package/src/commands/agent-board.ts +47 -35
  41. package/src/commands/attach-decision.mjs +66 -0
  42. package/src/commands/attach-flow.ts +45 -39
  43. package/src/commands/bg.ts +9 -0
  44. package/src/core/code-refs.mjs +85 -33
  45. package/src/core/evidence.mjs +2 -2
  46. package/src/core/heuristics.mjs +75 -2
  47. package/src/core/host-coordination.mjs +182 -0
  48. package/src/core/host-crash.mjs +43 -3
  49. package/src/core/host-probe.mjs +196 -0
  50. package/src/core/launch-options.mjs +17 -0
  51. package/src/core/launch.mjs +35 -35
  52. package/src/core/locks.mjs +196 -1
  53. package/src/core/paths.mjs +24 -0
  54. package/src/core/store.mjs +164 -5
  55. package/src/core/types.mjs +17 -1
  56. package/src/core/warm-host-sweeper.mjs +150 -0
  57. package/src/index.ts +40 -3
  58. package/src/runtime/service.mjs +1071 -108
  59. package/src/ui/dashboard-decisions.mjs +55 -0
  60. package/src/ui/dashboard.ts +67 -13
  61. package/src/ui/pty-attach.ts +23 -5
@@ -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 socketPath = P.controlSocketPath(root, meta.id);
148
- const { pid } = launchHostImpl(root, config, { runnerScript: ptyRunnerScript });
149
- writeHost(root, {
150
- version: 1,
275
+ const claimIdentity = serviceIdentity();
276
+ const claimed = claimHost(root, {
151
277
  viewId: meta.id,
152
- mode: "pty",
153
- runnerPid: pid,
154
- childPid: null,
278
+ instanceId,
279
+ configPath,
155
280
  socketPath,
156
- state: "starting",
157
- startedAt: Date.now(),
158
- lastSeenAt: Date.now(),
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
- writeHostPid(root, meta.id, pid);
167
- appendDiagnostic(root, meta.id, { source: "service", code: "launch_host", message: "PTY host launched", details: { pid, hasInitialPrompt: Boolean(initialPrompt) } });
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.hostAlive) sendHostMessage(row, { type: "terminate" });
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
- * Keep a small warm pool of idle PTY hosts for fast session switching.
405
- * Busy hosts and attached hosts are never evicted.
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 now = Date.now();
413
- const idleHosts = listRows(root)
414
- .filter((r) => r.meta.id !== pruneOpts.keepViewId)
415
- .filter((r) => r.hostAlive && !isAgentBusy(r) && (r.host?.attachedClients ?? 0) === 0);
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
- for (const row of idleHosts) {
418
- const idleSince = row.state?.lastActivityAt ?? row.host?.startedAt ?? row.meta.updatedAt;
419
- if (ttlMs > 0 && now - idleSince > ttlMs) sendHostMessage(row, { type: "terminate" });
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
- const survivors = idleHosts
423
- .filter((r) => {
424
- const idleSince = r.state?.lastActivityAt ?? r.host?.startedAt ?? r.meta.updatedAt;
425
- return !(ttlMs > 0 && now - idleSince > ttlMs);
426
- })
427
- .sort((a, b) => (a.state?.lastActivityAt ?? a.host?.startedAt ?? 0) - (b.state?.lastActivityAt ?? b.host?.startedAt ?? 0));
428
- const excess = Math.max(0, survivors.length - maxWarm);
429
- for (const row of survivors.slice(0, excess)) sendHostMessage(row, { type: "terminate" });
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
- /** @param {string} viewId */
471
- function drainNextFollowUp(viewId) {
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.hostAlive) {
482
- const sent = sendHostMessage(row, { type: "input", data: `${prompt}\r` });
483
- if (!sent.ok) {
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
- return sent;
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
- completeFollowUp(root, viewId, item.id);
488
- appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind: item.kind } });
489
- return { ok: true, sent: true, item };
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
- const pty = ptySupport({ refresh: true });
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
- if (pty.ok) launchHost(row.meta, prompt);
494
- else {
495
- if (isExternalSession(row.meta)) {
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
- appendDiagnostic(root, viewId, { source: "queue", level: "warn", code: "follow_up_waiting_for_pty", message: "Adopted session follow-up is waiting for PTY support", details: {} });
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: pty.ok ? "pty" : "json-runner" } });
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
- drainNextFollowUp(row.meta.id);
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
- if (pty.ok) launchHost(meta, prompt);
613
- else launchForView(meta, prompt, "dispatch");
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: pty.ok ? "pty" : "json-runner",
619
- fallbackReason: pty.ok ? undefined : nodePtyFallbackMessage(pty),
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 by launching a new run. Blocks if a run is live.
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.hostAlive) return sendHostMessage(row, { type: "input", data: `${prompt}\r` });
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
- if (pty.ok) launchHost(row.meta, prompt);
646
- else {
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: pty.ok ? "pty" : "json-runner", fallbackReason: pty.ok ? undefined : nodePtyFallbackMessage(pty) };
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?.hostAlive) return sendHostMessage(row, { type: "interrupt" });
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?.hostAlive) return { ok: false, error: "No live host" };
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
- * Ensure there is an interactive PTY host for this session. Used for fast attach
678
- * and dashboard prewarm. Starting an idle host must not alter task state.
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
- ensureHost(viewId) {
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
- // Default probe semantics: success is cached for the process lifetime and a
688
- // failed probe retries on a short TTL. Forcing refresh here would spawn a
689
- // probe process on every keypress-driven prewarm when PTY support is broken.
690
- const pty = ptySupport();
691
- if (!pty.ok) return { ok: false, error: "PTY unavailable", fallbackReason: nodePtyFallbackMessage(pty) };
692
- if (isAgentBusy(row)) return { ok: false, error: "A non-live background run is active for this session" };
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
- const launched = launchHost(row.meta, null, { markQueued: false });
696
- pruneWarmHosts({ keepViewId: viewId });
697
- return { ok: true, socketPath: launched.socketPath, started: true };
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.hostAlive) sendHostMessage(row, { type: "terminate" });
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
- if (row.host && !row.hostAlive && (row.host.state === "starting" || row.host.state === "alive" || row.host.state === "exited" || row.host.state === "failed")) {
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
- const drained = drainNextFollowUp(row.meta.id);
1005
- if (drained.ok) fixed += 1;
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
- /** @param {import("../core/store.mjs").Row} row */
1098
- function hasPendingQuestions(row) {
1099
- return Array.isArray(row.state?.pendingQuestions) && row.state.pendingQuestions.length > 0;
1100
- }
1101
-
1102
- /** @param {import("../core/store.mjs").Row} row */
1103
- function isAgentBusy(row) {
1104
- const st = row.state?.semanticState;
1105
- return Boolean(row.alive && (st === "queued" || st === "working" || hasPendingQuestions(row)));
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);