@zhuxixi/pi-agent-board 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- package/src/core/pty-input.mjs +0 -47
package/src/runtime/service.mjs
CHANGED
|
@@ -9,13 +9,14 @@ import { randomBytes } from "node:crypto";
|
|
|
9
9
|
import { createRequire } from "node:module";
|
|
10
10
|
import { createConnection } from "node:net";
|
|
11
11
|
import { resolve } from "node:path";
|
|
12
|
-
import { applyAutoStateToStatus, autoStateEnabled, heuristicAutoState } from "../core/auto-state.mjs";
|
|
12
|
+
import { applyAutoStateToStatus, autoStateEnabled, heuristicAutoState, isManualCompletion } from "../core/auto-state.mjs";
|
|
13
13
|
import { appendLine, atomicWriteJson, removeFile } from "../core/atomic.mjs";
|
|
14
14
|
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,6 +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";
|
|
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";
|
|
30
34
|
import { HOST_PROBE_RETRY_MS, probeHost } from "../core/host-probe.mjs";
|
|
31
35
|
import * as P from "../core/paths.mjs";
|
|
32
36
|
import {
|
|
@@ -88,6 +92,7 @@ const ATTACH_RESOLVE_TIMEOUT_MS = 120_000;
|
|
|
88
92
|
* signalOwnedProcess?: (identity: {pid: number, startToken: string|null}, signal: string) => void,
|
|
89
93
|
* probeHostFn?: typeof probeHost,
|
|
90
94
|
* sleepFn?: (ms: number) => Promise<void>,
|
|
95
|
+
* sendStateCommand?: typeof sendStateCommand,
|
|
91
96
|
* availableModels?: () => Array<{ provider: string, id: string }> | undefined,
|
|
92
97
|
* }} opts
|
|
93
98
|
*/
|
|
@@ -104,6 +109,11 @@ export function createService(opts) {
|
|
|
104
109
|
const nowImpl = opts.now ?? Date.now;
|
|
105
110
|
const acquireLockImpl = opts.acquireLock ?? acquireOwnedViewLock;
|
|
106
111
|
const tryAcquireLockImpl = opts.tryAcquireLock ?? tryAcquireOwnedViewLock;
|
|
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();
|
|
107
117
|
// Identity-aware observation/signalling for host recovery (issue #70). Callers must
|
|
108
118
|
// only signal after observeProcess returned "owned" for that exact identity.
|
|
109
119
|
const observeProcessImpl = opts.observeProcess ?? defaultObserveProcess;
|
|
@@ -144,9 +154,11 @@ export function createService(opts) {
|
|
|
144
154
|
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
145
155
|
* @param {string} prompt
|
|
146
156
|
* @param {RunKind} kind
|
|
147
|
-
* @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).
|
|
148
160
|
*/
|
|
149
|
-
function launchForView(meta, prompt, kind) {
|
|
161
|
+
async function launchForView(meta, prompt, kind) {
|
|
150
162
|
const runId = newRunId();
|
|
151
163
|
/** @type {import("../core/types.mjs").RunConfig} */
|
|
152
164
|
const config = {
|
|
@@ -163,9 +175,17 @@ export function createService(opts) {
|
|
|
163
175
|
thinkingLevel: meta.defaultThinking ?? null,
|
|
164
176
|
tools: null,
|
|
165
177
|
};
|
|
178
|
+
// Final-review F2 residual (fix round 2): mark_queued must LAND before the
|
|
179
|
+
// runner can boot. A detached runner that boots faster than this round-trip
|
|
180
|
+
// used to send run_started while the row was still manual-completed → the
|
|
181
|
+
// command hit the manual fence → row stuck queued/alive with no status file
|
|
182
|
+
// → beats and run_finalized reject stale_run forever → permanent zombie.
|
|
183
|
+
// Spawn regardless of the result: with the coordinator unreachable the
|
|
184
|
+
// runner's own commands fail the same way either way — launch availability
|
|
185
|
+
// beats strict ordering.
|
|
186
|
+
await markQueued(meta.id, runId);
|
|
166
187
|
const { pid } = launch(root, config, { runnerScript: opts.runnerScript });
|
|
167
188
|
appendDiagnostic(root, meta.id, { source: "service", runId, code: "launch_run", message: "Detached runner launched", details: { kind, pid } });
|
|
168
|
-
markQueued(meta.id, runId);
|
|
169
189
|
return { runId, pid };
|
|
170
190
|
}
|
|
171
191
|
|
|
@@ -176,9 +196,9 @@ export function createService(opts) {
|
|
|
176
196
|
* can never stack a second host on top of a live claim.
|
|
177
197
|
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
178
198
|
* @param {string|null} initialPrompt
|
|
179
|
-
* @returns {{ ok: true, status: "started"|"pending"|"reused", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }}
|
|
199
|
+
* @returns {Promise<{ ok: true, status: "started"|"pending"|"reused", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }>}
|
|
180
200
|
*/
|
|
181
|
-
function launchHost(meta, initialPrompt, launchOpts = {}) {
|
|
201
|
+
async function launchHost(meta, initialPrompt, launchOpts = {}) {
|
|
182
202
|
let lease;
|
|
183
203
|
try {
|
|
184
204
|
lease = acquireLockImpl(root, meta.id, "host-start", { waitMs: HOST_START_LOCK_WAIT_MS, identity: serviceIdentity() });
|
|
@@ -190,7 +210,12 @@ export function createService(opts) {
|
|
|
190
210
|
return pendingLaunchResult(existing);
|
|
191
211
|
}
|
|
192
212
|
try {
|
|
193
|
-
|
|
213
|
+
// Same ordering contract as launchForView (final-review F2 residual):
|
|
214
|
+
// the row must leave completed/fenced before the host can start work.
|
|
215
|
+
// `return await` (not `return`) keeps the host-start lease held until
|
|
216
|
+
// the two-phase claim transaction settles.
|
|
217
|
+
if (launchOpts.markQueued !== false) await markQueued(meta.id, null);
|
|
218
|
+
return await startHostUnderLease(meta, initialPrompt, launchOpts);
|
|
194
219
|
} finally {
|
|
195
220
|
try { lease.release(); } catch { /* best effort */ }
|
|
196
221
|
}
|
|
@@ -228,7 +253,7 @@ export function createService(opts) {
|
|
|
228
253
|
* lease; this function never acquires or releases it.
|
|
229
254
|
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
230
255
|
* @param {string|null} initialPrompt
|
|
231
|
-
* @returns {
|
|
256
|
+
* @returns {{ ok: true, status: "reused"|"pending"|"started", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }}
|
|
232
257
|
*/
|
|
233
258
|
function startHostUnderLease(meta, initialPrompt, launchOpts = {}) {
|
|
234
259
|
const existing = readHost(root, meta.id);
|
|
@@ -304,7 +329,6 @@ export function createService(opts) {
|
|
|
304
329
|
}
|
|
305
330
|
updateOwnedHost(root, meta.id, instanceId, (h) => ({ ...h, runnerPid: pid, runnerSpawnedAt: nowImpl() }));
|
|
306
331
|
appendDiagnostic(root, meta.id, { source: "service", code: "launch_host", message: "PTY host launched", details: { pid, instanceId, hasInitialPrompt: Boolean(initialPrompt) } });
|
|
307
|
-
if (launchOpts.markQueued !== false) markQueued(meta.id, null);
|
|
308
332
|
return { ok: true, status: "started", pid, socketPath, instanceId };
|
|
309
333
|
}
|
|
310
334
|
|
|
@@ -337,17 +361,36 @@ export function createService(opts) {
|
|
|
337
361
|
/**
|
|
338
362
|
* Apply immediate heuristic auto-state and, when configured, queue a detached
|
|
339
363
|
* model pass to refine the row without blocking the live Pi child.
|
|
364
|
+
*
|
|
365
|
+
* Issue #91 (A8, service source): the classification lands through the View
|
|
366
|
+
* State Coordinator — the single writer of state.json/status.json. On apply,
|
|
367
|
+
* projection-relevant fields are refreshed from the materialized state so the
|
|
368
|
+
* caller's foreground projection (writeForegroundState) cannot clobber the
|
|
369
|
+
* patch with stale in-memory values. Ambiguous transport outcomes (timeout /
|
|
370
|
+
* connection_reset) never fall back to a direct write — the command may
|
|
371
|
+
* already be journaled, and the coordinator's boot replay is the recovery
|
|
372
|
+
* path. coordinator_disabled keeps the pre-coordinator in-memory apply.
|
|
340
373
|
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
341
|
-
* @param {import("../core/types.mjs").RunStatus} status
|
|
374
|
+
* @param {import("../core/types.mjs").RunStatus} status refreshed in place on apply
|
|
342
375
|
* @param {import("../core/types.mjs").EvidenceSnapshot} evidence
|
|
343
|
-
* @returns {boolean}
|
|
376
|
+
* @returns {Promise<boolean>} whether the classification was applied
|
|
344
377
|
*/
|
|
345
|
-
function queueAutoState(meta, status, evidence) {
|
|
378
|
+
async function queueAutoState(meta, status, evidence) {
|
|
346
379
|
if (!autoStateEnabled()) return false;
|
|
347
380
|
if (status.processState === "alive" || status.semanticState === "failed" || status.semanticState === "stopped") return false;
|
|
348
381
|
const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
|
|
349
382
|
if (!latest.trim()) return false;
|
|
350
|
-
const
|
|
383
|
+
const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
|
|
384
|
+
const commandRunId = status.runId === "foreground" ? null : status.runId;
|
|
385
|
+
const result = await sendStateCommandImpl(root, {
|
|
386
|
+
type: "state_command",
|
|
387
|
+
viewId: meta.id,
|
|
388
|
+
runId: commandRunId,
|
|
389
|
+
source: "service",
|
|
390
|
+
kind: "auto_state_classified",
|
|
391
|
+
expectedRevision: null,
|
|
392
|
+
payload: { classification },
|
|
393
|
+
});
|
|
351
394
|
if (opts.autoStateRunnerScript) {
|
|
352
395
|
try {
|
|
353
396
|
launchAutoStateImpl(root, {
|
|
@@ -362,11 +405,47 @@ export function createService(opts) {
|
|
|
362
405
|
appendDiagnostic(root, meta.id, { source: "service", level: "warn", code: "auto_state_launch_failed", message: "Auto-state classifier could not be launched", details: { error: err instanceof Error ? err.message : String(err) } });
|
|
363
406
|
}
|
|
364
407
|
}
|
|
365
|
-
|
|
408
|
+
if (result.status === "applied") {
|
|
409
|
+
const fresh = readState(root, meta.id);
|
|
410
|
+
if (fresh) {
|
|
411
|
+
status.semanticState = fresh.semanticState;
|
|
412
|
+
status.summary = fresh.summary;
|
|
413
|
+
status.question = fresh.question;
|
|
414
|
+
status.autoState = fresh.autoState ?? null;
|
|
415
|
+
}
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
if (result.reason === "coordinator_disabled") {
|
|
419
|
+
return applyAutoStateToStatus(status, classification, Date.now());
|
|
420
|
+
}
|
|
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 } });
|
|
426
|
+
return false;
|
|
366
427
|
}
|
|
367
428
|
|
|
368
|
-
/**
|
|
369
|
-
|
|
429
|
+
/**
|
|
430
|
+
* Fire a state command for a non-run lifecycle/metadata mutation and surface
|
|
431
|
+
* non-applied outcomes. Ambiguity contract (issue #91): timeout /
|
|
432
|
+
* connection_reset mean the command MAY already be journaled — never fall
|
|
433
|
+
* back to a direct write on those; only the explicit
|
|
434
|
+
* AGENT_BOARD_COORDINATOR=off escape hatch takes the legacy branch (the
|
|
435
|
+
* caller checks `reason === "coordinator_disabled"`).
|
|
436
|
+
* @param {object} command
|
|
437
|
+
* @param {string} [viewId] defaults to command.viewId
|
|
438
|
+
*/
|
|
439
|
+
async function sendLifecycleCommand(command, viewId = command.viewId) {
|
|
440
|
+
const result = await sendStateCommandImpl(root, command);
|
|
441
|
+
if (result.status !== "applied" && result.reason !== "coordinator_disabled") {
|
|
442
|
+
appendDiagnostic(root, viewId, { source: "service", level: "warn", code: "state_command_not_applied", message: `${command.kind} ${result.status} (${result.reason})`, details: { kind: command.kind, reason: result.reason } });
|
|
443
|
+
}
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Legacy direct write for mark_queued — coordinator_disabled escape hatch only. */
|
|
448
|
+
function markQueuedDirect(viewId, runId) {
|
|
370
449
|
const state = readState(root, viewId) ?? blankState(viewId);
|
|
371
450
|
state.currentRunId = runId;
|
|
372
451
|
state.semanticState = "queued";
|
|
@@ -383,23 +462,111 @@ export function createService(opts) {
|
|
|
383
462
|
writeState(root, state);
|
|
384
463
|
}
|
|
385
464
|
|
|
386
|
-
/** @param {string} viewId @returns {{
|
|
387
|
-
function
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
465
|
+
/** @param {string} viewId @param {string|null} runId @returns {Promise<{status: string, reason: string|null, materializedRevision?: number}>} */
|
|
466
|
+
function markQueued(viewId, runId) {
|
|
467
|
+
if (coordinatorDisabled()) {
|
|
468
|
+
markQueuedDirect(viewId, runId);
|
|
469
|
+
return Promise.resolve({ status: "applied", reason: "coordinator_disabled" });
|
|
470
|
+
}
|
|
471
|
+
// Fire-and-forget: legacy issued this write right after launch() without
|
|
472
|
+
// waiting on the runner, and the coordinator serializes arrival order the
|
|
473
|
+
// same way. N1 (Task 1): command.runId stays null — the new run's id rides
|
|
474
|
+
// in the payload and the decision branch pins currentRunId, so the generic
|
|
475
|
+
// stale-run guard cannot fire against the PREVIOUS run.
|
|
476
|
+
//
|
|
477
|
+
// source is "dashboard-user" on purpose (final-review F1): launching a run
|
|
478
|
+
// from these flows is ALWAYS user intent (reply / dispatch / attach), and
|
|
479
|
+
// the manual fence exists to protect the user's verdict from AUTOMATED late
|
|
480
|
+
// writes — a user-initiated re-launch IS the user changing the verdict, so
|
|
481
|
+
// mark_queued must lift the fence for the rest of the new run (otherwise a
|
|
482
|
+
// reply on a done row would execute invisibly while the row stays
|
|
483
|
+
// completed). Automated re-launch stays guarded upstream: the job-runner's
|
|
484
|
+
// post-exit follow-up claim refuses manually-completed rows
|
|
485
|
+
// (drainQueuedFollowUp: `if (isManualCompletion(readState(...))) return;`),
|
|
486
|
+
// and the service-side drain path delivers already-queued user follow-ups
|
|
487
|
+
// with no fence by design (prompt-not-lost, issue #70 — its live-host input
|
|
488
|
+
// path never fenced either).
|
|
489
|
+
// Fix round 2 (launch ordering): this promise is now AWAITED by launchForView
|
|
490
|
+
// / launchHost before they spawn — see the ordering notes there.
|
|
491
|
+
return sendLifecycleCommand({
|
|
492
|
+
type: "state_command",
|
|
493
|
+
viewId,
|
|
494
|
+
runId: null,
|
|
495
|
+
source: "dashboard-user",
|
|
496
|
+
kind: "mark_queued",
|
|
497
|
+
expectedRevision: null,
|
|
498
|
+
payload: { runId },
|
|
499
|
+
}).catch(() => ({ status: "rejected", reason: "connection_reset" }));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Legacy direct write for markVisited — coordinator_disabled escape hatch only. */
|
|
503
|
+
function markVisitedDirect(row) {
|
|
504
|
+
const state = readState(root, row.meta.id) ?? row.state ?? blankState(row.meta.id);
|
|
391
505
|
state.lastVisitedAt = Date.now();
|
|
392
506
|
state.updatedAt = Date.now();
|
|
393
507
|
writeState(root, state);
|
|
394
|
-
return { ok: true };
|
|
395
508
|
}
|
|
396
509
|
|
|
397
|
-
/**
|
|
398
|
-
|
|
510
|
+
/**
|
|
511
|
+
* @param {string} viewId
|
|
512
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
513
|
+
*/
|
|
514
|
+
async function markVisited(viewId) {
|
|
399
515
|
const row = loadRow(root, viewId);
|
|
400
516
|
if (!row) return { ok: false, error: "Unknown session" };
|
|
401
|
-
if (
|
|
402
|
-
|
|
517
|
+
if (coordinatorDisabled()) {
|
|
518
|
+
markVisitedDirect(row);
|
|
519
|
+
return { ok: true };
|
|
520
|
+
}
|
|
521
|
+
// dashboard-user (not "service"): visiting is a user action, and the
|
|
522
|
+
// manual fence must not stop visit-recency tracking on completed rows —
|
|
523
|
+
// legacy stamped lastVisitedAt unconditionally. Whitelist: lastVisitedAt only.
|
|
524
|
+
const result = await sendLifecycleCommand({
|
|
525
|
+
type: "state_command",
|
|
526
|
+
viewId,
|
|
527
|
+
source: "dashboard-user",
|
|
528
|
+
kind: "patch_fields",
|
|
529
|
+
expectedRevision: null,
|
|
530
|
+
payload: { state: { lastVisitedAt: Date.now() } },
|
|
531
|
+
}, viewId);
|
|
532
|
+
if (result.reason === "coordinator_disabled") markVisitedDirect(row);
|
|
533
|
+
// Visit tracking is best-effort metadata — never fail the UI action.
|
|
534
|
+
return { ok: true };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Legacy direct write for adoptSession — coordinator_disabled escape hatch only. */
|
|
538
|
+
function adoptStateDirect(viewId, state) {
|
|
539
|
+
const target = state ?? readState(root, viewId) ?? blankState(viewId);
|
|
540
|
+
target.semanticState = "idle";
|
|
541
|
+
target.processState = "exited";
|
|
542
|
+
target.needsInput = false;
|
|
543
|
+
target.hasError = false;
|
|
544
|
+
target.question = null;
|
|
545
|
+
target.pendingQuestions = [];
|
|
546
|
+
target.error = null;
|
|
547
|
+
target.summary = "Backgrounded session";
|
|
548
|
+
target.updatedAt = Date.now();
|
|
549
|
+
target.lastActivityAt = Date.now();
|
|
550
|
+
writeState(root, target);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Route the adopt-idle transition through the coordinator (fenced, journaled). */
|
|
554
|
+
async function adoptIdleState(viewId, state) {
|
|
555
|
+
const result = await sendLifecycleCommand({
|
|
556
|
+
type: "state_command",
|
|
557
|
+
viewId,
|
|
558
|
+
runId: null,
|
|
559
|
+
source: "dashboard-user",
|
|
560
|
+
kind: "adopt_session",
|
|
561
|
+
expectedRevision: null,
|
|
562
|
+
payload: {},
|
|
563
|
+
}, viewId);
|
|
564
|
+
if (result.reason === "coordinator_disabled") adoptStateDirect(viewId, state);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Legacy direct-write completion — only reachable when the coordinator is
|
|
568
|
+
* explicitly disabled (AGENT_BOARD_COORDINATOR=off tests/escape hatch). */
|
|
569
|
+
function completeViewDirect(state) {
|
|
403
570
|
state.semanticState = "completed";
|
|
404
571
|
state.processState = "exited";
|
|
405
572
|
state.needsInput = false;
|
|
@@ -414,21 +581,72 @@ export function createService(opts) {
|
|
|
414
581
|
// Also clear autoState in the run status so in-flight model passes
|
|
415
582
|
// (job-runner / state-runner) see the manual completion and skip refinement.
|
|
416
583
|
if (state.currentRunId) {
|
|
417
|
-
const status = readStatus(root, viewId, state.currentRunId);
|
|
584
|
+
const status = readStatus(root, state.viewId, state.currentRunId);
|
|
418
585
|
if (status) {
|
|
419
586
|
status.autoState = null;
|
|
420
587
|
writeStatus(root, status);
|
|
421
588
|
}
|
|
422
589
|
}
|
|
423
590
|
writeState(root, state);
|
|
424
|
-
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Explicitly mark an inactive session as done via the View State Coordinator
|
|
595
|
+
* (issue #91): the command is journaled and materialized by the single owner,
|
|
596
|
+
* which also rejects stale-run and fenced manual-completion overwrites.
|
|
597
|
+
* @param {string} viewId
|
|
598
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
599
|
+
*/
|
|
600
|
+
async function completeView(viewId) {
|
|
601
|
+
const row = loadRow(root, viewId);
|
|
602
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
603
|
+
// Fast local pre-check for instant UI feedback; the coordinator's decision
|
|
604
|
+
// stays authoritative (its "busy" rejection maps to the same wording).
|
|
605
|
+
if (isAgentBusy(row)) return { ok: false, error: "Wait for the active run to finish before marking done" };
|
|
606
|
+
const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
|
|
607
|
+
const result = await sendStateCommandImpl(root, {
|
|
608
|
+
type: "state_command",
|
|
609
|
+
viewId,
|
|
610
|
+
runId: state.currentRunId ?? null,
|
|
611
|
+
source: "dashboard-user",
|
|
612
|
+
kind: "mark_completed",
|
|
613
|
+
expectedRevision: null,
|
|
614
|
+
payload: {},
|
|
615
|
+
});
|
|
616
|
+
if (result.status === "applied") return { ok: true };
|
|
617
|
+
if (result.reason === "busy") return { ok: false, error: "Wait for the active run to finish before marking done" };
|
|
618
|
+
if (result.reason === "coordinator_disabled") {
|
|
619
|
+
completeViewDirect(state);
|
|
620
|
+
return { ok: true };
|
|
621
|
+
}
|
|
622
|
+
// Ambiguous outcomes (timeout / connection_reset: the command MAY already be
|
|
623
|
+
// journaled) and real rejections surface verbatim — never fall back to a
|
|
624
|
+
// direct write here, it would bypass the single-writer fence.
|
|
625
|
+
return { ok: false, error: result.reason ?? "state_command_failed" };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Legacy direct write for archiveView's busy-row stop — coordinator_disabled escape hatch only. */
|
|
629
|
+
function archiveStateDirect(row) {
|
|
630
|
+
const state = readState(root, row.meta.id) ?? row.state ?? blankState(row.meta.id);
|
|
631
|
+
state.semanticState = "stopped";
|
|
632
|
+
state.processState = "exited";
|
|
633
|
+
state.needsInput = false;
|
|
634
|
+
state.hasError = false;
|
|
635
|
+
state.question = null;
|
|
636
|
+
state.pendingQuestions = [];
|
|
637
|
+
state.error = null;
|
|
638
|
+
state.autoState = null;
|
|
639
|
+
state.summary = "Stopped";
|
|
640
|
+
state.lastActivityAt = Date.now();
|
|
641
|
+
state.updatedAt = Date.now();
|
|
642
|
+
writeState(root, state);
|
|
425
643
|
}
|
|
426
644
|
|
|
427
645
|
/**
|
|
428
646
|
* @param {string} viewId
|
|
429
|
-
* @returns {{ ok: boolean, error?: string }}
|
|
647
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
430
648
|
*/
|
|
431
|
-
function archiveView(viewId) {
|
|
649
|
+
async function archiveView(viewId) {
|
|
432
650
|
const row = loadRow(root, viewId);
|
|
433
651
|
if (!row) return { ok: false, error: "Unknown session" };
|
|
434
652
|
if (row.host?.instanceId) stopHostRow(row, "archive");
|
|
@@ -438,20 +656,19 @@ export function createService(opts) {
|
|
|
438
656
|
if (pid) killProcess(pid);
|
|
439
657
|
}
|
|
440
658
|
if (isAgentBusy(row)) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
state.updatedAt = Date.now();
|
|
453
|
-
writeState(root, state);
|
|
659
|
+
// dashboard-user: archiving is a user action. Busy rows are allowed by
|
|
660
|
+
// the decision kind (archiving a working row stops it — legacy parity).
|
|
661
|
+
const result = await sendLifecycleCommand({
|
|
662
|
+
type: "state_command",
|
|
663
|
+
viewId,
|
|
664
|
+
source: "dashboard-user",
|
|
665
|
+
kind: "archive_view",
|
|
666
|
+
expectedRevision: null,
|
|
667
|
+
payload: {},
|
|
668
|
+
}, viewId);
|
|
669
|
+
if (result.reason === "coordinator_disabled") archiveStateDirect(row);
|
|
454
670
|
}
|
|
671
|
+
foregroundPreviewCache.forget(viewId);
|
|
455
672
|
row.meta.archived = true;
|
|
456
673
|
writeMeta(root, row.meta);
|
|
457
674
|
return { ok: true };
|
|
@@ -525,14 +742,34 @@ export function createService(opts) {
|
|
|
525
742
|
/**
|
|
526
743
|
* @param {import("../core/store.mjs").Row} row
|
|
527
744
|
* @param {import("../core/types.mjs").RunStatus} status
|
|
745
|
+
* @returns {Promise<void>}
|
|
528
746
|
*/
|
|
529
|
-
function writeForegroundState(row, status) {
|
|
747
|
+
async function writeForegroundState(row, status) {
|
|
530
748
|
const projected = projectViewState(status, Date.now(), readState(root, row.meta.id) ?? row.state ?? null);
|
|
531
749
|
// Foreground turns are driven by the interactive Pi process, not a detached
|
|
532
750
|
// runner, so keep currentRunId null. This prevents reconcile()/stop() from
|
|
533
|
-
// treating a foreground turn as a managed background runner pid.
|
|
751
|
+
// treating a foreground turn as a managed background runner pid. (The
|
|
752
|
+
// decision kind force-nulls it too; the caller-side null keeps the
|
|
753
|
+
// coordinator_disabled direct write at legacy parity.)
|
|
534
754
|
projected.currentRunId = null;
|
|
535
|
-
|
|
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);
|
|
759
|
+
if (coordinatorDisabled()) {
|
|
760
|
+
writeState(root, projected);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
const result = await sendLifecycleCommand({
|
|
764
|
+
type: "state_command",
|
|
765
|
+
viewId: row.meta.id,
|
|
766
|
+
runId: null,
|
|
767
|
+
source: "service",
|
|
768
|
+
kind: "sync_foreground",
|
|
769
|
+
expectedRevision: null,
|
|
770
|
+
payload: { projection: projected },
|
|
771
|
+
}, row.meta.id);
|
|
772
|
+
if (result.reason === "coordinator_disabled") writeState(root, projected);
|
|
536
773
|
}
|
|
537
774
|
|
|
538
775
|
/** @param {string} sessionFile */
|
|
@@ -1086,7 +1323,7 @@ export function createService(opts) {
|
|
|
1086
1323
|
* async: the item is only completed on input_ack; any failure releases it
|
|
1087
1324
|
* back to queued for a later retry (issue #70 A13).
|
|
1088
1325
|
* @param {string} viewId
|
|
1089
|
-
* @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)
|
|
1090
1327
|
*/
|
|
1091
1328
|
async function drainNextFollowUp(viewId) {
|
|
1092
1329
|
const row = loadRow(root, viewId);
|
|
@@ -1116,13 +1353,27 @@ export function createService(opts) {
|
|
|
1116
1353
|
return { ok: true, pending: true, item };
|
|
1117
1354
|
}
|
|
1118
1355
|
const sent = socketPath
|
|
1119
|
-
? 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
|
+
})
|
|
1120
1362
|
: { ok: false, error: "No host socket", retryable: true };
|
|
1121
1363
|
if (sent.ok) {
|
|
1122
1364
|
completeFollowUp(root, viewId, item.id);
|
|
1123
1365
|
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind: item.kind } });
|
|
1124
1366
|
return { ok: true, sent: true, item };
|
|
1125
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
|
+
}
|
|
1126
1377
|
releaseFollowUp(root, viewId, item.id);
|
|
1127
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 } });
|
|
1128
1379
|
return { ok: true, pending: true, item };
|
|
@@ -1135,7 +1386,7 @@ export function createService(opts) {
|
|
|
1135
1386
|
let hostMode = "pty";
|
|
1136
1387
|
if (!pty.ok) hostMode = "json-runner";
|
|
1137
1388
|
if (pty.ok) {
|
|
1138
|
-
const launched = launchHost(row.meta, prompt);
|
|
1389
|
+
const launched = await launchHost(row.meta, prompt);
|
|
1139
1390
|
if (launched.ok && launched.status !== "started") {
|
|
1140
1391
|
// An existing claim owns the view; the item stays queued and retries
|
|
1141
1392
|
// after that host becomes ready (issue #70 prompt-not-lost invariant).
|
|
@@ -1152,7 +1403,7 @@ export function createService(opts) {
|
|
|
1152
1403
|
return { ok: false, error: "PTY is required to drain adopted session follow-ups safely" };
|
|
1153
1404
|
}
|
|
1154
1405
|
if (hostMode === "json-runner") {
|
|
1155
|
-
runId = launchForView(row.meta, prompt, runKindForFollowUp(item))
|
|
1406
|
+
({ runId } = await launchForView(row.meta, prompt, runKindForFollowUp(item)));
|
|
1156
1407
|
}
|
|
1157
1408
|
completeFollowUp(root, viewId, item.id, { runId });
|
|
1158
1409
|
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_started", message: "Queued follow-up started", details: { kind: item.kind, hostMode } });
|
|
@@ -1164,10 +1415,22 @@ export function createService(opts) {
|
|
|
1164
1415
|
}
|
|
1165
1416
|
}
|
|
1166
1417
|
|
|
1167
|
-
/**
|
|
1168
|
-
|
|
1418
|
+
/**
|
|
1419
|
+
* Mirror a foreground/hosted session event into the row state. Async since
|
|
1420
|
+
* issue #91: the terminal classification routes through the View State
|
|
1421
|
+
* Coordinator command socket (fire-and-forget callers are fine — the returned
|
|
1422
|
+
* promise resolves after the classification outcome is known).
|
|
1423
|
+
* @param {import("../core/store.mjs").Row} row @param {any} event
|
|
1424
|
+
* @returns {Promise<boolean>} whether a managed row was updated
|
|
1425
|
+
*/
|
|
1426
|
+
async function syncRowEvent(row, event) {
|
|
1169
1427
|
const now = Date.now();
|
|
1170
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);
|
|
1171
1434
|
let evidence = readEvidence(root, row.meta.id);
|
|
1172
1435
|
if (!evidence.viewId) evidence = emptyEvidenceSnapshot({ viewId: row.meta.id, source: "hosted" });
|
|
1173
1436
|
try {
|
|
@@ -1187,26 +1450,55 @@ export function createService(opts) {
|
|
|
1187
1450
|
status.error = null;
|
|
1188
1451
|
status.summary = "Running…";
|
|
1189
1452
|
status.lastActivityAt = now;
|
|
1190
|
-
|
|
1453
|
+
// Throughput path: periodic self-healing mirror, fire-and-forget.
|
|
1454
|
+
void writeForegroundState(row, status);
|
|
1191
1455
|
return true;
|
|
1192
1456
|
}
|
|
1193
1457
|
|
|
1194
1458
|
if (event.type === "agent_end") {
|
|
1459
|
+
// #46-class fence (issue #91): a manual completion is a user verdict that
|
|
1460
|
+
// outlives the turn. A late/duplicate agent_end after markCompleted must
|
|
1461
|
+
// not resurrect the row via finalizeRun + projection — skip all semantic
|
|
1462
|
+
// writes (baseline, steering, classification) and keep evidence only.
|
|
1463
|
+
// The fence read is repeated after the classification await below; the
|
|
1464
|
+
// window between the two reads is covered by the coordinator's own
|
|
1465
|
+
// manual_fence rejection plus the guarded tail write.
|
|
1466
|
+
if (isManualCompletion(readState(root, row.meta.id))) {
|
|
1467
|
+
status.evidenceSummary = summarizeEvidence(evidence);
|
|
1468
|
+
writeEvidence(root, evidence);
|
|
1469
|
+
return false;
|
|
1470
|
+
}
|
|
1195
1471
|
finalizeRun(status, { exitCode: 0 }, now);
|
|
1196
1472
|
finalizeEvidence(evidence, status, now);
|
|
1197
1473
|
const steering = readSteering(root, row.meta.id);
|
|
1474
|
+
let classificationQueued = false;
|
|
1198
1475
|
if (steering.status === "plan_requested" || steering.status === "changes_requested") {
|
|
1199
1476
|
recordPlanReady(root, row.meta.id, { planText: latestEvidenceText(evidence) || status.latestAssistantPreview || evidence.summary || "Plan ready", runId: status.runId });
|
|
1200
1477
|
status.semanticState = "needs_input";
|
|
1201
1478
|
status.question = "Approve this plan?";
|
|
1202
1479
|
status.summary = "Plan ready for approval";
|
|
1203
|
-
} else
|
|
1204
|
-
|
|
1480
|
+
} else {
|
|
1481
|
+
// Baseline the exited projection BEFORE the classification command:
|
|
1482
|
+
// the coordinator's delegated auto-state rules key on processState,
|
|
1483
|
+
// and its materialized patch lands after this write and wins. When
|
|
1484
|
+
// the coordinator applies, the projection write below is skipped so
|
|
1485
|
+
// the stale in-memory fields cannot clobber the patch (#46 class).
|
|
1486
|
+
// The legacy coordinator_disabled path applies in-memory instead and
|
|
1487
|
+
// still needs the tail projection write (pre-coordinator behavior).
|
|
1488
|
+
// Awaited: this ordering IS the fence — the classification command
|
|
1489
|
+
// must observe processState "exited" or its delegated rules skip.
|
|
1490
|
+
await writeForegroundState(row, status);
|
|
1491
|
+
classificationQueued = await queueAutoState(row.meta, status, evidence);
|
|
1492
|
+
if (classificationQueued) finalizeEvidence(evidence, status, now);
|
|
1205
1493
|
}
|
|
1206
1494
|
status.evidenceSummary = summarizeEvidence(evidence);
|
|
1207
1495
|
writeEvidence(root, evidence);
|
|
1208
1496
|
updateCodeRefsFromEvidence(root, row.meta.id, evidence, row.meta);
|
|
1209
|
-
|
|
1497
|
+
// Re-read the fence after the classification await: a completion landing
|
|
1498
|
+
// between the baseline write and the coordinator's decision read gets
|
|
1499
|
+
// manual_fence back (classificationQueued=false), and writing the stale
|
|
1500
|
+
// in-memory projection here would clobber it (#46 class).
|
|
1501
|
+
if (!isManualCompletion(readState(root, row.meta.id)) && !(classificationQueued && !coordinatorDisabled())) await writeForegroundState(row, status);
|
|
1210
1502
|
pruneWarmHosts({ keepViewId: row.meta.id });
|
|
1211
1503
|
// Async delivery (ack-gated, issue #70 A13): fire-and-forget here — the
|
|
1212
1504
|
// queue item's own state records the outcome, ordering is preserved by
|
|
@@ -1217,7 +1509,8 @@ export function createService(opts) {
|
|
|
1217
1509
|
|
|
1218
1510
|
if (reduceEvent(status, event, now, { interactive: true })) {
|
|
1219
1511
|
status.processState = "alive";
|
|
1220
|
-
|
|
1512
|
+
// Throughput path: periodic self-healing mirror, fire-and-forget.
|
|
1513
|
+
void writeForegroundState(row, status);
|
|
1221
1514
|
return true;
|
|
1222
1515
|
}
|
|
1223
1516
|
return false;
|
|
@@ -1237,9 +1530,9 @@ export function createService(opts) {
|
|
|
1237
1530
|
* model?: string|null,
|
|
1238
1531
|
* thinkingLevel?: "off"|"minimal"|"low"|"medium"|"high"|"xhigh"|"max"|null,
|
|
1239
1532
|
* }} [dispatchOpts]
|
|
1240
|
-
* @returns {{ ok: boolean, viewId?: string, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }}
|
|
1533
|
+
* @returns {Promise<{ ok: boolean, viewId?: string, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }>}
|
|
1241
1534
|
*/
|
|
1242
|
-
dispatch(text, dispatchOpts = {}) {
|
|
1535
|
+
async dispatch(text, dispatchOpts = {}) {
|
|
1243
1536
|
const prompt = String(text || "").trim();
|
|
1244
1537
|
if (!prompt) return { ok: false, error: "Empty task" };
|
|
1245
1538
|
|
|
@@ -1271,7 +1564,7 @@ export function createService(opts) {
|
|
|
1271
1564
|
let fallbackReason;
|
|
1272
1565
|
let queued;
|
|
1273
1566
|
if (pty.ok) {
|
|
1274
|
-
const launched = launchHost(meta, prompt);
|
|
1567
|
+
const launched = await launchHost(meta, prompt);
|
|
1275
1568
|
if (launched.ok) {
|
|
1276
1569
|
hostMode = "pty";
|
|
1277
1570
|
if (launched.status !== "started") {
|
|
@@ -1283,11 +1576,11 @@ export function createService(opts) {
|
|
|
1283
1576
|
}
|
|
1284
1577
|
} else {
|
|
1285
1578
|
fallbackReason = launched.fallbackReason ?? launched.error;
|
|
1286
|
-
launchForView(meta, prompt, "dispatch");
|
|
1579
|
+
await launchForView(meta, prompt, "dispatch");
|
|
1287
1580
|
}
|
|
1288
1581
|
} else {
|
|
1289
1582
|
fallbackReason = nodePtyFallbackMessage(pty);
|
|
1290
|
-
launchForView(meta, prompt, "dispatch");
|
|
1583
|
+
await launchForView(meta, prompt, "dispatch");
|
|
1291
1584
|
}
|
|
1292
1585
|
queueGeneratedTitle(meta, prompt);
|
|
1293
1586
|
return {
|
|
@@ -1340,13 +1633,24 @@ export function createService(opts) {
|
|
|
1340
1633
|
}
|
|
1341
1634
|
const socketPath = row.host?.socketPath;
|
|
1342
1635
|
const sent = socketPath
|
|
1343
|
-
? 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
|
+
})
|
|
1344
1642
|
: { ok: false, error: "No host socket", retryable: true };
|
|
1345
1643
|
if (sent.ok) {
|
|
1346
1644
|
completeFollowUp(root, viewId, queued.item.id);
|
|
1347
1645
|
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind } });
|
|
1348
1646
|
return { ok: true, sent: true, item: queued.item };
|
|
1349
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
|
+
}
|
|
1350
1654
|
// Not an error: starting host, busy socket or lost ack — the drain
|
|
1351
1655
|
// loop retries once the host acks (issue #70 prompt-not-lost).
|
|
1352
1656
|
return { ok: true, queued: true, summary: queued.summary };
|
|
@@ -1356,7 +1660,7 @@ export function createService(opts) {
|
|
|
1356
1660
|
let hostMode = null;
|
|
1357
1661
|
let fallbackReason;
|
|
1358
1662
|
if (pty.ok) {
|
|
1359
|
-
const launched = launchHost(row.meta, prompt);
|
|
1663
|
+
const launched = await launchHost(row.meta, prompt);
|
|
1360
1664
|
if (launched.ok) {
|
|
1361
1665
|
if (launched.status !== "started") {
|
|
1362
1666
|
// The existing claim will consume the prompt later — keep it queued
|
|
@@ -1377,7 +1681,7 @@ export function createService(opts) {
|
|
|
1377
1681
|
}
|
|
1378
1682
|
if (!hostMode) {
|
|
1379
1683
|
if (isExternalSession(row.meta)) return { ok: false, error: "PTY is required to continue an adopted foreground session safely" };
|
|
1380
|
-
launchForView(row.meta, prompt, runKindForKind(kind));
|
|
1684
|
+
await launchForView(row.meta, prompt, runKindForKind(kind));
|
|
1381
1685
|
hostMode = "json-runner";
|
|
1382
1686
|
}
|
|
1383
1687
|
return { ok: true, hostMode, ...(hostMode === "json-runner" ? { fallbackReason } : {}) };
|
|
@@ -1475,7 +1779,7 @@ export function createService(opts) {
|
|
|
1475
1779
|
return { kind: "session", sessionFile: row.meta.sessionFile };
|
|
1476
1780
|
},
|
|
1477
1781
|
|
|
1478
|
-
adoptSession(adoptOpts = {}) {
|
|
1782
|
+
async adoptSession(adoptOpts = {}) {
|
|
1479
1783
|
const sessionFile = String(adoptOpts.sessionFile || "").trim();
|
|
1480
1784
|
if (!sessionFile) return { ok: false, error: "No session file to adopt" };
|
|
1481
1785
|
const existing = rowForSession(sessionFile);
|
|
@@ -1484,18 +1788,7 @@ export function createService(opts) {
|
|
|
1484
1788
|
if (adoptOpts.name) existing.meta.name = String(adoptOpts.name).trim() || existing.meta.name;
|
|
1485
1789
|
writeMeta(root, existing.meta);
|
|
1486
1790
|
if (!isAgentBusy(existing)) {
|
|
1487
|
-
|
|
1488
|
-
state.semanticState = "idle";
|
|
1489
|
-
state.processState = "exited";
|
|
1490
|
-
state.needsInput = false;
|
|
1491
|
-
state.hasError = false;
|
|
1492
|
-
state.question = null;
|
|
1493
|
-
state.pendingQuestions = [];
|
|
1494
|
-
state.error = null;
|
|
1495
|
-
state.summary = "Backgrounded session";
|
|
1496
|
-
state.updatedAt = Date.now();
|
|
1497
|
-
state.lastActivityAt = Date.now();
|
|
1498
|
-
writeState(root, state);
|
|
1791
|
+
await adoptIdleState(existing.meta.id, readState(root, existing.meta.id) ?? existing.state ?? blankState(existing.meta.id));
|
|
1499
1792
|
}
|
|
1500
1793
|
appendDiagnostic(root, existing.meta.id, { source: "service", code: "session_adopted", message: "Existing session adopted into Agent Board", details: { reused: true } });
|
|
1501
1794
|
return { ok: true, viewId: existing.meta.id, reused: true };
|
|
@@ -1517,12 +1810,7 @@ export function createService(opts) {
|
|
|
1517
1810
|
sessionFile,
|
|
1518
1811
|
});
|
|
1519
1812
|
const state = readState(root, id) ?? blankState(id);
|
|
1520
|
-
|
|
1521
|
-
state.processState = "exited";
|
|
1522
|
-
state.summary = "Backgrounded session";
|
|
1523
|
-
state.updatedAt = Date.now();
|
|
1524
|
-
state.lastActivityAt = Date.now();
|
|
1525
|
-
writeState(root, state);
|
|
1813
|
+
await adoptIdleState(id, state);
|
|
1526
1814
|
appendDiagnostic(root, id, { source: "service", code: "session_adopted", message: "Current session adopted into Agent Board", details: { reused: false } });
|
|
1527
1815
|
return { ok: true, viewId: meta.id, reused: false };
|
|
1528
1816
|
},
|
|
@@ -1560,7 +1848,7 @@ export function createService(opts) {
|
|
|
1560
1848
|
return { ok: true };
|
|
1561
1849
|
},
|
|
1562
1850
|
|
|
1563
|
-
/** @param {string} viewId @returns {{ ok: boolean, error?: string }} */
|
|
1851
|
+
/** @param {string} viewId @returns {Promise<{ ok: boolean, error?: string }>} */
|
|
1564
1852
|
markVisited(viewId) {
|
|
1565
1853
|
return markVisited(viewId);
|
|
1566
1854
|
},
|
|
@@ -1569,7 +1857,7 @@ export function createService(opts) {
|
|
|
1569
1857
|
* Explicitly mark an inactive session as done. Successful runs settle as
|
|
1570
1858
|
* `idle` until the user reviews and confirms this action from the dashboard.
|
|
1571
1859
|
* @param {string} viewId
|
|
1572
|
-
* @returns {{ ok: boolean, error?: string }}
|
|
1860
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
1573
1861
|
*/
|
|
1574
1862
|
markCompleted(viewId) {
|
|
1575
1863
|
return completeView(viewId);
|
|
@@ -1640,7 +1928,12 @@ export function createService(opts) {
|
|
|
1640
1928
|
return { state, summary: summarizeSteering(state) };
|
|
1641
1929
|
},
|
|
1642
1930
|
|
|
1643
|
-
|
|
1931
|
+
/**
|
|
1932
|
+
* Bulk mark sessions done, skipping live/already-done rows.
|
|
1933
|
+
* @param {string[]} viewIds
|
|
1934
|
+
* @returns {Promise<{ ok: boolean, completed: number, skipped: number, completedIds: string[] }>}
|
|
1935
|
+
*/
|
|
1936
|
+
async markCompletedMany(viewIds) {
|
|
1644
1937
|
const ids = [...new Set((viewIds ?? []).filter(Boolean))];
|
|
1645
1938
|
let completed = 0;
|
|
1646
1939
|
let skipped = 0;
|
|
@@ -1651,7 +1944,7 @@ export function createService(opts) {
|
|
|
1651
1944
|
skipped += 1;
|
|
1652
1945
|
continue;
|
|
1653
1946
|
}
|
|
1654
|
-
const res = completeView(viewId);
|
|
1947
|
+
const res = await completeView(viewId);
|
|
1655
1948
|
if (res.ok) {
|
|
1656
1949
|
completed += 1;
|
|
1657
1950
|
completedIds.push(viewId);
|
|
@@ -1672,9 +1965,9 @@ export function createService(opts) {
|
|
|
1672
1965
|
/**
|
|
1673
1966
|
* Bulk archive explicit row ids, skipping live/missing rows.
|
|
1674
1967
|
* @param {string[]} viewIds
|
|
1675
|
-
* @returns {{ ok: boolean, archived: number, skipped: number }}
|
|
1968
|
+
* @returns {Promise<{ ok: boolean, archived: number, skipped: number }>}
|
|
1676
1969
|
*/
|
|
1677
|
-
archiveMany(viewIds) {
|
|
1970
|
+
async archiveMany(viewIds) {
|
|
1678
1971
|
const ids = [...new Set((viewIds ?? []).filter(Boolean))];
|
|
1679
1972
|
let archived = 0;
|
|
1680
1973
|
let skipped = 0;
|
|
@@ -1684,7 +1977,7 @@ export function createService(opts) {
|
|
|
1684
1977
|
skipped += 1;
|
|
1685
1978
|
continue;
|
|
1686
1979
|
}
|
|
1687
|
-
const res = archiveView(viewId);
|
|
1980
|
+
const res = await archiveView(viewId);
|
|
1688
1981
|
if (res.ok) archived += 1;
|
|
1689
1982
|
else skipped += 1;
|
|
1690
1983
|
}
|
|
@@ -1708,6 +2001,7 @@ export function createService(opts) {
|
|
|
1708
2001
|
}
|
|
1709
2002
|
if (row.host?.instanceId) stopHostRow(row, "archive");
|
|
1710
2003
|
else if (row.hostAlive) sendHostMessage(row, { type: "terminate" });
|
|
2004
|
+
foregroundPreviewCache.forget(row.meta.id);
|
|
1711
2005
|
row.meta.archived = true;
|
|
1712
2006
|
writeMeta(root, row.meta);
|
|
1713
2007
|
archived += 1;
|
|
@@ -1719,9 +2013,9 @@ export function createService(opts) {
|
|
|
1719
2013
|
* Recovery: reconcile rows whose runner died without finalizing (e.g. machine crash
|
|
1720
2014
|
* or the runner was killed). If a terminal status exists, project it; otherwise mark
|
|
1721
2015
|
* the row failed/stale. Safe to call on every dashboard open and on session_start.
|
|
1722
|
-
* @returns {number} number of rows reconciled.
|
|
2016
|
+
* @returns {Promise<number>} number of rows reconciled.
|
|
1723
2017
|
*/
|
|
1724
|
-
reconcile() {
|
|
2018
|
+
async reconcile() {
|
|
1725
2019
|
const now = Date.now();
|
|
1726
2020
|
let fixed = 0;
|
|
1727
2021
|
for (const row of listRows(root)) {
|
|
@@ -1734,34 +2028,131 @@ export function createService(opts) {
|
|
|
1734
2028
|
// its runner pid may legitimately be absent — never finalize it to failed yet.
|
|
1735
2029
|
if (row.host.state === "starting" && now - (row.host.claimAt ?? row.host.startedAt ?? 0) < HOST_START_GRACE_MS) continue;
|
|
1736
2030
|
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);
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
2031
|
+
// F6: failed verdicts must carry a reason (legacy guaranteed an
|
|
2032
|
+
// error string). The row has no currentRunId here, so only the
|
|
2033
|
+
// state half participates (no status.json to sync).
|
|
2034
|
+
const result = await sendLifecycleCommand({
|
|
2035
|
+
type: "state_command",
|
|
2036
|
+
viewId: row.meta.id,
|
|
2037
|
+
runId: null,
|
|
2038
|
+
source: "service",
|
|
2039
|
+
kind: "reconcile_finalize",
|
|
2040
|
+
expectedRevision: null,
|
|
2041
|
+
payload: {
|
|
2042
|
+
semanticState: failed ? "failed" : "idle",
|
|
2043
|
+
summary: failed ? "Failed (PTY host exited)" : "Needs instructions",
|
|
2044
|
+
reason: failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null,
|
|
2045
|
+
},
|
|
2046
|
+
}, row.meta.id);
|
|
2047
|
+
if (result.status !== "applied") {
|
|
2048
|
+
if (result.reason === "coordinator_disabled") {
|
|
2049
|
+
s.semanticState = failed ? "failed" : "idle";
|
|
2050
|
+
s.processState = "exited";
|
|
2051
|
+
s.hasError = failed;
|
|
2052
|
+
s.needsInput = false;
|
|
2053
|
+
s.question = null;
|
|
2054
|
+
s.pendingQuestions = [];
|
|
2055
|
+
s.error = failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null;
|
|
2056
|
+
s.summary = failed ? "Failed (PTY host exited)" : "Needs instructions";
|
|
2057
|
+
s.updatedAt = now;
|
|
2058
|
+
writeState(root, s);
|
|
2059
|
+
} else continue; // rejected/ambiguous: nothing mutated, not fixed
|
|
2060
|
+
}
|
|
1747
2061
|
appendDiagnostic(root, row.meta.id, { source: "service", level: failed ? "error" : "info", code: "host_reconciled", message: failed ? "PTY host exited before final event" : "PTY host finalized without final event", details: { hostState: row.host.state, exitCode: row.host.exitCode } });
|
|
1748
2062
|
fixed += 1;
|
|
1749
2063
|
}
|
|
1750
2064
|
continue;
|
|
1751
2065
|
}
|
|
1752
2066
|
if (row.alive) continue;
|
|
1753
|
-
|
|
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
|
+
}
|
|
1754
2109
|
if (status?.endedAt) {
|
|
1755
|
-
|
|
2110
|
+
// The run's terminal status exists but the row was never materialized
|
|
2111
|
+
// from it (crash between the two writes, or a pre-coordinator row).
|
|
2112
|
+
// Project mode lets the status's own verdict govern (completed rows
|
|
2113
|
+
// must not be forced to failed/idle).
|
|
2114
|
+
const result = await sendLifecycleCommand({
|
|
2115
|
+
type: "state_command",
|
|
2116
|
+
viewId: row.meta.id,
|
|
2117
|
+
runId: s.currentRunId,
|
|
2118
|
+
source: "service",
|
|
2119
|
+
kind: "reconcile_finalize",
|
|
2120
|
+
expectedRevision: null,
|
|
2121
|
+
payload: { project: true },
|
|
2122
|
+
}, row.meta.id);
|
|
2123
|
+
if (result.status !== "applied" && result.reason !== "coordinator_disabled") {
|
|
2124
|
+
// Fix round 2 (F5 leftover): a rejected/ambiguous project-mode
|
|
2125
|
+
// row (manual fence, no_change, stale_run, timeout) mutated
|
|
2126
|
+
// nothing — it must not count toward the fixed tally.
|
|
2127
|
+
continue;
|
|
2128
|
+
}
|
|
2129
|
+
if (result.reason === "coordinator_disabled") writeState(root, projectViewState(status, now, readState(root, row.meta.id) ?? row.state ?? null));
|
|
1756
2130
|
} else {
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
2131
|
+
const result = await sendLifecycleCommand({
|
|
2132
|
+
type: "state_command",
|
|
2133
|
+
viewId: row.meta.id,
|
|
2134
|
+
runId: s.currentRunId,
|
|
2135
|
+
source: "service",
|
|
2136
|
+
kind: "reconcile_finalize",
|
|
2137
|
+
expectedRevision: null,
|
|
2138
|
+
payload: {
|
|
2139
|
+
semanticState: "failed",
|
|
2140
|
+
summary: "Failed (runner exited)",
|
|
2141
|
+
reason: s.error ?? "Runner exited unexpectedly",
|
|
2142
|
+
},
|
|
2143
|
+
}, row.meta.id);
|
|
2144
|
+
if (result.status !== "applied") {
|
|
2145
|
+
if (result.reason === "coordinator_disabled") {
|
|
2146
|
+
s.semanticState = "failed";
|
|
2147
|
+
s.processState = "exited";
|
|
2148
|
+
s.hasError = true;
|
|
2149
|
+
s.needsInput = false;
|
|
2150
|
+
s.error = s.error ?? "Runner exited unexpectedly";
|
|
2151
|
+
s.summary = "Failed (runner exited)";
|
|
2152
|
+
s.updatedAt = now;
|
|
2153
|
+
writeState(root, s);
|
|
2154
|
+
} else continue;
|
|
2155
|
+
}
|
|
1765
2156
|
}
|
|
1766
2157
|
fixed += 1;
|
|
1767
2158
|
}
|
|
@@ -1782,20 +2173,20 @@ export function createService(opts) {
|
|
|
1782
2173
|
* looking stale after the user types a follow-up in the real Pi session.
|
|
1783
2174
|
* @param {string|undefined} sessionFile
|
|
1784
2175
|
* @param {any} event
|
|
1785
|
-
* @returns {boolean} whether a managed row was updated
|
|
2176
|
+
* @returns {Promise<boolean>} whether a managed row was updated
|
|
1786
2177
|
*/
|
|
1787
2178
|
syncForegroundEvent(sessionFile, event) {
|
|
1788
|
-
if (!sessionFile || !event?.type) return false;
|
|
2179
|
+
if (!sessionFile || !event?.type) return Promise.resolve(false);
|
|
1789
2180
|
const row = rowForSession(sessionFile);
|
|
1790
|
-
if (!row) return false;
|
|
2181
|
+
if (!row) return Promise.resolve(false);
|
|
1791
2182
|
return syncRowEvent(row, event);
|
|
1792
2183
|
},
|
|
1793
2184
|
|
|
1794
2185
|
/** @param {string|undefined} viewId @param {any} event */
|
|
1795
2186
|
syncHostedEvent(viewId, event) {
|
|
1796
|
-
if (!viewId || !event?.type) return false;
|
|
2187
|
+
if (!viewId || !event?.type) return Promise.resolve(false);
|
|
1797
2188
|
const row = loadRow(root, viewId);
|
|
1798
|
-
if (!row) return false;
|
|
2189
|
+
if (!row) return Promise.resolve(false);
|
|
1799
2190
|
return syncRowEvent(row, event);
|
|
1800
2191
|
},
|
|
1801
2192
|
|
|
@@ -2040,11 +2431,184 @@ function sendHostMessage(row, message) {
|
|
|
2040
2431
|
}
|
|
2041
2432
|
|
|
2042
2433
|
/**
|
|
2043
|
-
* Default window service waits for the runner's input_ack
|
|
2044
|
-
* 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).
|
|
2045
2437
|
*/
|
|
2046
2438
|
const HOST_INPUT_ACK_TIMEOUT_MS = 2_000;
|
|
2047
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
|
+
|
|
2048
2612
|
/**
|
|
2049
2613
|
* Send one input line to a host control socket and wait for the runner's
|
|
2050
2614
|
* input_ack (issue #70 A13). Fire-and-forget "connected" no longer counts as
|
|
@@ -2067,6 +2631,15 @@ const HOST_INPUT_ACK_TIMEOUT_MS = 2_000;
|
|
|
2067
2631
|
* @returns {Promise<{ ok: boolean, error?: string, retryable?: boolean }>}
|
|
2068
2632
|
*/
|
|
2069
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
|
+
}
|
|
2070
2643
|
const requestId = opts.requestId ?? null;
|
|
2071
2644
|
const timeoutMs = opts.timeoutMs ?? HOST_INPUT_ACK_TIMEOUT_MS;
|
|
2072
2645
|
const connect = opts.connect ?? createConnection;
|