@zhuxixi/pi-agent-board 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner.mjs +8 -7
- package/runner/pty-runner.mjs +616 -27
- package/runner/state-coordinator.mjs +43 -17
- package/runner/state-runner.mjs +6 -5
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +35 -3
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +27 -3
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +20 -0
- package/src/core/state-commands.mjs +88 -6
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +284 -16
- package/src/ui/dashboard.ts +41 -33
- package/src/ui/pty-attach.ts +236 -72
- package/src/core/pty-input.mjs +0 -47
package/src/runtime/service.mjs
CHANGED
|
@@ -15,7 +15,8 @@ import { finalizeRun, projectViewState, reduceEvent } from "../core/events.mjs";
|
|
|
15
15
|
import { clearDiagnostics, appendDiagnostic, tailDiagnostics } from "../core/diagnostics.mjs";
|
|
16
16
|
import { emptyEvidenceSnapshot, finalizeEvidence, readEvidence, reduceEvidence, summarizeEvidence, writeEvidence } from "../core/evidence.mjs";
|
|
17
17
|
import { updateCodeRefsFromEvidence } from "../core/code-refs-store.mjs";
|
|
18
|
-
import { claimNextFollowUp, completeFollowUp, enqueueFollowUp, readFollowUpQueue, releaseFollowUp, summarizeFollowUpQueue, clearQueuedFollowUps, removeLastFollowUp } from "../core/follow-up-queue.mjs";
|
|
18
|
+
import { claimNextFollowUp, completeFollowUp, enqueueFollowUp, failFollowUp, readFollowUpQueue, releaseFollowUp, summarizeFollowUpQueue, clearQueuedFollowUps, removeLastFollowUp } from "../core/follow-up-queue.mjs";
|
|
19
|
+
import { foregroundPreviewCache } from "../core/foreground-preview-cache.mjs";
|
|
19
20
|
import { approvePlan as approvePlanState, markExecutingApprovedPlan, readSteering, recordPlanReady, requestPlan as requestPlanState, requestPlanChanges as requestPlanChangesState, summarizeSteering } from "../core/steering.mjs";
|
|
20
21
|
import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../core/steering-prompts.mjs";
|
|
21
22
|
import { isGenericStatusText } from "../core/derive.mjs";
|
|
@@ -27,7 +28,9 @@ import { isAlive, killProcess } from "../core/pid.mjs";
|
|
|
27
28
|
import { acquireOwnedViewLock, tryAcquireOwnedViewLock } from "../core/locks.mjs";
|
|
28
29
|
import { canFinalizeLegacyHost, canReplaceHost } from "../core/host-coordination.mjs";
|
|
29
30
|
import { modelRefAvailable } from "../core/launch-options.mjs";
|
|
30
|
-
import { sendStateCommand, coordinatorDisabled } from "../core/coordinator-client.mjs";
|
|
31
|
+
import { ensureCoordinator, sendStateCommand, coordinatorDisabled } from "../core/coordinator-client.mjs";
|
|
32
|
+
import { createDesyncEpisodeThrottle, rereadPair, statusRevisionDesynced } from "../core/status-consistency.mjs";
|
|
33
|
+
import { commandRejectDiagnostic } from "../core/state-commands.mjs";
|
|
31
34
|
import { HOST_PROBE_RETRY_MS, probeHost } from "../core/host-probe.mjs";
|
|
32
35
|
import * as P from "../core/paths.mjs";
|
|
33
36
|
import {
|
|
@@ -107,6 +110,10 @@ export function createService(opts) {
|
|
|
107
110
|
const acquireLockImpl = opts.acquireLock ?? acquireOwnedViewLock;
|
|
108
111
|
const tryAcquireLockImpl = opts.tryAcquireLock ?? tryAcquireOwnedViewLock;
|
|
109
112
|
const sendStateCommandImpl = opts.sendStateCommand ?? sendStateCommand;
|
|
113
|
+
const ensureCoordinatorImpl = opts.ensureCoordinator ?? ensureCoordinator;
|
|
114
|
+
// Desync episode throttle: a stuck (stateRev:statusRev) pair logs once per
|
|
115
|
+
// service, not once per reconcile pass — see createDesyncEpisodeThrottle().
|
|
116
|
+
const desyncThrottle = createDesyncEpisodeThrottle();
|
|
110
117
|
// Identity-aware observation/signalling for host recovery (issue #70). Callers must
|
|
111
118
|
// only signal after observeProcess returned "owned" for that exact identity.
|
|
112
119
|
const observeProcessImpl = opts.observeProcess ?? defaultObserveProcess;
|
|
@@ -147,7 +154,9 @@ export function createService(opts) {
|
|
|
147
154
|
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
148
155
|
* @param {string} prompt
|
|
149
156
|
* @param {RunKind} kind
|
|
150
|
-
* @returns {{ runId: string, pid: number|null }}
|
|
157
|
+
* @returns {Promise<{ runId: string, pid: number|null }>} resolves after
|
|
158
|
+
* mark_queued has landed and the detached runner is spawned (ordering
|
|
159
|
+
* contract: the row must be queued before the runner can boot, final-review F2).
|
|
151
160
|
*/
|
|
152
161
|
async function launchForView(meta, prompt, kind) {
|
|
153
162
|
const runId = newRunId();
|
|
@@ -244,7 +253,7 @@ export function createService(opts) {
|
|
|
244
253
|
* lease; this function never acquires or releases it.
|
|
245
254
|
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
246
255
|
* @param {string|null} initialPrompt
|
|
247
|
-
* @returns {
|
|
256
|
+
* @returns {{ ok: true, status: "reused"|"pending"|"started", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }}
|
|
248
257
|
*/
|
|
249
258
|
function startHostUnderLease(meta, initialPrompt, launchOpts = {}) {
|
|
250
259
|
const existing = readHost(root, meta.id);
|
|
@@ -409,11 +418,11 @@ export function createService(opts) {
|
|
|
409
418
|
if (result.reason === "coordinator_disabled") {
|
|
410
419
|
return applyAutoStateToStatus(status, classification, Date.now());
|
|
411
420
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
appendDiagnostic(root, meta.id, { source: "service", runId: commandRunId,
|
|
421
|
+
// Unified decided/ambiguous classification (CR r2 issue-3): decided
|
|
422
|
+
// rejects (manual_fence/stale_run/…) log as info *_skipped — the
|
|
423
|
+
// coordinator's verdict is authoritative; only genuinely ambiguous
|
|
424
|
+
// outcomes (timeout/connection_reset) keep the recovery-path warn.
|
|
425
|
+
appendDiagnostic(root, meta.id, { source: "service", runId: commandRunId, ...commandRejectDiagnostic("auto_state", "Auto-state classification", result.reason, "otherwise the next classification pass will converge the row"), details: { reason: result.reason } });
|
|
417
426
|
return false;
|
|
418
427
|
}
|
|
419
428
|
|
|
@@ -659,6 +668,7 @@ export function createService(opts) {
|
|
|
659
668
|
}, viewId);
|
|
660
669
|
if (result.reason === "coordinator_disabled") archiveStateDirect(row);
|
|
661
670
|
}
|
|
671
|
+
foregroundPreviewCache.forget(viewId);
|
|
662
672
|
row.meta.archived = true;
|
|
663
673
|
writeMeta(root, row.meta);
|
|
664
674
|
return { ok: true };
|
|
@@ -742,6 +752,10 @@ export function createService(opts) {
|
|
|
742
752
|
// decision kind force-nulls it too; the caller-side null keeps the
|
|
743
753
|
// coordinator_disabled direct write at legacy parity.)
|
|
744
754
|
projected.currentRunId = null;
|
|
755
|
+
// Issue #113: remember the freshest known foreground fields before the
|
|
756
|
+
// coordinator round trip — syncRowEvent rebuilds from disk on the next
|
|
757
|
+
// event and must not read an older projection than this one.
|
|
758
|
+
foregroundPreviewCache.remember(row.meta.id, projected);
|
|
745
759
|
if (coordinatorDisabled()) {
|
|
746
760
|
writeState(root, projected);
|
|
747
761
|
return;
|
|
@@ -1309,7 +1323,7 @@ export function createService(opts) {
|
|
|
1309
1323
|
* async: the item is only completed on input_ack; any failure releases it
|
|
1310
1324
|
* back to queued for a later retry (issue #70 A13).
|
|
1311
1325
|
* @param {string} viewId
|
|
1312
|
-
* @returns {Promise<{ ok: boolean, error?: string, sent?: boolean, started?: boolean, pending?: boolean, item?: any }>}
|
|
1326
|
+
* @returns {Promise<{ ok: boolean, error?: string, sent?: boolean, started?: boolean, pending?: boolean, failed?: boolean, item?: any }>} (failed=true: §10 accepted_unknown — marked failed, never re-sent)
|
|
1313
1327
|
*/
|
|
1314
1328
|
async function drainNextFollowUp(viewId) {
|
|
1315
1329
|
const row = loadRow(root, viewId);
|
|
@@ -1339,13 +1353,27 @@ export function createService(opts) {
|
|
|
1339
1353
|
return { ok: true, pending: true, item };
|
|
1340
1354
|
}
|
|
1341
1355
|
const sent = socketPath
|
|
1342
|
-
? await sendHostInput(socketPath, `${prompt}\r`, {
|
|
1356
|
+
? await sendHostInput(socketPath, `${prompt}\r`, {
|
|
1357
|
+
requestId: item.id,
|
|
1358
|
+
commandId: item.id,
|
|
1359
|
+
viewId,
|
|
1360
|
+
instanceId: row.host.instanceId,
|
|
1361
|
+
})
|
|
1343
1362
|
: { ok: false, error: "No host socket", retryable: true };
|
|
1344
1363
|
if (sent.ok) {
|
|
1345
1364
|
completeFollowUp(root, viewId, item.id);
|
|
1346
1365
|
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind: item.kind } });
|
|
1347
1366
|
return { ok: true, sent: true, item };
|
|
1348
1367
|
}
|
|
1368
|
+
if (sent.ambiguous) {
|
|
1369
|
+
// §10: the prompt was accepted by a host that restarted before
|
|
1370
|
+
// delivering it — a re-send can only return cached stages, so the
|
|
1371
|
+
// honest terminal state is failed-with-reason (mirrors the queue's
|
|
1372
|
+
// existing failure marking; the item keeps its text for re-send).
|
|
1373
|
+
failFollowUp(root, viewId, item.id, "accepted_unknown: accepted but never applied; outcome unknowable (§10); not re-sent");
|
|
1374
|
+
appendDiagnostic(root, viewId, { source: "queue", level: "error", code: "follow_up_ambiguous", message: "Queued follow-up accepted_unknown after host restart — marked failed, not re-sent", details: { kind: item.kind, commandId: item.id } });
|
|
1375
|
+
return { ok: true, failed: true, item };
|
|
1376
|
+
}
|
|
1349
1377
|
releaseFollowUp(root, viewId, item.id);
|
|
1350
1378
|
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 } });
|
|
1351
1379
|
return { ok: true, pending: true, item };
|
|
@@ -1398,6 +1426,11 @@ export function createService(opts) {
|
|
|
1398
1426
|
async function syncRowEvent(row, event) {
|
|
1399
1427
|
const now = Date.now();
|
|
1400
1428
|
const status = statusFromRow(row);
|
|
1429
|
+
// Issue #113: the previous event's projection may still be in flight (the
|
|
1430
|
+
// coordinator journals + fsyncs before materializing), so a disk rebuild
|
|
1431
|
+
// can lag the freshest known preview/activity. Restore read-your-writes
|
|
1432
|
+
// before any event branch touches the status.
|
|
1433
|
+
foregroundPreviewCache.backfill(row.meta.id, status);
|
|
1401
1434
|
let evidence = readEvidence(root, row.meta.id);
|
|
1402
1435
|
if (!evidence.viewId) evidence = emptyEvidenceSnapshot({ viewId: row.meta.id, source: "hosted" });
|
|
1403
1436
|
try {
|
|
@@ -1600,13 +1633,24 @@ export function createService(opts) {
|
|
|
1600
1633
|
}
|
|
1601
1634
|
const socketPath = row.host?.socketPath;
|
|
1602
1635
|
const sent = socketPath
|
|
1603
|
-
? await sendHostInput(socketPath, `${prompt}\r`, {
|
|
1636
|
+
? await sendHostInput(socketPath, `${prompt}\r`, {
|
|
1637
|
+
requestId: queued.item.id,
|
|
1638
|
+
commandId: queued.item.id,
|
|
1639
|
+
viewId,
|
|
1640
|
+
instanceId: row.host.instanceId,
|
|
1641
|
+
})
|
|
1604
1642
|
: { ok: false, error: "No host socket", retryable: true };
|
|
1605
1643
|
if (sent.ok) {
|
|
1606
1644
|
completeFollowUp(root, viewId, queued.item.id);
|
|
1607
1645
|
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind } });
|
|
1608
1646
|
return { ok: true, sent: true, item: queued.item };
|
|
1609
1647
|
}
|
|
1648
|
+
if (sent.ambiguous) {
|
|
1649
|
+
// §10: see drainNextFollowUp — never re-queue, never complete.
|
|
1650
|
+
failFollowUp(root, viewId, queued.item.id, "accepted_unknown: accepted but never applied; outcome unknowable (§10); not re-sent");
|
|
1651
|
+
appendDiagnostic(root, viewId, { source: "queue", level: "error", code: "follow_up_ambiguous", message: "Queued follow-up accepted_unknown after host restart — marked failed, not re-sent", details: { kind, commandId: queued.item.id } });
|
|
1652
|
+
return { ok: true, failed: true, item: queued.item };
|
|
1653
|
+
}
|
|
1610
1654
|
// Not an error: starting host, busy socket or lost ack — the drain
|
|
1611
1655
|
// loop retries once the host acks (issue #70 prompt-not-lost).
|
|
1612
1656
|
return { ok: true, queued: true, summary: queued.summary };
|
|
@@ -1804,7 +1848,7 @@ export function createService(opts) {
|
|
|
1804
1848
|
return { ok: true };
|
|
1805
1849
|
},
|
|
1806
1850
|
|
|
1807
|
-
/** @param {string} viewId @returns {{ ok: boolean, error?: string }} */
|
|
1851
|
+
/** @param {string} viewId @returns {Promise<{ ok: boolean, error?: string }>} */
|
|
1808
1852
|
markVisited(viewId) {
|
|
1809
1853
|
return markVisited(viewId);
|
|
1810
1854
|
},
|
|
@@ -1957,6 +2001,7 @@ export function createService(opts) {
|
|
|
1957
2001
|
}
|
|
1958
2002
|
if (row.host?.instanceId) stopHostRow(row, "archive");
|
|
1959
2003
|
else if (row.hostAlive) sendHostMessage(row, { type: "terminate" });
|
|
2004
|
+
foregroundPreviewCache.forget(row.meta.id);
|
|
1960
2005
|
row.meta.archived = true;
|
|
1961
2006
|
writeMeta(root, row.meta);
|
|
1962
2007
|
archived += 1;
|
|
@@ -2019,7 +2064,48 @@ export function createService(opts) {
|
|
|
2019
2064
|
continue;
|
|
2020
2065
|
}
|
|
2021
2066
|
if (row.alive) continue;
|
|
2022
|
-
|
|
2067
|
+
let status = readStatus(root, row.meta.id, s.currentRunId);
|
|
2068
|
+
if (statusRevisionDesynced(s, status)) {
|
|
2069
|
+
// TOCTOU guard: `s` is a listRows snapshot, and earlier rows'
|
|
2070
|
+
// awaited commands may have let the on-disk pair advance past
|
|
2071
|
+
// it — re-read BOTH files fresh and act only on a pair that
|
|
2072
|
+
// still disagrees. (The residual window between the two fresh
|
|
2073
|
+
// reads is sub-ms under the coordinator's view-lock pairing
|
|
2074
|
+
// invariant.)
|
|
2075
|
+
const reread = rereadPair(root, row.meta.id, s.currentRunId);
|
|
2076
|
+
if (reread.desynced) {
|
|
2077
|
+
// Half-materialized pair (a coordinator crashed between its paired
|
|
2078
|
+
// writes): don't combine the mismatched halves into one decision —
|
|
2079
|
+
// record it and kick a coordinator so boot replay repairs the pair.
|
|
2080
|
+
// The row is skipped this pass (not fixed); the next pass sees the
|
|
2081
|
+
// repaired stamps and proceeds normally.
|
|
2082
|
+
// Episode throttle: a stuck pair logs once, not once per 700ms
|
|
2083
|
+
// reconcile pass — diagnostics must stay bounded for unrepairable
|
|
2084
|
+
// pairs (torn transient beat has no journal record; coordinator off).
|
|
2085
|
+
// Episode throttle gates the DIAGNOSTIC (a stuck pair logs once —
|
|
2086
|
+
// CR r1); the repair KICK is retried independently while it keeps
|
|
2087
|
+
// failing (CR r2 issue-4): a transient ensureCoordinator failure
|
|
2088
|
+
// (spawn window exhausted) must not consume the recovery path —
|
|
2089
|
+
// an idle dashboard would otherwise never run boot replay. A
|
|
2090
|
+
// successful kick stops the retries: the coordinator is up, boot
|
|
2091
|
+
// replay already repaired a repairable pair, and an
|
|
2092
|
+
// unrepairable-torn pair gains nothing from re-kicking.
|
|
2093
|
+
// (shouldLog records the episode — evaluate it exactly once.)
|
|
2094
|
+
const logEpisode = desyncThrottle.shouldLog(row.meta.id, reread.state.materializedRevision, reread.status.materializedRevision);
|
|
2095
|
+
if (logEpisode) {
|
|
2096
|
+
appendDiagnostic(root, row.meta.id, { source: "service", level: "warn", code: "state_status_revision_desync", message: "state.json and status.json revisions disagree; skipping reconcile projection and requesting coordinator repair", details: { stateRevision: reread.state.materializedRevision, statusRevision: reread.status.materializedRevision, runId: s.currentRunId } });
|
|
2097
|
+
}
|
|
2098
|
+
if (logEpisode || desyncThrottle.shouldRetryKick(row.meta.id)) {
|
|
2099
|
+
void ensureCoordinatorImpl(root).then((res) => {
|
|
2100
|
+
if (res?.ok) desyncThrottle.clearKickFailed(row.meta.id);
|
|
2101
|
+
else desyncThrottle.markKickFailed(row.meta.id);
|
|
2102
|
+
}).catch(() => desyncThrottle.markKickFailed(row.meta.id));
|
|
2103
|
+
}
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
2106
|
+
// Snapshot was stale but the fresh pair agrees — project from it.
|
|
2107
|
+
status = reread.status;
|
|
2108
|
+
}
|
|
2023
2109
|
if (status?.endedAt) {
|
|
2024
2110
|
// The run's terminal status exists but the row was never materialized
|
|
2025
2111
|
// from it (crash between the two writes, or a pre-coordinator row).
|
|
@@ -2345,11 +2431,184 @@ function sendHostMessage(row, message) {
|
|
|
2345
2431
|
}
|
|
2346
2432
|
|
|
2347
2433
|
/**
|
|
2348
|
-
* Default window service waits for the runner's input_ack
|
|
2349
|
-
* host input as undelivered
|
|
2434
|
+
* Default window service waits for the runner's input_ack (or, on the staged
|
|
2435
|
+
* path, `cmd_ack applied`) before treating a host input as undelivered
|
|
2436
|
+
* (issue #70 A13; phase 5 D4).
|
|
2350
2437
|
*/
|
|
2351
2438
|
const HOST_INPUT_ACK_TIMEOUT_MS = 2_000;
|
|
2352
2439
|
|
|
2440
|
+
/**
|
|
2441
|
+
* Reconcile-query one commandId against a host control socket (§10 discipline:
|
|
2442
|
+
* a timeout must be resolved by query, never by a blind retry).
|
|
2443
|
+
* @returns {Promise<"accepted_unknown"|"reconciled_clean"|"unavailable">}
|
|
2444
|
+
*/
|
|
2445
|
+
function reconcileQueryCommand(socketPath, commandId, opts = {}) {
|
|
2446
|
+
const { viewId, instanceId, clientId = "service", connect = createConnection, timeoutMs = HOST_INPUT_ACK_TIMEOUT_MS } = opts;
|
|
2447
|
+
return new Promise((resolve) => {
|
|
2448
|
+
let settled = false;
|
|
2449
|
+
let buffer = "";
|
|
2450
|
+
/** @type {import("node:net").Socket|null} */
|
|
2451
|
+
let socket = null;
|
|
2452
|
+
const finish = (verdict) => {
|
|
2453
|
+
if (settled) return;
|
|
2454
|
+
settled = true;
|
|
2455
|
+
clearTimeout(timer);
|
|
2456
|
+
try { socket?.destroy(); } catch { /* best effort */ }
|
|
2457
|
+
resolve(verdict);
|
|
2458
|
+
};
|
|
2459
|
+
const timer = setTimeout(() => finish("unavailable"), timeoutMs);
|
|
2460
|
+
timer.unref?.();
|
|
2461
|
+
try {
|
|
2462
|
+
socket = connect(socketPath);
|
|
2463
|
+
} catch {
|
|
2464
|
+
finish("unavailable");
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
socket.on("connect", () => {
|
|
2468
|
+
try {
|
|
2469
|
+
socket.write(JSON.stringify({ type: "reconcile", commandId: `reconcile-${commandId}`, clientId, seq: 1, viewId, instanceId }) + "\n");
|
|
2470
|
+
} catch {
|
|
2471
|
+
finish("unavailable");
|
|
2472
|
+
}
|
|
2473
|
+
});
|
|
2474
|
+
socket.on("data", (chunk) => {
|
|
2475
|
+
buffer += chunk.toString("utf8");
|
|
2476
|
+
const lines = buffer.split("\n");
|
|
2477
|
+
buffer = lines.pop() ?? "";
|
|
2478
|
+
for (const line of lines) {
|
|
2479
|
+
if (!line.trim()) continue;
|
|
2480
|
+
let msg;
|
|
2481
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
2482
|
+
if (msg?.type !== "reconcile_result") continue;
|
|
2483
|
+
const unresolved = Array.isArray(msg.unresolved) ? msg.unresolved : [];
|
|
2484
|
+
finish(unresolved.some((u) => u?.commandId === commandId) ? "accepted_unknown" : "reconciled_clean");
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
});
|
|
2488
|
+
socket.on("error", () => finish("unavailable"));
|
|
2489
|
+
socket.on("close", () => finish("unavailable"));
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
/**
|
|
2494
|
+
* Staged durable-input delivery (phase 5 D4): enveloped `{durable:true}` input
|
|
2495
|
+
* expecting `cmd_ack accepted → applied` against a generation-capable runner,
|
|
2496
|
+
* with the §10 reconcile-query on timeout. Runners whose hello lacks a
|
|
2497
|
+
* `generation` token predate the control lifecycle: the SAME connection falls
|
|
2498
|
+
* back to the legacy requestId/input_ack contract (byte-identical bytes), so
|
|
2499
|
+
* phase-4 owned hosts (instanceId known, envelope-ignorant) keep their dedup
|
|
2500
|
+
* instead of double-writing.
|
|
2501
|
+
*
|
|
2502
|
+
* Result shapes (never rejects):
|
|
2503
|
+
* - `{ok:true, applied:true}` — the runner wrote the child.
|
|
2504
|
+
* - `{ok:false, ambiguous:true, error:"accepted_unknown", retryable:false}` —
|
|
2505
|
+
* accepted but never applied (restart/write-failure window); the caller must
|
|
2506
|
+
* NOT re-send (§10) and must NOT treat the prompt as delivered.
|
|
2507
|
+
* - `{ok:false, error, retryable}` — everything else (starting window, dead
|
|
2508
|
+
* socket, legacy timeout); re-send is safe (journal dedup or requestId dedup).
|
|
2509
|
+
* @param {string} socketPath
|
|
2510
|
+
* @param {string} text
|
|
2511
|
+
* @param {{ requestId?: string|null, commandId?: string|null, viewId?: string, instanceId?: string|null, clientId?: string, timeoutMs?: number, connect?: (path: string) => import("node:net").Socket }} [opts]
|
|
2512
|
+
*/
|
|
2513
|
+
function sendHostInputStaged(socketPath, text, opts = {}) {
|
|
2514
|
+
const commandId = opts.commandId ?? opts.requestId;
|
|
2515
|
+
const clientId = opts.clientId ?? "service";
|
|
2516
|
+
const timeoutMs = opts.timeoutMs ?? HOST_INPUT_ACK_TIMEOUT_MS;
|
|
2517
|
+
const connect = opts.connect ?? createConnection;
|
|
2518
|
+
const { viewId, instanceId } = opts;
|
|
2519
|
+
return new Promise((resolve) => {
|
|
2520
|
+
let settled = false;
|
|
2521
|
+
let buffer = "";
|
|
2522
|
+
let helloSeen = false;
|
|
2523
|
+
let sawAccepted = false;
|
|
2524
|
+
/** @type {import("node:net").Socket|null} */
|
|
2525
|
+
let socket = null;
|
|
2526
|
+
const finish = (result) => {
|
|
2527
|
+
if (settled) return;
|
|
2528
|
+
settled = true;
|
|
2529
|
+
clearTimeout(timer);
|
|
2530
|
+
try { socket?.destroy(); } catch { /* best effort */ }
|
|
2531
|
+
resolve(result);
|
|
2532
|
+
};
|
|
2533
|
+
/** §10: resolve the deadline by query, never by a blind retry. */
|
|
2534
|
+
const resolveByReconcile = (fallbackError) => {
|
|
2535
|
+
if (settled) return;
|
|
2536
|
+
reconcileQueryCommand(socketPath, commandId, { viewId, instanceId, clientId, connect, timeoutMs }).then((verdict) => {
|
|
2537
|
+
if (verdict === "accepted_unknown") finish({ ok: false, ambiguous: true, error: "accepted_unknown", retryable: false });
|
|
2538
|
+
else if (verdict === "reconciled_clean") finish({ ok: false, error: fallbackError, retryable: true });
|
|
2539
|
+
else finish({ ok: false, error: fallbackError, retryable: true });
|
|
2540
|
+
});
|
|
2541
|
+
};
|
|
2542
|
+
const timer = setTimeout(() => resolveByReconcile("timeout"), timeoutMs);
|
|
2543
|
+
timer.unref?.();
|
|
2544
|
+
try {
|
|
2545
|
+
socket = connect(socketPath);
|
|
2546
|
+
} catch (err) {
|
|
2547
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
socket.on("data", (chunk) => {
|
|
2551
|
+
buffer += chunk.toString("utf8");
|
|
2552
|
+
const lines = buffer.split("\n");
|
|
2553
|
+
buffer = lines.pop() ?? "";
|
|
2554
|
+
for (const line of lines) {
|
|
2555
|
+
if (!line.trim()) continue;
|
|
2556
|
+
let msg;
|
|
2557
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
2558
|
+
if (!helloSeen) {
|
|
2559
|
+
if (msg?.type !== "hello") continue; // wait for the capability answer
|
|
2560
|
+
helloSeen = true;
|
|
2561
|
+
try {
|
|
2562
|
+
if (typeof msg.generation === "string" && msg.generation) {
|
|
2563
|
+
socket.write(JSON.stringify({ type: "input", durable: true, commandId, clientId, seq: 1, viewId, instanceId, data: text }) + "\n");
|
|
2564
|
+
} else {
|
|
2565
|
+
// Pre-lifecycle runner: legacy requestId contract, same bytes.
|
|
2566
|
+
socket.write(JSON.stringify({ type: "input", requestId: commandId, data: text }) + "\n");
|
|
2567
|
+
}
|
|
2568
|
+
} catch (err) {
|
|
2569
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
|
|
2570
|
+
}
|
|
2571
|
+
continue;
|
|
2572
|
+
}
|
|
2573
|
+
if (msg?.type === "cmd_ack" && msg.commandId === commandId) {
|
|
2574
|
+
if (msg.stage === "applied") finish({ ok: true, applied: true });
|
|
2575
|
+
if (msg.stage === "accepted") {
|
|
2576
|
+
sawAccepted = true;
|
|
2577
|
+
// accepted → applied is synchronous runner-side (Task 2 ruling 4);
|
|
2578
|
+
// a dropped socket between them is resolved by the deadline's query.
|
|
2579
|
+
}
|
|
2580
|
+
continue;
|
|
2581
|
+
}
|
|
2582
|
+
if (msg?.type === "input_ack" && msg.requestId === commandId) {
|
|
2583
|
+
// Legacy fallback answer on this same connection.
|
|
2584
|
+
finish({ ok: true });
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
if (msg?.type === "error") {
|
|
2588
|
+
const code = msg.code ?? msg.message ?? "host_error";
|
|
2589
|
+
if (code === "command_failed") {
|
|
2590
|
+
// Taxonomy: reconcile by commandId before any retry decision.
|
|
2591
|
+
resolveByReconcile(code);
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
finish({ ok: false, error: code, retryable: code !== "envelope_invalid" });
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
// hello/status/output lines are ignored — keep waiting.
|
|
2598
|
+
}
|
|
2599
|
+
});
|
|
2600
|
+
socket.on("error", (err) => {
|
|
2601
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
2602
|
+
finish({ ok: false, error: code ?? (err instanceof Error ? err.message : String(err)), retryable: true });
|
|
2603
|
+
});
|
|
2604
|
+
socket.on("close", () => {
|
|
2605
|
+
// A close after an accepted ack is the §10 window — resolve by query.
|
|
2606
|
+
if (sawAccepted) resolveByReconcile("socket_closed");
|
|
2607
|
+
else finish({ ok: false, error: "socket_closed", retryable: true });
|
|
2608
|
+
});
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2353
2612
|
/**
|
|
2354
2613
|
* Send one input line to a host control socket and wait for the runner's
|
|
2355
2614
|
* input_ack (issue #70 A13). Fire-and-forget "connected" no longer counts as
|
|
@@ -2372,6 +2631,15 @@ const HOST_INPUT_ACK_TIMEOUT_MS = 2_000;
|
|
|
2372
2631
|
* @returns {Promise<{ ok: boolean, error?: string, retryable?: boolean }>}
|
|
2373
2632
|
*/
|
|
2374
2633
|
export function sendHostInput(socketPath, text, opts = {}) {
|
|
2634
|
+
// Staged durable path (phase 5 D4): the caller knows the host fence
|
|
2635
|
+
// (viewId + instanceId) and a stable commandId — envelope it and expect
|
|
2636
|
+
// accepted → applied with the §10 reconcile-query on timeout. Runners whose
|
|
2637
|
+
// hello lacks a generation fall back to the legacy requestId contract on
|
|
2638
|
+
// the same connection, so the legacy branch below stays for callers that
|
|
2639
|
+
// have no fence (pre-instanceId hosts).
|
|
2640
|
+
if (opts.viewId && opts.instanceId != null && (opts.commandId || opts.requestId)) {
|
|
2641
|
+
return sendHostInputStaged(socketPath, text, opts);
|
|
2642
|
+
}
|
|
2375
2643
|
const requestId = opts.requestId ?? null;
|
|
2376
2644
|
const timeoutMs = opts.timeoutMs ?? HOST_INPUT_ACK_TIMEOUT_MS;
|
|
2377
2645
|
const connect = opts.connect ?? createConnection;
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -440,7 +440,7 @@ export class DashboardComponent implements Component {
|
|
|
440
440
|
const page = Math.max(1, termRows - 8);
|
|
441
441
|
if (matchesKey(data, Key.left) || matchesKey(data, Key.escape) || data === "<") {
|
|
442
442
|
const row = this.selectedRow();
|
|
443
|
-
if (row) this.deps.service.markVisited?.(row.meta.id);
|
|
443
|
+
if (row) void this.deps.service.markVisited?.(row.meta.id)?.catch(() => {});
|
|
444
444
|
this.mode = "list";
|
|
445
445
|
return;
|
|
446
446
|
}
|
|
@@ -847,6 +847,14 @@ export class DashboardComponent implements Component {
|
|
|
847
847
|
const launchCwd = launchOpts?.cwd ?? this.deps.defaultCwd;
|
|
848
848
|
const launchModel = launchOpts?.model ?? (this.launch?.model ? canonicalModelRef(this.launch.model) : null);
|
|
849
849
|
const launchThinking = launchOpts?.thinkingLevel ?? this.launch?.thinking ?? this.deps.currentThinkingLevel;
|
|
850
|
+
// Clear synchronously, BEFORE the async dispatch: closes the double-submit
|
|
851
|
+
// window — a second Enter during the coordinator round-trip (mark_queued
|
|
852
|
+
// lands before the runner spawns, issue #91) must not re-dispatch.
|
|
853
|
+
this.setInput("");
|
|
854
|
+
this.launch = null;
|
|
855
|
+
this.mode = "list";
|
|
856
|
+
this.inputNotice = null;
|
|
857
|
+
this.refresh();
|
|
850
858
|
// dispatch is async (mark_queued routes through the view-state coordinator
|
|
851
859
|
// before the runner spawns, issue #91); notices/attach land when it settles.
|
|
852
860
|
void Promise.resolve(
|
|
@@ -856,38 +864,38 @@ export class DashboardComponent implements Component {
|
|
|
856
864
|
thinkingLevel: launchThinking,
|
|
857
865
|
}),
|
|
858
866
|
).then((res) => {
|
|
859
|
-
if (!res.ok)
|
|
860
|
-
|
|
861
|
-
this.
|
|
862
|
-
|
|
863
|
-
recordCwdLaunch(this.deps.root, launchCwd);
|
|
864
|
-
} catch {
|
|
865
|
-
/* best effort: stats must never block dispatch */
|
|
866
|
-
}
|
|
867
|
-
try {
|
|
868
|
-
this.deps.service.saveLaunchPrefs?.(this.lastLaunchPrefs);
|
|
869
|
-
} catch {
|
|
870
|
-
/* best effort */
|
|
871
|
-
}
|
|
872
|
-
this.selectedId = res.viewId ?? this.selectedId;
|
|
873
|
-
if (launchOpts?.attach && res.hostMode === "pty" && res.viewId) {
|
|
874
|
-
this.setInput("");
|
|
875
|
-
this.launch = null;
|
|
876
|
-
this.mode = "list";
|
|
877
|
-
this.inputNotice = null;
|
|
878
|
-
this.done({ action: "attach", viewId: res.viewId, stopFirst: false });
|
|
879
|
-
return;
|
|
880
|
-
}
|
|
881
|
-
if (res.hostMode === "json-runner") {
|
|
882
|
-
this.notice(launchOpts?.attach ? `Start & attach needs PTY; launched in background: ${res.fallbackReason ?? "PTY unavailable"}` : `Dispatched with non-live fallback: ${res.fallbackReason ?? "PTY unavailable"}`, "warn");
|
|
883
|
-
} else {
|
|
884
|
-
this.notice(`Dispatched: ${truncate(text, 40)}`, "info");
|
|
885
|
-
}
|
|
867
|
+
if (!res.ok) {
|
|
868
|
+
this.notice(res.error ?? "Dispatch failed", "error");
|
|
869
|
+
this.refresh();
|
|
870
|
+
return;
|
|
886
871
|
}
|
|
887
|
-
this.
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
872
|
+
this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
|
|
873
|
+
try {
|
|
874
|
+
recordCwdLaunch(this.deps.root, launchCwd);
|
|
875
|
+
} catch {
|
|
876
|
+
/* best effort: stats must never block dispatch */
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
this.deps.service.saveLaunchPrefs?.(this.lastLaunchPrefs);
|
|
880
|
+
} catch {
|
|
881
|
+
/* best effort */
|
|
882
|
+
}
|
|
883
|
+
this.selectedId = res.viewId ?? this.selectedId;
|
|
884
|
+
if (launchOpts?.attach && res.hostMode === "pty" && res.viewId) {
|
|
885
|
+
this.done({ action: "attach", viewId: res.viewId, stopFirst: false });
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (res.hostMode === "json-runner") {
|
|
889
|
+
this.notice(launchOpts?.attach ? `Start & attach needs PTY; launched in background: ${res.fallbackReason ?? "PTY unavailable"}` : `Dispatched with non-live fallback: ${res.fallbackReason ?? "PTY unavailable"}`, "warn");
|
|
890
|
+
} else {
|
|
891
|
+
this.notice(`Dispatched: ${truncate(text, 40)}`, "info");
|
|
892
|
+
}
|
|
893
|
+
this.refresh();
|
|
894
|
+
}).catch(() => {
|
|
895
|
+
// dispatch() is not expected to reject (its failures surface as
|
|
896
|
+
// {ok:false}); this guards fs-class throws from escaping as unhandled
|
|
897
|
+
// rejections (issue #91 hygiene).
|
|
898
|
+
this.notice("Dispatch failed", "error");
|
|
891
899
|
this.refresh();
|
|
892
900
|
});
|
|
893
901
|
}
|
|
@@ -1067,7 +1075,7 @@ export class DashboardComponent implements Component {
|
|
|
1067
1075
|
|
|
1068
1076
|
private openSessionView(): void {
|
|
1069
1077
|
if (!this.selectedId) return;
|
|
1070
|
-
this.deps.service.markVisited?.(this.selectedId);
|
|
1078
|
+
void this.deps.service.markVisited?.(this.selectedId)?.catch(() => {});
|
|
1071
1079
|
this.sessionScrollTop = 0;
|
|
1072
1080
|
this.mode = "session";
|
|
1073
1081
|
}
|