@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,3222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentViewService — the imperative actions behind the dashboard: dispatch a new
|
|
3
|
+
* background session, reply/resume, stop, pin/rename/archive, and the same-repo write
|
|
4
|
+
* safety rule. Pure node + core modules; the Pi-coupled bits (attach, dialogs) live in
|
|
5
|
+
* the command handler. The pi invocation + runner path are injected (resolved in index.ts).
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import { createConnection } from "node:net";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { applyAutoStateToStatus, autoStateEnabled, heuristicAutoState, isManualVerdict } from "../core/auto-state.mjs";
|
|
13
|
+
import { CLOCK_EPSILON_MS, classifyRunsForRow, probeBackgroundRun, resolveWorkflowHome, scanBackgroundRuns } from "../core/background-runs.mjs";
|
|
14
|
+
import { appendLine, atomicWriteJson, removeFile } from "../core/atomic.mjs";
|
|
15
|
+
import { finalizeRun, projectViewState, reduceEvent } from "../core/events.mjs";
|
|
16
|
+
import { clearDiagnostics, appendDiagnostic, appendDiagnosticOnce, tailDiagnostics } from "../core/diagnostics.mjs";
|
|
17
|
+
import { emptyEvidenceSnapshot, finalizeEvidence, readEvidence, reduceEvidence, summarizeEvidence, writeEvidence } from "../core/evidence.mjs";
|
|
18
|
+
import { updateCodeRefsFromEvidence } from "../core/code-refs-store.mjs";
|
|
19
|
+
import { claimNextFollowUp, completeFollowUp, enqueueFollowUp, failFollowUp, readFollowUpQueue, releaseFollowUp, summarizeFollowUpQueue, clearQueuedFollowUps, removeLastFollowUp } from "../core/follow-up-queue.mjs";
|
|
20
|
+
import { foregroundPreviewCache } from "../core/foreground-preview-cache.mjs";
|
|
21
|
+
import { foregroundProjectionCache } from "../core/foreground-projection-cache.mjs";
|
|
22
|
+
import { approvePlan as approvePlanState, markExecutingApprovedPlan, readSteering, recordPlanReady, requestPlan as requestPlanState, requestPlanChanges as requestPlanChangesState, summarizeSteering } from "../core/steering.mjs";
|
|
23
|
+
import { buildApprovePlanPrompt, buildPlanChangesPrompt, buildPlanRequestPrompt } from "../core/steering-prompts.mjs";
|
|
24
|
+
import { isGenericStatusText } from "../core/derive.mjs";
|
|
25
|
+
import { firstSentence, truncate } from "../core/heuristics.mjs";
|
|
26
|
+
import { newRunId, newViewId, slugifyTask } from "../core/ids.mjs";
|
|
27
|
+
import { launchAutoState as launchAutoStateProcess, launchHost as launchHostProcess, launchRun, launchTitle as launchTitleProcess } from "../core/launch.mjs";
|
|
28
|
+
import { gitRepoRoot } from "../core/repo.mjs";
|
|
29
|
+
import { captureStartToken, isAlive, killProcess } from "../core/pid.mjs";
|
|
30
|
+
import { acquireOwnedViewLock, tryAcquireOwnedViewLock } from "../core/locks.mjs";
|
|
31
|
+
import { canFinalizeLegacyHost, canReplaceHost, classifyUnverifiableProcess, processIdentityState } from "../core/host-coordination.mjs";
|
|
32
|
+
import { readProcessIdentityProbe, readProcessStartToken } from "../core/process-identity-probe.mjs";
|
|
33
|
+
import { modelRefAvailable } from "../core/launch-options.mjs";
|
|
34
|
+
import { ensureCoordinator, sendStateCommand, coordinatorDisabled } from "../core/coordinator-client.mjs";
|
|
35
|
+
import { createDesyncEpisodeThrottle, rereadPair, statusRevisionDesynced } from "../core/status-consistency.mjs";
|
|
36
|
+
import { commandRejectDiagnostic } from "../core/state-commands.mjs";
|
|
37
|
+
import { HOST_PROBE_RETRY_MS, probeHost } from "../core/host-probe.mjs";
|
|
38
|
+
import * as P from "../core/paths.mjs";
|
|
39
|
+
import {
|
|
40
|
+
claimHost,
|
|
41
|
+
createView,
|
|
42
|
+
listRows,
|
|
43
|
+
loadRow,
|
|
44
|
+
readHost,
|
|
45
|
+
readHostPid,
|
|
46
|
+
readLaunchPrefs,
|
|
47
|
+
readPid,
|
|
48
|
+
readState,
|
|
49
|
+
readStatus,
|
|
50
|
+
updateOwnedHost,
|
|
51
|
+
writeHost,
|
|
52
|
+
writeLaunchPrefs,
|
|
53
|
+
writeMeta,
|
|
54
|
+
writeState,
|
|
55
|
+
writeStatus,
|
|
56
|
+
} from "../core/store.mjs";
|
|
57
|
+
import { diagnoseNodePtyFailure, ensureNodePtySpawnHelperExecutable, nodePtyFallbackMessage, probeNodePtyEnvironment } from "../core/pty-support.mjs";
|
|
58
|
+
import { normalizeScreenLogMaxBytes, pruneScreenLogs } from "../core/screen-log-gc.mjs";
|
|
59
|
+
import { canAutoDrain, hasPendingQuestions, isAgentBusy, selectIdleHostsToEvict } from "../core/warm-host-sweeper.mjs";
|
|
60
|
+
import { resolveTestMs } from "../core/test-knobs.mjs";
|
|
61
|
+
|
|
62
|
+
/** @typedef {import("../core/types.mjs").RunKind} RunKind */
|
|
63
|
+
|
|
64
|
+
/** A fresh `starting` claim is a normal cold start for this long — never an orphan (issue #70). */
|
|
65
|
+
export const HOST_START_GRACE_MS = 10_000;
|
|
66
|
+
/** Bounded wait for the per-view host-start lease on explicit user actions (dispatch/reply). */
|
|
67
|
+
export const HOST_START_LOCK_WAIT_MS = 500;
|
|
68
|
+
/** Bounded wait for a revoked host's runner/child to end before recovery escalates (issue #70). */
|
|
69
|
+
export const HOST_RECOVERY_GRACE_MS = 5_000;
|
|
70
|
+
/** Poll cadence while waiting out a host recovery window. */
|
|
71
|
+
const HOST_RECOVERY_POLL_MS = 150;
|
|
72
|
+
/** Creation-time tolerance when a host role can only be corroborated by process creation time (issue #217). */
|
|
73
|
+
export const HOST_IDENTITY_CREATION_TOLERANCE_MS = 5_000;
|
|
74
|
+
/** Default wall-clock budget for one attach resolution (issue #70 Task 12). */
|
|
75
|
+
const ATTACH_RESOLVE_TIMEOUT_MS = 120_000;
|
|
76
|
+
/**
|
|
77
|
+
* How long after a finalized host to keep re-probing a still-`running` run
|
|
78
|
+
* before treating it as a hard-kill artifact (issue #230). Finalization waits
|
|
79
|
+
* for the runner and child pids to die, not for the workflow engine's last
|
|
80
|
+
* write, so the `paused` head the engine writes while settling in-flight step
|
|
81
|
+
* children during SIGTERM shutdown can land after `host.state = exited`. A
|
|
82
|
+
* probe in that window reports `running` and, without this grace period, the
|
|
83
|
+
* interruption notice would be swallowed for that host incarnation.
|
|
84
|
+
*/
|
|
85
|
+
export const NOTICE_FLIP_GRACE_MS = 60_000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Dynamic readers for the ladder constants above (issue #95 F4). Module-load-time
|
|
89
|
+
* constants cannot see env set after import, so every ladder decision reads the
|
|
90
|
+
* knob at use time; unset env keeps the exact production default.
|
|
91
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
92
|
+
* @returns {number}
|
|
93
|
+
*/
|
|
94
|
+
export function resolveHostStartGraceMs(env = process.env) {
|
|
95
|
+
return resolveTestMs(env, "AGENT_BOARD_TEST_HOST_START_GRACE_MS", HOST_START_GRACE_MS);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {NodeJS.ProcessEnv} [env] @returns {number} */
|
|
99
|
+
export function resolveHostRecoveryGraceMs(env = process.env) {
|
|
100
|
+
return resolveTestMs(env, "AGENT_BOARD_TEST_HOST_RECOVERY_GRACE_MS", HOST_RECOVERY_GRACE_MS);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {NodeJS.ProcessEnv} [env] @returns {number} */
|
|
104
|
+
export function resolveHostRecoveryPollMs(env = process.env) {
|
|
105
|
+
return resolveTestMs(env, "AGENT_BOARD_TEST_HOST_RECOVERY_POLL_MS", HOST_RECOVERY_POLL_MS);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {NodeJS.ProcessEnv} [env] @returns {number} */
|
|
109
|
+
export function resolveIdentityCreationToleranceMs(env = process.env) {
|
|
110
|
+
return resolveTestMs(env, "AGENT_BOARD_TEST_IDENTITY_CREATION_TOLERANCE_MS", HOST_IDENTITY_CREATION_TOLERANCE_MS);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {NodeJS.ProcessEnv} [env] @returns {number} */
|
|
114
|
+
export function resolveAttachResolveTimeoutMs(env = process.env) {
|
|
115
|
+
return resolveTestMs(env, "AGENT_BOARD_TEST_ATTACH_RESOLVE_TIMEOUT_MS", ATTACH_RESOLVE_TIMEOUT_MS);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {{
|
|
120
|
+
* root: string,
|
|
121
|
+
* runnerScript: string,
|
|
122
|
+
* ptyRunnerScript?: string,
|
|
123
|
+
* piCommand: string,
|
|
124
|
+
* piArgsPrefix: string[],
|
|
125
|
+
* defaultCwd: string,
|
|
126
|
+
* titleRunnerScript?: string,
|
|
127
|
+
* autoStateRunnerScript?: string,
|
|
128
|
+
* launch?: typeof launchRun,
|
|
129
|
+
* launchHost?: typeof launchHostProcess,
|
|
130
|
+
* launchTitle?: typeof launchTitleProcess,
|
|
131
|
+
* launchAutoState?: typeof launchAutoStateProcess,
|
|
132
|
+
* ptySupport?: (opts?: { refresh?: boolean, maxAgeMs?: number }) => { ok: boolean, reason?: string|null, issue?: any },
|
|
133
|
+
* pruneScreenLogs?: typeof pruneScreenLogs,
|
|
134
|
+
* randomId?: () => string,
|
|
135
|
+
* now?: () => number,
|
|
136
|
+
* acquireLock?: typeof acquireOwnedViewLock,
|
|
137
|
+
* tryAcquireLock?: typeof tryAcquireOwnedViewLock,
|
|
138
|
+
* observeProcess?: (identity: {pid: number, startToken: string|null}|null, observation: {spawnedAt: number|null, pid: number|null, tokenMemo: Map<number, Promise<string|null>>}) => "not_started"|"dead"|"owned"|"foreign"|"unknown"|Promise<"not_started"|"dead"|"owned"|"foreign"|"unknown">,
|
|
139
|
+
* isAlive?: (pid: number) => boolean,
|
|
140
|
+
* readStartToken?: (pid: number) => Promise<string|null>,
|
|
141
|
+
* probeProcessIdentity?: (pid: number) => Promise<{creationTimeMs: number|null, commandLine: string|null}|null>,
|
|
142
|
+
* signalOwnedProcess?: (identity: {pid: number, startToken: string|null}, signal: string) => void,
|
|
143
|
+
* probeHostFn?: typeof probeHost,
|
|
144
|
+
* sleepFn?: (ms: number) => Promise<void>,
|
|
145
|
+
* sendStateCommand?: typeof sendStateCommand,
|
|
146
|
+
* availableModels?: () => Array<{ provider: string, id: string }> | undefined,
|
|
147
|
+
* workflowHome?: string,
|
|
148
|
+
* backgroundRuns?: {
|
|
149
|
+
* scanBackgroundRuns: typeof scanBackgroundRuns,
|
|
150
|
+
* probeBackgroundRun: typeof probeBackgroundRun,
|
|
151
|
+
* },
|
|
152
|
+
* }} opts
|
|
153
|
+
*/
|
|
154
|
+
/** A pending question that is an open dialog, such as a permission prompt. */
|
|
155
|
+
const DIALOG = "dialog";
|
|
156
|
+
|
|
157
|
+
export function createService(opts) {
|
|
158
|
+
const root = opts.root;
|
|
159
|
+
const launch = opts.launch ?? launchRun;
|
|
160
|
+
const launchHostImpl = opts.launchHost ?? launchHostProcess;
|
|
161
|
+
const launchTitleImpl = opts.launchTitle ?? launchTitleProcess;
|
|
162
|
+
const launchAutoStateImpl = opts.launchAutoState ?? launchAutoStateProcess;
|
|
163
|
+
const ptySupport = opts.ptySupport ?? ptyHostAvailability;
|
|
164
|
+
const ptyRunnerScript = opts.ptyRunnerScript ?? opts.runnerScript;
|
|
165
|
+
const titleRunnerScript = opts.titleRunnerScript ?? null;
|
|
166
|
+
const randomIdImpl = opts.randomId ?? (() => randomBytes(16).toString("hex"));
|
|
167
|
+
const nowImpl = opts.now ?? Date.now;
|
|
168
|
+
const acquireLockImpl = opts.acquireLock ?? acquireOwnedViewLock;
|
|
169
|
+
const tryAcquireLockImpl = opts.tryAcquireLock ?? tryAcquireOwnedViewLock;
|
|
170
|
+
const sendStateCommandImpl = opts.sendStateCommand ?? sendStateCommand;
|
|
171
|
+
const ensureCoordinatorImpl = opts.ensureCoordinator ?? ensureCoordinator;
|
|
172
|
+
// Desync episode throttle: a stuck (stateRev:statusRev) pair logs once per
|
|
173
|
+
// service, not once per reconcile pass — see createDesyncEpisodeThrottle().
|
|
174
|
+
const desyncThrottle = createDesyncEpisodeThrottle();
|
|
175
|
+
// Identity-aware observation/signalling for host recovery (issue #70). Callers must
|
|
176
|
+
// only signal after observeProcess returned "owned" for that exact identity.
|
|
177
|
+
// The default assembles the observation snapshot (issue #238 D4) and hands the
|
|
178
|
+
// judgment to the pure `processIdentityState`.
|
|
179
|
+
// Injected observers are also called when the role has no identity (the default
|
|
180
|
+
// handles that and falls through to the probe path), so an injection must not
|
|
181
|
+
// assume `identity.pid` is present.
|
|
182
|
+
const observeProcessImpl = opts.observeProcess ?? observeProcessIdentity;
|
|
183
|
+
const signalOwnedProcessImpl = opts.signalOwnedProcess ?? defaultSignalOwnedProcess;
|
|
184
|
+
// Process liveness and cross-platform start-token reads (issue #238). Both are
|
|
185
|
+
// injectable so token-path judgment is unit-testable without real processes.
|
|
186
|
+
const isAliveImpl = opts.isAlive ?? isAlive;
|
|
187
|
+
const readStartTokenImpl = opts.readStartToken ?? readProcessStartToken;
|
|
188
|
+
// Corroborating process identity probe for platforms without a start token
|
|
189
|
+
// (issue #217). Callers inject it in tests; the default reads platform-
|
|
190
|
+
// specific creation time + command line for one pid.
|
|
191
|
+
const probeProcessIdentityImpl = opts.probeProcessIdentity ?? readProcessIdentityProbe;
|
|
192
|
+
// Attach-resolver dependencies (issue #70 Task 12): a real socket probe and an
|
|
193
|
+
// awaitable sleep. The resolver runs on the UI thread, so its waits MUST go
|
|
194
|
+
// through sleepFn (never a blocking acquire / Atomics.wait).
|
|
195
|
+
const probeHostImpl = opts.probeHostFn ?? probeHost;
|
|
196
|
+
const sleepFnImpl = opts.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
197
|
+
// Live model list for the stale-defaultModel launch guard (issue #90). Called
|
|
198
|
+
// at validation time (never cached at startup) and wrapped defensively by the
|
|
199
|
+
// caller; undefined/absent skips validation entirely.
|
|
200
|
+
const availableModelsImpl = opts.availableModels ?? null;
|
|
201
|
+
// Background-run protection (issue #230): the scan/probe pair and the workflow
|
|
202
|
+
// home are injectable for tests; the env guard flag is read at call time.
|
|
203
|
+
const backgroundRunsImpl = opts.backgroundRuns ?? { scanBackgroundRuns, probeBackgroundRun };
|
|
204
|
+
const workflowHomeImpl = opts.workflowHome ?? resolveWorkflowHome();
|
|
205
|
+
// Notice memo (issue #230): remember (viewId, host.endedAt) after a successful
|
|
206
|
+
// probe, except while it still reports any `running` run inside the flip grace
|
|
207
|
+
// window — the engine's last `paused` write can land after finalization, so
|
|
208
|
+
// that window stays un-memoized and re-probes next tick to catch the flip
|
|
209
|
+
// even when an older `paused` run coexists with the running one. `!probe.ok`
|
|
210
|
+
// stays un-memoized to retry transient read failures.
|
|
211
|
+
const noticeProbeMemo = new Set();
|
|
212
|
+
// In-flight attach resolutions, keyed by viewId: concurrent resolver calls for
|
|
213
|
+
// the same view share one promise (issue #70 Task 12).
|
|
214
|
+
const inflightAttachResolvers = new Map();
|
|
215
|
+
|
|
216
|
+
const pruneScreenLogsImpl = opts.pruneScreenLogs ?? pruneScreenLogs;
|
|
217
|
+
// Reclaim replay logs of long-ended views on dashboard startup. Deferred via
|
|
218
|
+
// setImmediate so the first frame is unaffected; any failure must not break
|
|
219
|
+
// the dashboard.
|
|
220
|
+
setImmediate(() => {
|
|
221
|
+
try {
|
|
222
|
+
const stats = pruneScreenLogsImpl(root, { retentionDays: readLaunchPrefs(root).screenLogRetentionDays });
|
|
223
|
+
// One JSONL record per pass that actually reclaimed something. `removed` is a
|
|
224
|
+
// one-time event per file, so records stay proportional to real usage.
|
|
225
|
+
// Persistent conditions (a permanent foreign dir, a recurring unlink failure)
|
|
226
|
+
// ride along as record fields but never trigger a record on their own —
|
|
227
|
+
// otherwise this file would itself grow without bound from routine opens.
|
|
228
|
+
if (stats && stats.removed > 0) {
|
|
229
|
+
appendLine(P.gcHistoryPath(root), JSON.stringify({ at: Date.now(), ...stats }));
|
|
230
|
+
}
|
|
231
|
+
} catch {}
|
|
232
|
+
}).unref?.();
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Launch a run (dispatch or reply) against an existing view, updating its state to queued.
|
|
236
|
+
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
237
|
+
* @param {string} prompt
|
|
238
|
+
* @param {RunKind} kind
|
|
239
|
+
* @returns {Promise<{ runId: string, pid: number|null }>} resolves after
|
|
240
|
+
* mark_queued has landed and the detached runner is spawned (ordering
|
|
241
|
+
* contract: the row must be queued before the runner can boot, final-review F2).
|
|
242
|
+
*/
|
|
243
|
+
async function launchForView(meta, prompt, kind) {
|
|
244
|
+
const runId = newRunId();
|
|
245
|
+
/** @type {import("../core/types.mjs").RunConfig} */
|
|
246
|
+
const config = {
|
|
247
|
+
root,
|
|
248
|
+
viewId: meta.id,
|
|
249
|
+
runId,
|
|
250
|
+
kind,
|
|
251
|
+
sessionFile: meta.sessionFile,
|
|
252
|
+
cwd: meta.cwd,
|
|
253
|
+
prompt,
|
|
254
|
+
piCommand: opts.piCommand,
|
|
255
|
+
piArgsPrefix: opts.piArgsPrefix,
|
|
256
|
+
model: meta.defaultModel ?? null,
|
|
257
|
+
thinkingLevel: meta.defaultThinking ?? null,
|
|
258
|
+
tools: null,
|
|
259
|
+
};
|
|
260
|
+
// Final-review F2 residual (fix round 2): mark_queued must LAND before the
|
|
261
|
+
// runner can boot. A detached runner that boots faster than this round-trip
|
|
262
|
+
// used to send run_started while the row was still manual-completed → the
|
|
263
|
+
// command hit the manual fence → row stuck queued/alive with no status file
|
|
264
|
+
// → beats and run_finalized reject stale_run forever → permanent zombie.
|
|
265
|
+
// Spawn regardless of the result: with the coordinator unreachable the
|
|
266
|
+
// runner's own commands fail the same way either way — launch availability
|
|
267
|
+
// beats strict ordering.
|
|
268
|
+
await markQueued(meta.id, runId);
|
|
269
|
+
const { pid } = launch(root, config, { runnerScript: opts.runnerScript });
|
|
270
|
+
appendDiagnostic(root, meta.id, { source: "service", runId, code: "launch_run", message: "Detached runner launched", details: { kind, pid } });
|
|
271
|
+
return { runId, pid };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Launch a durable interactive PTY host for a view — two-phase claim protocol
|
|
276
|
+
* (issue #70). Everything (claim → spawn → runnerPid merge) happens while the
|
|
277
|
+
* caller holds the per-view `host-start` lease, so concurrent service processes
|
|
278
|
+
* can never stack a second host on top of a live claim.
|
|
279
|
+
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
280
|
+
* @param {string|null} initialPrompt
|
|
281
|
+
* @returns {Promise<{ ok: true, status: "started"|"pending"|"reused", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }>}
|
|
282
|
+
*/
|
|
283
|
+
async function launchHost(meta, initialPrompt, launchOpts = {}) {
|
|
284
|
+
let lease;
|
|
285
|
+
try {
|
|
286
|
+
lease = acquireLockImpl(root, meta.id, "host-start", { waitMs: HOST_START_LOCK_WAIT_MS, identity: serviceIdentity() });
|
|
287
|
+
} catch {
|
|
288
|
+
// LOCK_TIMEOUT means lease contention (another live process holds it);
|
|
289
|
+
// an IO-class failure means the lock path itself is unusable. Both map
|
|
290
|
+
// to "cannot launch now": surface the existing claim as pending, never stack.
|
|
291
|
+
const existing = readHost(root, meta.id);
|
|
292
|
+
return pendingLaunchResult(existing);
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
// Same ordering contract as launchForView (final-review F2 residual):
|
|
296
|
+
// the row must leave completed/fenced before the host can start work.
|
|
297
|
+
// `return await` (not `return`) keeps the host-start lease held until
|
|
298
|
+
// the two-phase claim transaction settles.
|
|
299
|
+
if (launchOpts.markQueued !== false) await markQueued(meta.id, null);
|
|
300
|
+
return await startHostUnderLease(meta, initialPrompt, launchOpts);
|
|
301
|
+
} finally {
|
|
302
|
+
try { lease.release(); } catch { /* best effort */ }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** @param {import("../core/types.mjs").HostStatus|null} host */
|
|
307
|
+
function pendingLaunchResult(host) {
|
|
308
|
+
return { ok: true, status: "pending", pid: null, socketPath: host?.socketPath ?? null, instanceId: host?.instanceId ?? null };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Actionable error when the view's defaultModel is provably unavailable
|
|
313
|
+
* (issue #90): launching a child with a dead model id makes it exit 1 on
|
|
314
|
+
* every start, so attach would loop cold starts forever. Only fires when a
|
|
315
|
+
* model is configured AND a live model list is injectable; anything
|
|
316
|
+
* uncertain (no list, list call failure) conservatively allows the launch.
|
|
317
|
+
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
318
|
+
* @returns {string|null}
|
|
319
|
+
*/
|
|
320
|
+
function validateViewModelMeta(meta) {
|
|
321
|
+
const model = meta.defaultModel ?? null;
|
|
322
|
+
if (!model || !availableModelsImpl) return null;
|
|
323
|
+
let available;
|
|
324
|
+
try {
|
|
325
|
+
available = availableModelsImpl();
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
if (modelRefAvailable(model, available)) return null;
|
|
330
|
+
return `Model "${model}" configured for this session is no longer available — update the view's model (or clear defaultModel) and retry attach.`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Claim → spawn → merge flow. The CALLER must already hold the `host-start`
|
|
335
|
+
* lease; this function never acquires or releases it.
|
|
336
|
+
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
337
|
+
* @param {string|null} initialPrompt
|
|
338
|
+
* @returns {{ ok: true, status: "reused"|"pending"|"started", pid: number|null, socketPath: string|null, instanceId: string|null } | { ok: false, error: string, fallbackReason?: string }}
|
|
339
|
+
*/
|
|
340
|
+
function startHostUnderLease(meta, initialPrompt, launchOpts = {}) {
|
|
341
|
+
const existing = readHost(root, meta.id);
|
|
342
|
+
if (existing && (existing.state === "starting" || existing.state === "alive" || existing.state === "stopping")) {
|
|
343
|
+
// An active claim already exists — reuse it, never a second spawn.
|
|
344
|
+
return existing.state === "alive"
|
|
345
|
+
? { ok: true, status: "reused", pid: null, socketPath: existing.socketPath ?? null, instanceId: existing.instanceId ?? null }
|
|
346
|
+
: pendingLaunchResult(existing);
|
|
347
|
+
}
|
|
348
|
+
// No record is trivially replaceable; a terminal record only when nothing
|
|
349
|
+
// it names can still be alive (conservative: a live pid is "unknown" and blocks).
|
|
350
|
+
if (existing && !canReplaceHost(observeHostForReplace(existing))) {
|
|
351
|
+
return pendingLaunchResult(existing);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Fail fast BEFORE claiming (issue #90): no claim record, no config file,
|
|
355
|
+
// no spawn — the resolver surfaces the error via its pending reason.
|
|
356
|
+
const modelError = validateViewModelMeta(meta);
|
|
357
|
+
if (modelError) return { ok: false, error: modelError };
|
|
358
|
+
|
|
359
|
+
const instanceId = randomIdImpl();
|
|
360
|
+
const configPath = P.hostConfigPathFor(root, meta.id, instanceId);
|
|
361
|
+
const socketPath = P.hostEndpointPathFor(process.platform, root, meta.id, instanceId);
|
|
362
|
+
/** @type {import("../core/types.mjs").HostConfig} */
|
|
363
|
+
const config = {
|
|
364
|
+
root,
|
|
365
|
+
viewId: meta.id,
|
|
366
|
+
sessionFile: meta.sessionFile,
|
|
367
|
+
cwd: meta.cwd,
|
|
368
|
+
initialPrompt,
|
|
369
|
+
piCommand: opts.piCommand,
|
|
370
|
+
piArgsPrefix: opts.piArgsPrefix,
|
|
371
|
+
model: meta.defaultModel ?? null,
|
|
372
|
+
thinkingLevel: meta.defaultThinking ?? null,
|
|
373
|
+
tools: null,
|
|
374
|
+
env: {},
|
|
375
|
+
cols: Number(process.env.COLUMNS || 120),
|
|
376
|
+
rows: Number(process.env.LINES || 36),
|
|
377
|
+
screenLogMaxBytes: normalizeScreenLogMaxBytes(readLaunchPrefs(root).screenLogMaxSize),
|
|
378
|
+
instanceId,
|
|
379
|
+
configPath,
|
|
380
|
+
socketPath,
|
|
381
|
+
};
|
|
382
|
+
const claimIdentity = serviceIdentity();
|
|
383
|
+
const claimed = claimHost(root, {
|
|
384
|
+
viewId: meta.id,
|
|
385
|
+
instanceId,
|
|
386
|
+
configPath,
|
|
387
|
+
socketPath,
|
|
388
|
+
claimAt: nowImpl(),
|
|
389
|
+
claimPid: process.pid,
|
|
390
|
+
claimIdentity,
|
|
391
|
+
cols: config.cols,
|
|
392
|
+
rows: config.rows,
|
|
393
|
+
});
|
|
394
|
+
if (!claimed.claimed) {
|
|
395
|
+
// Lost the host-meta race: another launch owns the claim now.
|
|
396
|
+
return pendingLaunchResult(claimed.host);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let pid = null;
|
|
400
|
+
let spawnError = null;
|
|
401
|
+
try {
|
|
402
|
+
({ pid } = launchHostImpl(root, config, { runnerScript: ptyRunnerScript }));
|
|
403
|
+
} catch (err) {
|
|
404
|
+
spawnError = err instanceof Error ? err.message : String(err);
|
|
405
|
+
}
|
|
406
|
+
if (pid == null) {
|
|
407
|
+
const message = spawnError ?? "PTY host runner failed to spawn";
|
|
408
|
+
updateOwnedHost(root, meta.id, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: message }));
|
|
409
|
+
removeFile(configPath);
|
|
410
|
+
return { ok: false, error: message, fallbackReason: message };
|
|
411
|
+
}
|
|
412
|
+
updateOwnedHost(root, meta.id, instanceId, (h) => ({ ...h, runnerPid: pid, runnerSpawnedAt: nowImpl() }));
|
|
413
|
+
appendDiagnostic(root, meta.id, { source: "service", code: "launch_host", message: "PTY host launched", details: { pid, instanceId, hasInitialPrompt: Boolean(initialPrompt) } });
|
|
414
|
+
return { ok: true, status: "started", pid, socketPath, instanceId };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Best-effort detached title generation. If this fails or times out, the fallback slug
|
|
419
|
+
* remains as the row name.
|
|
420
|
+
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
421
|
+
* @param {string} prompt
|
|
422
|
+
*/
|
|
423
|
+
function queueGeneratedTitle(meta, prompt) {
|
|
424
|
+
if (!titleRunnerScript) return;
|
|
425
|
+
/** @type {import("../core/types.mjs").TitleConfig} */
|
|
426
|
+
const config = {
|
|
427
|
+
root,
|
|
428
|
+
viewId: meta.id,
|
|
429
|
+
cwd: meta.cwd,
|
|
430
|
+
prompt,
|
|
431
|
+
fallbackName: meta.name,
|
|
432
|
+
piCommand: opts.piCommand,
|
|
433
|
+
piArgsPrefix: opts.piArgsPrefix,
|
|
434
|
+
model: null,
|
|
435
|
+
};
|
|
436
|
+
try {
|
|
437
|
+
launchTitleImpl(root, config, { runnerScript: titleRunnerScript });
|
|
438
|
+
} catch {
|
|
439
|
+
/* best effort */
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Apply immediate heuristic auto-state and, when configured, queue a detached
|
|
445
|
+
* model pass to refine the row without blocking the live Pi child.
|
|
446
|
+
*
|
|
447
|
+
* Issue #91 (A8, service source): the classification lands through the View
|
|
448
|
+
* State Coordinator — the single writer of state.json/status.json. On apply,
|
|
449
|
+
* projection-relevant fields are refreshed from the materialized state so the
|
|
450
|
+
* caller's foreground projection (writeForegroundState) cannot clobber the
|
|
451
|
+
* patch with stale in-memory values. Ambiguous transport outcomes (timeout /
|
|
452
|
+
* connection_reset) never fall back to a direct write — the command may
|
|
453
|
+
* already be journaled, and the coordinator's boot replay is the recovery
|
|
454
|
+
* path. coordinator_disabled keeps the pre-coordinator in-memory apply.
|
|
455
|
+
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
456
|
+
* @param {import("../core/types.mjs").RunStatus} status refreshed in place on apply
|
|
457
|
+
* @param {import("../core/types.mjs").EvidenceSnapshot} evidence
|
|
458
|
+
* @returns {Promise<boolean>} whether the classification was applied
|
|
459
|
+
*/
|
|
460
|
+
async function queueAutoState(meta, status, evidence) {
|
|
461
|
+
if (!autoStateEnabled()) return false;
|
|
462
|
+
if (status.processState === "alive" || status.semanticState === "failed" || status.semanticState === "stopped") return false;
|
|
463
|
+
const latest = latestEvidenceText(evidence) || status.latestAssistantPreview || status.summary || "";
|
|
464
|
+
if (!latest.trim()) return false;
|
|
465
|
+
const classification = heuristicAutoState(latest, { lastAgentActivityAt: status.lastAgentActivityAt ?? null });
|
|
466
|
+
const commandRunId = status.runId === "foreground" ? null : status.runId;
|
|
467
|
+
const result = await sendStateCommandImpl(root, {
|
|
468
|
+
type: "state_command",
|
|
469
|
+
viewId: meta.id,
|
|
470
|
+
runId: commandRunId,
|
|
471
|
+
source: "service",
|
|
472
|
+
kind: "auto_state_classified",
|
|
473
|
+
expectedRevision: null,
|
|
474
|
+
payload: { classification },
|
|
475
|
+
});
|
|
476
|
+
if (opts.autoStateRunnerScript) {
|
|
477
|
+
try {
|
|
478
|
+
launchAutoStateImpl(root, {
|
|
479
|
+
root,
|
|
480
|
+
viewId: meta.id,
|
|
481
|
+
runId: status.runId === "foreground" ? null : status.runId,
|
|
482
|
+
cwd: meta.cwd,
|
|
483
|
+
piCommand: opts.piCommand,
|
|
484
|
+
piArgsPrefix: opts.piArgsPrefix,
|
|
485
|
+
}, { runnerScript: opts.autoStateRunnerScript });
|
|
486
|
+
} catch (err) {
|
|
487
|
+
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) } });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (result.status === "applied") {
|
|
491
|
+
const fresh = readState(root, meta.id);
|
|
492
|
+
if (fresh) {
|
|
493
|
+
status.semanticState = fresh.semanticState;
|
|
494
|
+
status.summary = fresh.summary;
|
|
495
|
+
status.question = fresh.question;
|
|
496
|
+
status.autoState = fresh.autoState ?? null;
|
|
497
|
+
}
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
if (result.reason === "coordinator_disabled") {
|
|
501
|
+
return applyAutoStateToStatus(status, classification, Date.now());
|
|
502
|
+
}
|
|
503
|
+
// Unified decided/ambiguous classification (CR r2 issue-3): decided
|
|
504
|
+
// rejects (manual_fence/stale_run/…) log as info *_skipped — the
|
|
505
|
+
// coordinator's verdict is authoritative; only genuinely ambiguous
|
|
506
|
+
// outcomes (timeout/connection_reset) keep the recovery-path warn.
|
|
507
|
+
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 } });
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Fire a state command for a non-run lifecycle/metadata mutation and surface
|
|
513
|
+
* non-applied outcomes. Ambiguity contract (issue #91): timeout /
|
|
514
|
+
* connection_reset mean the command MAY already be journaled — never fall
|
|
515
|
+
* back to a direct write on those; only the explicit
|
|
516
|
+
* AGENT_BOARD_COORDINATOR=off escape hatch takes the legacy branch (the
|
|
517
|
+
* caller checks `reason === "coordinator_disabled"`).
|
|
518
|
+
* @param {object} command
|
|
519
|
+
* @param {string} [viewId] defaults to command.viewId
|
|
520
|
+
*/
|
|
521
|
+
async function sendLifecycleCommand(command, viewId = command.viewId) {
|
|
522
|
+
const result = await sendStateCommandImpl(root, command);
|
|
523
|
+
if (result.status !== "applied" && result.reason !== "coordinator_disabled") {
|
|
524
|
+
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 } });
|
|
525
|
+
}
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Legacy direct write for mark_queued — coordinator_disabled escape hatch only. */
|
|
530
|
+
function markQueuedDirect(viewId, runId) {
|
|
531
|
+
const state = readState(root, viewId) ?? blankState(viewId);
|
|
532
|
+
state.currentRunId = runId;
|
|
533
|
+
state.semanticState = "queued";
|
|
534
|
+
state.processState = "alive";
|
|
535
|
+
state.summary = "Queued";
|
|
536
|
+
state.needsInput = false;
|
|
537
|
+
state.hasError = false;
|
|
538
|
+
state.question = null;
|
|
539
|
+
state.pendingQuestions = [];
|
|
540
|
+
state.error = null;
|
|
541
|
+
state.autoState = null;
|
|
542
|
+
state.lastActivityAt = Date.now();
|
|
543
|
+
state.updatedAt = Date.now();
|
|
544
|
+
writeState(root, state);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** @param {string} viewId @param {string|null} runId @returns {Promise<{status: string, reason: string|null, materializedRevision?: number}>} */
|
|
548
|
+
function markQueued(viewId, runId) {
|
|
549
|
+
if (coordinatorDisabled()) {
|
|
550
|
+
markQueuedDirect(viewId, runId);
|
|
551
|
+
return Promise.resolve({ status: "applied", reason: "coordinator_disabled" });
|
|
552
|
+
}
|
|
553
|
+
// Fire-and-forget: legacy issued this write right after launch() without
|
|
554
|
+
// waiting on the runner, and the coordinator serializes arrival order the
|
|
555
|
+
// same way. N1 (Task 1): command.runId stays null — the new run's id rides
|
|
556
|
+
// in the payload and the decision branch pins currentRunId, so the generic
|
|
557
|
+
// stale-run guard cannot fire against the PREVIOUS run.
|
|
558
|
+
//
|
|
559
|
+
// source is "dashboard-user" on purpose (final-review F1): launching a run
|
|
560
|
+
// from these flows is ALWAYS user intent (reply / dispatch / attach), and
|
|
561
|
+
// the manual fence exists to protect the user's verdict from AUTOMATED late
|
|
562
|
+
// writes — a user-initiated re-launch IS the user changing the verdict, so
|
|
563
|
+
// mark_queued must lift the fence for the rest of the new run (otherwise a
|
|
564
|
+
// reply on a done row would execute invisibly while the row stays
|
|
565
|
+
// completed). Automated re-launch stays guarded upstream: the job-runner's
|
|
566
|
+
// post-exit follow-up claim refuses rows carrying a manual verdict
|
|
567
|
+
// (drainQueuedFollowUp: `if (isManualVerdict(readState(...))) return;`),
|
|
568
|
+
// and the service-side drain path delivers already-queued user follow-ups
|
|
569
|
+
// with no fence by design (prompt-not-lost, issue #70 — its live-host input
|
|
570
|
+
// path never fenced either).
|
|
571
|
+
// Fix round 2 (launch ordering): this promise is now AWAITED by launchForView
|
|
572
|
+
// / launchHost before they spawn — see the ordering notes there.
|
|
573
|
+
return sendLifecycleCommand({
|
|
574
|
+
type: "state_command",
|
|
575
|
+
viewId,
|
|
576
|
+
runId: null,
|
|
577
|
+
source: "dashboard-user",
|
|
578
|
+
kind: "mark_queued",
|
|
579
|
+
expectedRevision: null,
|
|
580
|
+
payload: { runId },
|
|
581
|
+
}).catch(() => ({ status: "rejected", reason: "connection_reset" }));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Legacy direct write for markVisited — coordinator_disabled escape hatch only. */
|
|
585
|
+
function markVisitedDirect(row) {
|
|
586
|
+
const state = readState(root, row.meta.id) ?? row.state ?? blankState(row.meta.id);
|
|
587
|
+
state.lastVisitedAt = Date.now();
|
|
588
|
+
state.updatedAt = Date.now();
|
|
589
|
+
writeState(root, state);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* @param {string} viewId
|
|
594
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
595
|
+
*/
|
|
596
|
+
async function markVisited(viewId) {
|
|
597
|
+
const row = loadRow(root, viewId);
|
|
598
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
599
|
+
if (coordinatorDisabled()) {
|
|
600
|
+
markVisitedDirect(row);
|
|
601
|
+
return { ok: true };
|
|
602
|
+
}
|
|
603
|
+
// dashboard-user (not "service"): visiting is a user action, and the
|
|
604
|
+
// manual fence must not stop visit-recency tracking on completed rows —
|
|
605
|
+
// legacy stamped lastVisitedAt unconditionally. Whitelist: lastVisitedAt only.
|
|
606
|
+
const result = await sendLifecycleCommand({
|
|
607
|
+
type: "state_command",
|
|
608
|
+
viewId,
|
|
609
|
+
source: "dashboard-user",
|
|
610
|
+
kind: "patch_fields",
|
|
611
|
+
expectedRevision: null,
|
|
612
|
+
payload: { state: { lastVisitedAt: Date.now() } },
|
|
613
|
+
}, viewId);
|
|
614
|
+
if (result.reason === "coordinator_disabled") markVisitedDirect(row);
|
|
615
|
+
// Visit tracking is best-effort metadata — never fail the UI action.
|
|
616
|
+
return { ok: true };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Legacy direct write for adoptSession — coordinator_disabled escape hatch only. */
|
|
620
|
+
function adoptStateDirect(viewId, state) {
|
|
621
|
+
const target = state ?? readState(root, viewId) ?? blankState(viewId);
|
|
622
|
+
target.semanticState = "idle";
|
|
623
|
+
target.processState = "exited";
|
|
624
|
+
target.needsInput = false;
|
|
625
|
+
target.hasError = false;
|
|
626
|
+
target.question = null;
|
|
627
|
+
target.pendingQuestions = [];
|
|
628
|
+
target.error = null;
|
|
629
|
+
target.summary = "Backgrounded session";
|
|
630
|
+
target.updatedAt = Date.now();
|
|
631
|
+
target.lastActivityAt = Date.now();
|
|
632
|
+
writeState(root, target);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Route the adopt-idle transition through the coordinator (fenced, journaled). */
|
|
636
|
+
async function adoptIdleState(viewId, state) {
|
|
637
|
+
const result = await sendLifecycleCommand({
|
|
638
|
+
type: "state_command",
|
|
639
|
+
viewId,
|
|
640
|
+
runId: null,
|
|
641
|
+
source: "dashboard-user",
|
|
642
|
+
kind: "adopt_session",
|
|
643
|
+
expectedRevision: null,
|
|
644
|
+
payload: {},
|
|
645
|
+
}, viewId);
|
|
646
|
+
if (result.reason === "coordinator_disabled") adoptStateDirect(viewId, state);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Legacy direct-write completion — only reachable when the coordinator is
|
|
650
|
+
* explicitly disabled (AGENT_BOARD_COORDINATOR=off tests/escape hatch). */
|
|
651
|
+
function completeViewDirect(state) {
|
|
652
|
+
state.semanticState = "completed";
|
|
653
|
+
state.processState = "exited";
|
|
654
|
+
state.needsInput = false;
|
|
655
|
+
state.hasError = false;
|
|
656
|
+
state.question = null;
|
|
657
|
+
state.pendingQuestions = [];
|
|
658
|
+
state.error = null;
|
|
659
|
+
state.autoState = null;
|
|
660
|
+
state.summary = completionSummary(state);
|
|
661
|
+
state.lastActivityAt = Date.now();
|
|
662
|
+
state.updatedAt = Date.now();
|
|
663
|
+
// Also clear autoState in the run status so in-flight model passes
|
|
664
|
+
// (job-runner / state-runner) see the manual verdict and skip refinement.
|
|
665
|
+
if (state.currentRunId) {
|
|
666
|
+
const status = readStatus(root, state.viewId, state.currentRunId);
|
|
667
|
+
if (status) {
|
|
668
|
+
status.autoState = null;
|
|
669
|
+
writeStatus(root, status);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
writeState(root, state);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Direct write for holdView — coordinator_disabled escape hatch only. */
|
|
676
|
+
function holdViewDirect(state) {
|
|
677
|
+
state.semanticState = "holding";
|
|
678
|
+
state.processState = "exited";
|
|
679
|
+
state.needsInput = false;
|
|
680
|
+
state.hasError = false;
|
|
681
|
+
state.question = null;
|
|
682
|
+
state.pendingQuestions = [];
|
|
683
|
+
state.error = null;
|
|
684
|
+
state.autoState = null;
|
|
685
|
+
state.lastActivityAt = Date.now();
|
|
686
|
+
state.updatedAt = Date.now();
|
|
687
|
+
writeState(root, state);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Direct write for clearHoldView — coordinator_disabled escape hatch only. */
|
|
691
|
+
function clearHoldViewDirect(state) {
|
|
692
|
+
state.semanticState = "idle";
|
|
693
|
+
state.processState = "exited";
|
|
694
|
+
state.needsInput = false;
|
|
695
|
+
state.hasError = false;
|
|
696
|
+
state.question = null;
|
|
697
|
+
state.pendingQuestions = [];
|
|
698
|
+
state.error = null;
|
|
699
|
+
state.autoState = null;
|
|
700
|
+
state.lastActivityAt = Date.now();
|
|
701
|
+
state.updatedAt = Date.now();
|
|
702
|
+
writeState(root, state);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Explicitly mark an inactive session as done via the View State Coordinator
|
|
707
|
+
* (issue #91): the command is journaled and materialized by the single owner,
|
|
708
|
+
* which also rejects stale-run and fenced manual-verdict overwrites.
|
|
709
|
+
* @param {string} viewId
|
|
710
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
711
|
+
*/
|
|
712
|
+
async function completeView(viewId) {
|
|
713
|
+
const row = loadRow(root, viewId);
|
|
714
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
715
|
+
// Fast local pre-check for instant UI feedback; the coordinator's decision
|
|
716
|
+
// stays authoritative (its "busy" rejection maps to the same wording).
|
|
717
|
+
if (isAgentBusy(row)) return { ok: false, error: "Wait for the active run to finish before marking done" };
|
|
718
|
+
const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
|
|
719
|
+
const result = await sendStateCommandImpl(root, {
|
|
720
|
+
type: "state_command",
|
|
721
|
+
viewId,
|
|
722
|
+
runId: state.currentRunId ?? null,
|
|
723
|
+
source: "dashboard-user",
|
|
724
|
+
kind: "mark_completed",
|
|
725
|
+
expectedRevision: null,
|
|
726
|
+
payload: {},
|
|
727
|
+
});
|
|
728
|
+
if (result.status === "applied") return { ok: true };
|
|
729
|
+
if (result.reason === "busy") return { ok: false, error: "Wait for the active run to finish before marking done" };
|
|
730
|
+
if (result.reason === "coordinator_disabled") {
|
|
731
|
+
completeViewDirect(state);
|
|
732
|
+
return { ok: true };
|
|
733
|
+
}
|
|
734
|
+
// Ambiguous outcomes (timeout / connection_reset: the command MAY already be
|
|
735
|
+
// journaled) and real rejections surface verbatim — never fall back to a
|
|
736
|
+
// direct write here, it would bypass the single-writer fence.
|
|
737
|
+
return { ok: false, error: result.reason ?? "state_command_failed" };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Place an inactive session on hold (issue #145; template: completeView). */
|
|
741
|
+
async function holdView(viewId) {
|
|
742
|
+
const row = loadRow(root, viewId);
|
|
743
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
744
|
+
if (isAgentBusy(row)) return { ok: false, error: "Wait for the active run to finish before placing on hold" };
|
|
745
|
+
const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
|
|
746
|
+
const result = await sendStateCommandImpl(root, {
|
|
747
|
+
type: "state_command",
|
|
748
|
+
viewId,
|
|
749
|
+
runId: state.currentRunId ?? null,
|
|
750
|
+
source: "dashboard-user",
|
|
751
|
+
kind: "mark_holding",
|
|
752
|
+
expectedRevision: null,
|
|
753
|
+
payload: {},
|
|
754
|
+
});
|
|
755
|
+
if (result.status === "applied") return { ok: true };
|
|
756
|
+
if (result.reason === "busy") return { ok: false, error: "Wait for the active run to finish before placing on hold" };
|
|
757
|
+
if (result.reason === "coordinator_disabled") {
|
|
758
|
+
holdViewDirect(state);
|
|
759
|
+
return { ok: true };
|
|
760
|
+
}
|
|
761
|
+
return { ok: false, error: result.reason ?? "state_command_failed" };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Resume an on-hold session back to Needs-instructions (issue #145). */
|
|
765
|
+
async function clearHoldView(viewId) {
|
|
766
|
+
const row = loadRow(root, viewId);
|
|
767
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
768
|
+
const state = readState(root, viewId) ?? row.state ?? blankState(viewId);
|
|
769
|
+
const result = await sendStateCommandImpl(root, {
|
|
770
|
+
type: "state_command",
|
|
771
|
+
viewId,
|
|
772
|
+
runId: state.currentRunId ?? null,
|
|
773
|
+
source: "dashboard-user",
|
|
774
|
+
kind: "clear_holding",
|
|
775
|
+
expectedRevision: null,
|
|
776
|
+
payload: {},
|
|
777
|
+
});
|
|
778
|
+
if (result.status === "applied") return { ok: true };
|
|
779
|
+
if (result.reason === "no_change") return { ok: true };
|
|
780
|
+
if (result.reason === "coordinator_disabled") {
|
|
781
|
+
clearHoldViewDirect(state);
|
|
782
|
+
return { ok: true };
|
|
783
|
+
}
|
|
784
|
+
return { ok: false, error: result.reason ?? "state_command_failed" };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Legacy direct write for archiveView's busy-row stop — coordinator_disabled escape hatch only. */
|
|
788
|
+
function archiveStateDirect(row) {
|
|
789
|
+
const state = readState(root, row.meta.id) ?? row.state ?? blankState(row.meta.id);
|
|
790
|
+
state.semanticState = "stopped";
|
|
791
|
+
state.processState = "exited";
|
|
792
|
+
state.needsInput = false;
|
|
793
|
+
state.hasError = false;
|
|
794
|
+
state.question = null;
|
|
795
|
+
state.pendingQuestions = [];
|
|
796
|
+
state.error = null;
|
|
797
|
+
state.autoState = null;
|
|
798
|
+
state.summary = "Stopped";
|
|
799
|
+
state.lastActivityAt = Date.now();
|
|
800
|
+
state.updatedAt = Date.now();
|
|
801
|
+
writeState(root, state);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* @param {string} viewId
|
|
806
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
807
|
+
*/
|
|
808
|
+
async function archiveView(viewId) {
|
|
809
|
+
const row = loadRow(root, viewId);
|
|
810
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
811
|
+
if (row.host?.instanceId) stopHostRow(row, "archive");
|
|
812
|
+
else if (row.hostAlive) sendHostMessage(row, { type: "terminate" });
|
|
813
|
+
if (row.alive && row.state?.currentRunId) {
|
|
814
|
+
const pid = readPid(root, viewId, row.state.currentRunId);
|
|
815
|
+
if (pid) killProcess(pid);
|
|
816
|
+
}
|
|
817
|
+
if (isAgentBusy(row)) {
|
|
818
|
+
// dashboard-user: archiving is a user action. Busy rows are allowed by
|
|
819
|
+
// the decision kind (archiving a working row stops it — legacy parity).
|
|
820
|
+
const result = await sendLifecycleCommand({
|
|
821
|
+
type: "state_command",
|
|
822
|
+
viewId,
|
|
823
|
+
source: "dashboard-user",
|
|
824
|
+
kind: "archive_view",
|
|
825
|
+
expectedRevision: null,
|
|
826
|
+
payload: {},
|
|
827
|
+
}, viewId);
|
|
828
|
+
if (result.reason === "coordinator_disabled") archiveStateDirect(row);
|
|
829
|
+
}
|
|
830
|
+
foregroundPreviewCache.forget(viewId);
|
|
831
|
+
foregroundProjectionCache.forget(viewId);
|
|
832
|
+
row.meta.archived = true;
|
|
833
|
+
writeMeta(root, row.meta);
|
|
834
|
+
return { ok: true };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** @param {string} viewId @returns {import("../core/types.mjs").ViewState} */
|
|
838
|
+
function blankState(viewId) {
|
|
839
|
+
return {
|
|
840
|
+
version: 1,
|
|
841
|
+
viewId,
|
|
842
|
+
currentRunId: null,
|
|
843
|
+
semanticState: "queued",
|
|
844
|
+
processState: "exited",
|
|
845
|
+
summary: "Queued",
|
|
846
|
+
lastActivityAt: Date.now(),
|
|
847
|
+
updatedAt: Date.now(),
|
|
848
|
+
needsInput: false,
|
|
849
|
+
hasError: false,
|
|
850
|
+
latestAssistantPreview: "",
|
|
851
|
+
latestTool: null,
|
|
852
|
+
question: null,
|
|
853
|
+
pendingQuestions: [],
|
|
854
|
+
error: null,
|
|
855
|
+
lastVisitedAt: null,
|
|
856
|
+
lastAgentActivityAt: null,
|
|
857
|
+
autoState: null,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** @param {string} a @param {string} b */
|
|
862
|
+
function samePath(a, b) {
|
|
863
|
+
return resolve(a) === resolve(b);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* @param {import("../core/store.mjs").Row} row
|
|
868
|
+
* @returns {import("../core/types.mjs").RunStatus}
|
|
869
|
+
*/
|
|
870
|
+
function statusFromRow(row) {
|
|
871
|
+
const now = Date.now();
|
|
872
|
+
const s = row.state ?? blankState(row.meta.id);
|
|
873
|
+
return {
|
|
874
|
+
version: 1,
|
|
875
|
+
runId: s.currentRunId ?? "foreground",
|
|
876
|
+
viewId: row.meta.id,
|
|
877
|
+
pid: null,
|
|
878
|
+
startedAt: s.lastActivityAt ?? now,
|
|
879
|
+
endedAt: null,
|
|
880
|
+
exitCode: null,
|
|
881
|
+
kind: "reply",
|
|
882
|
+
prompt: "",
|
|
883
|
+
model: row.meta.defaultModel ?? null,
|
|
884
|
+
semanticState: s.semanticState,
|
|
885
|
+
processState: s.processState,
|
|
886
|
+
summary: s.summary,
|
|
887
|
+
lastActivityAt: s.lastActivityAt,
|
|
888
|
+
// Issue #225: freshness signal for the projection cache. Absent on legacy rows.
|
|
889
|
+
updatedAt: typeof s.updatedAt === "number" ? s.updatedAt : 0,
|
|
890
|
+
currentTool: s.latestTool ? { name: s.latestTool.name, path: s.latestTool.path, summary: s.summary } : null,
|
|
891
|
+
latestAssistantPreview: s.latestAssistantPreview,
|
|
892
|
+
question: s.question,
|
|
893
|
+
pendingQuestions: Array.isArray(s.pendingQuestions) ? s.pendingQuestions : [],
|
|
894
|
+
error: s.error,
|
|
895
|
+
lastAgentActivityAt: s.lastAgentActivityAt ?? null,
|
|
896
|
+
stopReason: null,
|
|
897
|
+
stoppedByUser: false,
|
|
898
|
+
turns: 0,
|
|
899
|
+
toolCount: 0,
|
|
900
|
+
autoState: s.autoState ?? null,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* @param {import("../core/store.mjs").Row} row
|
|
906
|
+
* @param {import("../core/types.mjs").RunStatus} status
|
|
907
|
+
* @param {{ source?: string }} [opts] F-lift (issue #145): pass "dashboard-user"
|
|
908
|
+
* when the mirror was triggered by a human submitting input — the manual
|
|
909
|
+
* fence must not outlive the user speaking to the row.
|
|
910
|
+
* @returns {Promise<void>}
|
|
911
|
+
*/
|
|
912
|
+
async function writeForegroundState(row, status, opts = {}) {
|
|
913
|
+
// Issue #225: a strictly increasing stamp lets the next disk rebuild tell a stale
|
|
914
|
+
// snapshot from this write, even when both land in the same millisecond.
|
|
915
|
+
const stamp = foregroundProjectionCache.nextStamp(row.meta.id);
|
|
916
|
+
const projected = projectViewState(status, stamp, readState(root, row.meta.id) ?? row.state ?? null);
|
|
917
|
+
foregroundProjectionCache.remember(row.meta.id, status, stamp);
|
|
918
|
+
// Foreground turns are driven by the interactive Pi process, not a detached
|
|
919
|
+
// runner, so keep currentRunId null. This prevents reconcile()/stop() from
|
|
920
|
+
// treating a foreground turn as a managed background runner pid. (The
|
|
921
|
+
// decision kind force-nulls it too; the caller-side null keeps the
|
|
922
|
+
// coordinator_disabled direct write at legacy parity.)
|
|
923
|
+
projected.currentRunId = null;
|
|
924
|
+
// Issue #113: remember the freshest known foreground fields before the
|
|
925
|
+
// coordinator round trip — syncRowEvent rebuilds from disk on the next
|
|
926
|
+
// event and must not read an older projection than this one.
|
|
927
|
+
foregroundPreviewCache.remember(row.meta.id, projected);
|
|
928
|
+
if (coordinatorDisabled()) {
|
|
929
|
+
writeState(root, projected);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const result = await sendLifecycleCommand({
|
|
933
|
+
type: "state_command",
|
|
934
|
+
viewId: row.meta.id,
|
|
935
|
+
runId: null,
|
|
936
|
+
source: opts.source ?? "service",
|
|
937
|
+
kind: "sync_foreground",
|
|
938
|
+
expectedRevision: null,
|
|
939
|
+
payload: { projection: projected },
|
|
940
|
+
}, row.meta.id);
|
|
941
|
+
if (result.reason === "coordinator_disabled") writeState(root, projected);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** @param {string} sessionFile */
|
|
945
|
+
function rowForSession(sessionFile) {
|
|
946
|
+
return listRows(root, { includeArchived: true }).find((r) => samePath(r.meta.sessionFile, sessionFile)) ?? null;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Evict idle warm PTY hosts past TTL / over the warm pool cap.
|
|
951
|
+
* Idle = host alive, not agent-busy, no attached clients.
|
|
952
|
+
* Called lazily from dispatch/ensureHost (keepViewId = the view being used)
|
|
953
|
+
* and periodically / on shutdown by the warm-host sweeper (issue #75).
|
|
954
|
+
* @param {{ keepViewId?: string|null }} [pruneOpts]
|
|
955
|
+
*/
|
|
956
|
+
function pruneWarmHosts(pruneOpts = {}) {
|
|
957
|
+
const maxWarm = envInt("AGENT_BOARD_MAX_WARM_HOSTS", 4, 0, 50, "AGENT_VIEW_MAX_WARM_HOSTS");
|
|
958
|
+
const ttlMs = envInt("AGENT_BOARD_WARM_HOST_TTL_MS", 10 * 60 * 1000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_WARM_HOST_TTL_MS");
|
|
959
|
+
if (maxWarm === 0 && ttlMs === 0) return;
|
|
960
|
+
const graceMs = envInt("AGENT_BOARD_WARM_HOST_GRACE_MS", 30 * 1000, 0, 24 * 60 * 60 * 1000, "AGENT_VIEW_WARM_HOST_GRACE_MS");
|
|
961
|
+
const keepViewId = pruneOpts.keepViewId ?? null;
|
|
962
|
+
const now = Date.now();
|
|
963
|
+
const rows = listRows(root);
|
|
964
|
+
const guardDisabled = process.env.AGENT_BOARD_NO_BACKGROUND_RUN_GUARD === "1";
|
|
965
|
+
|
|
966
|
+
// Phase 1 (dry): select without background-run protection. No candidates
|
|
967
|
+
// means no IO at all, preserving today's fast path for a full warm pool.
|
|
968
|
+
const dry = selectIdleHostsToEvict(rows, { now, maxWarm, ttlMs, graceMs, keepViewId });
|
|
969
|
+
const candidates = [...dry.ttlEvicted, ...dry.excessEvicted];
|
|
970
|
+
if (candidates.length === 0) return;
|
|
971
|
+
|
|
972
|
+
// One scan for the whole pass; the protection predicate stays pure
|
|
973
|
+
// in-memory (spec D5). minMtimeMs prefilters to heads that could belong
|
|
974
|
+
// to a live candidate host (spec D4); a candidate without startedAt
|
|
975
|
+
// falls back to 0, which disables the prefilter.
|
|
976
|
+
let scan = { ok: true, heads: [], error: null };
|
|
977
|
+
if (!guardDisabled) {
|
|
978
|
+
const candidateStartedAts = candidates.map((viewId) => rows.find((r) => r.meta.id === viewId)?.host?.startedAt ?? null);
|
|
979
|
+
const minStartedAt = candidateStartedAts.some((v) => v == null) ? 0 : Math.min(...candidateStartedAts);
|
|
980
|
+
scan = backgroundRunsImpl.scanBackgroundRuns({
|
|
981
|
+
workflowHome: workflowHomeImpl,
|
|
982
|
+
minMtimeMs: minStartedAt - CLOCK_EPSILON_MS,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
const isProtected = (row) => {
|
|
986
|
+
if (guardDisabled) return false;
|
|
987
|
+
return classifyRunsForRow(scan.heads, {
|
|
988
|
+
sessionFile: row.meta.sessionFile,
|
|
989
|
+
hostStartedAt: row.host?.startedAt,
|
|
990
|
+
}).state === "protecting";
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
// Phase 2: re-select with the protection predicate so both eviction
|
|
994
|
+
// branches (TTL and maxWarm) exempt hosts running a background workflow.
|
|
995
|
+
const selected = selectIdleHostsToEvict(rows, { now, maxWarm, ttlMs, graceMs, keepViewId, isProtected });
|
|
996
|
+
for (const viewId of [...selected.ttlEvicted, ...selected.excessEvicted]) {
|
|
997
|
+
// Re-verify against fresh state before terminating: the selection ran
|
|
998
|
+
// on a listRows snapshot, and the host may have been dispatched (busy)
|
|
999
|
+
// or attached (attachedClients > 0) since. Never kill a busy/attached
|
|
1000
|
+
// host; graceMs also re-checked in case the host was just restarted.
|
|
1001
|
+
const row = loadRow(root, viewId);
|
|
1002
|
+
if (!row?.hostAlive) continue;
|
|
1003
|
+
if (isAgentBusy(row)) continue;
|
|
1004
|
+
if ((row.host?.attachedClients ?? 0) !== 0) continue;
|
|
1005
|
+
if (graceMs > 0 && row.host?.startedAt != null && Date.now() - row.host.startedAt < graceMs) continue;
|
|
1006
|
+
if (isProtected(row)) continue;
|
|
1007
|
+
|
|
1008
|
+
if (guardDisabled) {
|
|
1009
|
+
stopHostRow(row, "warm_prune");
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
// Fresh protection probe (A9): a run started between the snapshot and
|
|
1013
|
+
// this stop decision still blocks the eviction. Fail-open: a probe
|
|
1014
|
+
// error logs a deduped warning and proceeds without protection (D6).
|
|
1015
|
+
const fresh = backgroundRunsImpl.probeBackgroundRun({
|
|
1016
|
+
sessionFile: row.meta.sessionFile ?? null,
|
|
1017
|
+
hostStartedAt: row.host?.startedAt ?? null,
|
|
1018
|
+
workflowHome: workflowHomeImpl,
|
|
1019
|
+
});
|
|
1020
|
+
if (fresh.running) {
|
|
1021
|
+
const runId = fresh.running.runId;
|
|
1022
|
+
appendDiagnosticOnce(root, viewId, {
|
|
1023
|
+
source: "service",
|
|
1024
|
+
level: "warn",
|
|
1025
|
+
code: "warm_prune_during_background_run",
|
|
1026
|
+
message: `Skipped warm-host eviction: background workflow ${runId} started after selection`,
|
|
1027
|
+
details: { runId, hostStartedAt: row.host?.startedAt ?? null },
|
|
1028
|
+
}, { match: ["runId"] });
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
if (!fresh.ok) {
|
|
1032
|
+
appendDiagnosticOnce(root, viewId, {
|
|
1033
|
+
source: "service",
|
|
1034
|
+
level: "warn",
|
|
1035
|
+
code: "warm_prune_probe_failed",
|
|
1036
|
+
message: "Could not read the workflow run store; warm-host eviction proceeded without background-run protection",
|
|
1037
|
+
details: { error: String(fresh.error ?? "unknown") },
|
|
1038
|
+
}, { match: ["error"] });
|
|
1039
|
+
stopHostRow(row, "warm_prune");
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
stopHostRow(row, "warm_prune");
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** Stop a host row by policy (issue #70): new-protocol hosts get the authoritative
|
|
1047
|
+
* file-based revoke (works even when the control socket is dead); legacy hosts
|
|
1048
|
+
* without an instanceId keep the old socket-terminate fallback. */
|
|
1049
|
+
function stopHostRow(row, reason) {
|
|
1050
|
+
const instanceId = row.host?.instanceId ?? null;
|
|
1051
|
+
if (instanceId) return requestHostStop(row.meta.id, instanceId, reason);
|
|
1052
|
+
return sendHostMessage(row, { type: "terminate" });
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* File-based host revoke (issue #70): marks the host record `stopping` with a
|
|
1057
|
+
* fresh revoke token so the runner's heartbeat shuts it down cooperatively.
|
|
1058
|
+
* Never signals, never spawns a replacement. Idempotent: terminal or
|
|
1059
|
+
* already-stopping states are left untouched (`requested: false`).
|
|
1060
|
+
* @param {string} viewId
|
|
1061
|
+
* @param {string|null} expectedInstanceId
|
|
1062
|
+
* @param {string} reason
|
|
1063
|
+
* @returns {{ ok: boolean, requested?: boolean, error?: string }}
|
|
1064
|
+
*/
|
|
1065
|
+
function requestHostStop(viewId, expectedInstanceId, reason) {
|
|
1066
|
+
let wasRequested = false;
|
|
1067
|
+
const res = updateOwnedHost(root, viewId, expectedInstanceId, (h) => {
|
|
1068
|
+
if (h.state === "exited" || h.state === "failed" || h.state === "stopping") return h;
|
|
1069
|
+
wasRequested = true;
|
|
1070
|
+
return { ...h, state: "stopping", stopRequestedAt: nowImpl(), revokeToken: randomIdImpl(), stopReason: reason };
|
|
1071
|
+
});
|
|
1072
|
+
if (!res.updated) return { ok: false, error: "owner_changed" };
|
|
1073
|
+
return { ok: true, requested: wasRequested };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Assemble one host-role observation snapshot and let the pure state machine
|
|
1078
|
+
* judge it (issue #238 D4). The recorded start token is only read when the
|
|
1079
|
+
* record carries one, so legacy records (token `null`, or platforms without a
|
|
1080
|
+
* token) keep the zero-token-read path. `tokenMemo` deduplicates the (async,
|
|
1081
|
+
* expensive on win32) token read per pid, so one recovery pass reads each
|
|
1082
|
+
* process at most once.
|
|
1083
|
+
* @param {{pid:number,startToken:string|null}|null} identity recorded identity
|
|
1084
|
+
* @param {{spawnedAt:number|null, pid:number|null, tokenMemo:Map<number, Promise<string|null>>}} observation
|
|
1085
|
+
* @returns {Promise<"not_started"|"dead"|"owned"|"foreign"|"unknown">}
|
|
1086
|
+
*/
|
|
1087
|
+
async function observeProcessIdentity(identity, { spawnedAt, pid, tokenMemo }) {
|
|
1088
|
+
if (identity == null && spawnedAt == null) return "not_started";
|
|
1089
|
+
if (pid == null) return "unknown";
|
|
1090
|
+
const alive = isAliveImpl(pid);
|
|
1091
|
+
const observed = { alive, startToken: null };
|
|
1092
|
+
if (alive && identity != null && identity.startToken != null) {
|
|
1093
|
+
if (!tokenMemo.has(pid)) tokenMemo.set(pid, readStartTokenImpl(pid));
|
|
1094
|
+
observed.startToken = await tokenMemo.get(pid);
|
|
1095
|
+
}
|
|
1096
|
+
return processIdentityState(identity, observed, spawnedAt);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Observe one host role: a recorded identity goes through identity-aware
|
|
1101
|
+
* observation (token comparison first); a missing identity means `not_started`
|
|
1102
|
+
* (never spawned). An `unknown` observation (no stable token, or the token
|
|
1103
|
+
* path unavailable) is then corroborated by the process identity probe before
|
|
1104
|
+
* recovery decides (issue #217). The caller's `memo` deduplicates probes per
|
|
1105
|
+
* pid and `tokenMemo` deduplicates token reads per pid so one recovery pass
|
|
1106
|
+
* never observes the same process twice.
|
|
1107
|
+
* @param {any} host host record holding this role's identity/pid/spawn fields
|
|
1108
|
+
* @param {"runner"|"child"} role which host process to observe
|
|
1109
|
+
* @param {{ sessionFile: string|null, memo: Map<number, any>, tokenMemo: Map<number, Promise<string|null>> }} opts
|
|
1110
|
+
* session file used as the child command fragment, and the per-call
|
|
1111
|
+
* probe/token memos (kept separate: probe values are objects, token values
|
|
1112
|
+
* are strings or null)
|
|
1113
|
+
*/
|
|
1114
|
+
async function observeHostRole(host, role, { sessionFile, memo, tokenMemo }) {
|
|
1115
|
+
const identity = host?.[`${role}Identity`] ?? null;
|
|
1116
|
+
const spawnedAt = host?.[`${role}SpawnedAt`] ?? null;
|
|
1117
|
+
if (identity == null && spawnedAt == null) return "not_started";
|
|
1118
|
+
const pid = identity?.pid ?? host?.[`${role}Pid`] ?? null;
|
|
1119
|
+
if (pid == null) return "unknown";
|
|
1120
|
+
const observation = await observeProcessImpl(identity, { spawnedAt, pid, tokenMemo });
|
|
1121
|
+
if (observation !== "unknown") return observation;
|
|
1122
|
+
if (!memo.has(pid)) memo.set(pid, await probeProcessIdentityImpl(pid));
|
|
1123
|
+
return classifyUnverifiableProcess({
|
|
1124
|
+
spawnedAt,
|
|
1125
|
+
probe: memo.get(pid),
|
|
1126
|
+
expectedCommandFragment: role === "child" ? sessionFile : host?.configPath ?? null,
|
|
1127
|
+
toleranceMs: resolveIdentityCreationToleranceMs(),
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** Re-validate an "owned" identity immediately before signalling: a pid reused
|
|
1132
|
+
* mid-recovery must never receive an escalation signal (#240 CR). A recorded
|
|
1133
|
+
* start token is re-read live, bypassing the observation memo — on win32 the
|
|
1134
|
+
* observed token may be ~400 ms old, leaving a pid-reuse window — and any
|
|
1135
|
+
* mismatch or unreadable token refuses the signal, which leaves the recovery
|
|
1136
|
+
* pending (safe: never foreign, never signalled). Records without a token keep
|
|
1137
|
+
* the probe-derived re-validation. */
|
|
1138
|
+
async function revalidateProbeOwned(host, role, sessionFile, memo) {
|
|
1139
|
+
const identity = host?.[`${role}Identity`] ?? null;
|
|
1140
|
+
const pid = identity?.pid ?? host?.[`${role}Pid`] ?? null;
|
|
1141
|
+
if (pid == null) return false;
|
|
1142
|
+
if (identity?.startToken != null) {
|
|
1143
|
+
const token = await readStartTokenImpl(pid);
|
|
1144
|
+
return token != null && token === identity.startToken;
|
|
1145
|
+
}
|
|
1146
|
+
const probe = await probeProcessIdentityImpl(pid);
|
|
1147
|
+
memo.set(pid, probe);
|
|
1148
|
+
return classifyUnverifiableProcess({
|
|
1149
|
+
spawnedAt: host?.[`${role}SpawnedAt`] ?? null,
|
|
1150
|
+
probe,
|
|
1151
|
+
expectedCommandFragment: role === "child" ? sessionFile : host?.configPath ?? null,
|
|
1152
|
+
toleranceMs: resolveIdentityCreationToleranceMs(),
|
|
1153
|
+
}) === "owned";
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** A host role observation is "ended" when it is provably not running:
|
|
1157
|
+
* never-spawned, confirmed dead, or a reused PID (foreign). */
|
|
1158
|
+
const hostRoleEnded = (o) => o === "not_started" || o === "dead" || o === "foreign";
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Record the host roles that stranded a recovery (issue #217 D4).
|
|
1162
|
+
* Only roles whose identity is unverifiable (`"unknown"`) are recorded: an
|
|
1163
|
+
* `"owned"` role is alive and verified, so flagging it as unverifiable would
|
|
1164
|
+
* contradict the observation that stranded recovery. Only the producing side
|
|
1165
|
+
* writes: one event per instance+role, so repeated attach clicks on the same
|
|
1166
|
+
* stranded host never flood the diagnostics tail.
|
|
1167
|
+
* @param {string} viewId
|
|
1168
|
+
* @param {any} host host record the observations came from
|
|
1169
|
+
* @param {{ runner: string, child?: string|null }} observations `null` = not observed
|
|
1170
|
+
* @param {Map<number, any>} memo probe memo of the pass that produced `observations`
|
|
1171
|
+
* @param {"recover"|"attach"} path which call site stranded
|
|
1172
|
+
*/
|
|
1173
|
+
function noteRecoveryPending(viewId, host, observations, memo, path) {
|
|
1174
|
+
for (const role of ["runner", "child"]) {
|
|
1175
|
+
const observation = observations[role];
|
|
1176
|
+
if (observation !== "unknown") continue;
|
|
1177
|
+
const pid = host?.[`${role}Identity`]?.pid ?? host?.[`${role}Pid`] ?? null;
|
|
1178
|
+
appendDiagnosticOnce(
|
|
1179
|
+
root,
|
|
1180
|
+
viewId,
|
|
1181
|
+
{
|
|
1182
|
+
source: "service",
|
|
1183
|
+
level: "warn",
|
|
1184
|
+
code: "host_recovery_pending",
|
|
1185
|
+
message: "Host recovery is pending: process identity unverifiable",
|
|
1186
|
+
details: {
|
|
1187
|
+
instanceId: host?.instanceId ?? null,
|
|
1188
|
+
role,
|
|
1189
|
+
observation,
|
|
1190
|
+
probe: (pid == null ? null : memo.get(pid)) ? "available" : "unavailable",
|
|
1191
|
+
path,
|
|
1192
|
+
},
|
|
1193
|
+
},
|
|
1194
|
+
{ match: ["instanceId", "role"] },
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Revoke + wait + replace a broken host (issue #70). The caller (attach
|
|
1201
|
+
* resolver) has already probed and decided the instance is unrecoverable.
|
|
1202
|
+
* Escalation signals go ONLY to identities observed as "owned" at that
|
|
1203
|
+
* moment, each guarded by an instance re-read; unknown identities leave the
|
|
1204
|
+
* host pending instead of guessing. The replacement claim is created only
|
|
1205
|
+
* after runner and child are both confirmed ended.
|
|
1206
|
+
* @param {string} viewId
|
|
1207
|
+
* @param {string} expectedInstanceId
|
|
1208
|
+
* @param {string|null} [sessionFile] session whose file identifies the child
|
|
1209
|
+
* process; defaults to the row's meta session file
|
|
1210
|
+
* @returns {Promise<{ ok: boolean, recovered?: boolean, instanceId?: string, socketPath?: string|null, error?: string }>}
|
|
1211
|
+
*/
|
|
1212
|
+
async function recoverHost(viewId, expectedInstanceId, sessionFile = loadRow(root, viewId)?.meta.sessionFile ?? null) {
|
|
1213
|
+
const ended = hostRoleEnded;
|
|
1214
|
+
const probeMemo = new Map();
|
|
1215
|
+
// Token reads are memoized separately from probes: probe values are objects,
|
|
1216
|
+
// token values are strings/null. One recovery pass reads each pid once.
|
|
1217
|
+
const tokenMemo = new Map();
|
|
1218
|
+
const instanceStillExpected = () => {
|
|
1219
|
+
const h = readHost(root, viewId);
|
|
1220
|
+
return h != null && h.instanceId === expectedInstanceId;
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
// Gate: another launch/recovery in flight, or the instance already moved on.
|
|
1224
|
+
const gate = tryAcquireLockImpl(root, viewId, "host-start", { identity: serviceIdentity() });
|
|
1225
|
+
if (!gate.acquired) return { ok: false, error: "busy_retry_later" };
|
|
1226
|
+
try {
|
|
1227
|
+
const current = readHost(root, viewId);
|
|
1228
|
+
if (!current || current.instanceId !== expectedInstanceId) return { ok: false, error: "already_recovered" };
|
|
1229
|
+
if (current.state === "exited" || current.state === "failed") return { ok: false, error: "already_recovered" };
|
|
1230
|
+
} finally {
|
|
1231
|
+
try { gate.lease.release(); } catch { /* best effort */ }
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// File-based revoke under the lease is done; release before waiting so the
|
|
1235
|
+
// dying runner's own finish path is never blocked by this recovery.
|
|
1236
|
+
requestHostStop(viewId, expectedInstanceId, "recovery");
|
|
1237
|
+
|
|
1238
|
+
// Bounded wait for runner/child to end, with identity-checked escalation.
|
|
1239
|
+
const startedAt = nowImpl();
|
|
1240
|
+
let runnerTermSent = false;
|
|
1241
|
+
let runnerKillSent = false;
|
|
1242
|
+
let childTermSent = false;
|
|
1243
|
+
let childKillSent = false;
|
|
1244
|
+
let childTermAt = 0;
|
|
1245
|
+
while (nowImpl() - startedAt < resolveHostRecoveryGraceMs()) {
|
|
1246
|
+
const host = readHost(root, viewId);
|
|
1247
|
+
if (!host || host.instanceId !== expectedInstanceId) return { ok: false, error: "already_recovered" };
|
|
1248
|
+
const runnerObservation = await observeHostRole(host, "runner", { sessionFile, memo: probeMemo, tokenMemo });
|
|
1249
|
+
const childObservation = await observeHostRole(host, "child", { sessionFile, memo: probeMemo, tokenMemo });
|
|
1250
|
+
if (ended(runnerObservation) && ended(childObservation)) break;
|
|
1251
|
+
// Alive-but-unverifiable: never signal, never claim — stay pending.
|
|
1252
|
+
if (runnerObservation === "unknown" || childObservation === "unknown") {
|
|
1253
|
+
noteRecoveryPending(viewId, host, { runner: runnerObservation, child: childObservation }, probeMemo, "recover");
|
|
1254
|
+
return { ok: false, error: "recovery_pending" };
|
|
1255
|
+
}
|
|
1256
|
+
if (!instanceStillExpected()) return { ok: false, error: "already_recovered" };
|
|
1257
|
+
const elapsed = nowImpl() - startedAt;
|
|
1258
|
+
if (runnerObservation === "owned") {
|
|
1259
|
+
if (!runnerTermSent) {
|
|
1260
|
+
if (await revalidateProbeOwned(host, "runner", sessionFile, probeMemo)) {
|
|
1261
|
+
signalOwnedProcessImpl(host.runnerIdentity, "SIGTERM");
|
|
1262
|
+
runnerTermSent = true;
|
|
1263
|
+
}
|
|
1264
|
+
} else if (elapsed >= resolveHostRecoveryGraceMs() / 2 && !runnerKillSent) {
|
|
1265
|
+
if (await revalidateProbeOwned(host, "runner", sessionFile, probeMemo)) {
|
|
1266
|
+
signalOwnedProcessImpl(host.runnerIdentity, "SIGKILL");
|
|
1267
|
+
runnerKillSent = true;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
// The child is only escalated once its runner is confirmed gone.
|
|
1272
|
+
if (childObservation === "owned" && runnerObservation !== "owned") {
|
|
1273
|
+
if (!childTermSent) {
|
|
1274
|
+
if (await revalidateProbeOwned(host, "child", sessionFile, probeMemo)) {
|
|
1275
|
+
signalOwnedProcessImpl(host.childIdentity, "SIGTERM");
|
|
1276
|
+
childTermSent = true;
|
|
1277
|
+
childTermAt = elapsed;
|
|
1278
|
+
}
|
|
1279
|
+
} else if (elapsed - childTermAt >= 1_000 && !childKillSent) {
|
|
1280
|
+
if (await revalidateProbeOwned(host, "child", sessionFile, probeMemo)) {
|
|
1281
|
+
signalOwnedProcessImpl(host.childIdentity, "SIGKILL");
|
|
1282
|
+
childKillSent = true;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
await sleepFnImpl(resolveHostRecoveryPollMs());
|
|
1287
|
+
}
|
|
1288
|
+
// Grace exhausted or loop broke — verify both roles are confirmed ended before claiming.
|
|
1289
|
+
const finalHost = readHost(root, viewId);
|
|
1290
|
+
if (!finalHost || finalHost.instanceId !== expectedInstanceId) return { ok: false, error: "already_recovered" };
|
|
1291
|
+
const finalRunnerObservation = await observeHostRole(finalHost, "runner", { sessionFile, memo: probeMemo, tokenMemo });
|
|
1292
|
+
// Decision order preserved: the child is observed only once the runner is ended,
|
|
1293
|
+
// and `null` means "not observed" so no diagnostic claims an unlooked-at role.
|
|
1294
|
+
const finalChildObservation = ended(finalRunnerObservation) ? await observeHostRole(finalHost, "child", { sessionFile, memo: probeMemo, tokenMemo }) : null;
|
|
1295
|
+
if (!ended(finalRunnerObservation) || !ended(finalChildObservation)) {
|
|
1296
|
+
noteRecoveryPending(viewId, finalHost, { runner: finalRunnerObservation, child: finalChildObservation }, probeMemo, "recover");
|
|
1297
|
+
return { ok: false, error: "recovery_pending" };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Claim the replacement under the host-start lease: finalize the old record
|
|
1301
|
+
// (clearing its process fields — they are confirmed gone) and claim fresh.
|
|
1302
|
+
let claimLease;
|
|
1303
|
+
try {
|
|
1304
|
+
claimLease = acquireLockImpl(root, viewId, "host-start", { waitMs: HOST_START_LOCK_WAIT_MS, identity: serviceIdentity() });
|
|
1305
|
+
} catch {
|
|
1306
|
+
return { ok: false, error: "busy_retry_later" };
|
|
1307
|
+
}
|
|
1308
|
+
try {
|
|
1309
|
+
const current = readHost(root, viewId);
|
|
1310
|
+
if (!current || current.instanceId !== expectedInstanceId) return { ok: false, error: "already_recovered" };
|
|
1311
|
+
const finalized = updateOwnedHost(root, viewId, expectedInstanceId, (h) => ({
|
|
1312
|
+
...h,
|
|
1313
|
+
state: "exited",
|
|
1314
|
+
endedAt: h.endedAt ?? nowImpl(),
|
|
1315
|
+
exitCode: h.exitCode ?? 0,
|
|
1316
|
+
runnerPid: null,
|
|
1317
|
+
runnerIdentity: null,
|
|
1318
|
+
runnerSpawnedAt: null,
|
|
1319
|
+
childPid: null,
|
|
1320
|
+
childIdentity: null,
|
|
1321
|
+
childSpawnedAt: null,
|
|
1322
|
+
}));
|
|
1323
|
+
if (!finalized.updated) return { ok: false, error: "owner_changed" };
|
|
1324
|
+
const instanceId = randomIdImpl();
|
|
1325
|
+
// claimPid stays null: this recovery transaction is COMPLETE once the claim
|
|
1326
|
+
// is on disk (nothing of ours is still between claim and spawn — spawning is
|
|
1327
|
+
// deliberately the adopter's job). A live claimPid would make ensureHost /
|
|
1328
|
+
// the resolver treat the replacement as a launcher mid-transaction and wait
|
|
1329
|
+
// out HOST_START_GRACE_MS before adopting, which starves the recovery chain
|
|
1330
|
+
// on slow machines (PR #84 A10 CI flake). Null = no launcher to protect:
|
|
1331
|
+
// claimerGone holds immediately, so adoption can fire on the next tick.
|
|
1332
|
+
const claimed = claimHost(root, {
|
|
1333
|
+
viewId,
|
|
1334
|
+
instanceId,
|
|
1335
|
+
configPath: P.hostConfigPathFor(root, viewId, instanceId),
|
|
1336
|
+
socketPath: P.hostEndpointPathFor(process.platform, root, viewId, instanceId),
|
|
1337
|
+
claimAt: nowImpl(),
|
|
1338
|
+
claimPid: null,
|
|
1339
|
+
claimIdentity: null,
|
|
1340
|
+
cols: Number(process.env.COLUMNS || 120),
|
|
1341
|
+
rows: Number(process.env.LINES || 36),
|
|
1342
|
+
});
|
|
1343
|
+
if (!claimed.claimed) return { ok: false, error: "claim_lost", socketPath: claimed.host?.socketPath ?? null };
|
|
1344
|
+
appendDiagnostic(root, viewId, { source: "service", code: "host_recovered", message: "PTY host recovered after revoke", details: { oldInstanceId: expectedInstanceId, instanceId } });
|
|
1345
|
+
return { ok: true, recovered: true, instanceId, socketPath: claimed.host?.socketPath ?? null };
|
|
1346
|
+
} finally {
|
|
1347
|
+
try { claimLease.release(); } catch { /* best effort */ }
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* ensureHost implementation (issue #70 Task 10 + Task 12 adopt branch).
|
|
1353
|
+
* @param {string} viewId
|
|
1354
|
+
* @returns {{ ok: boolean, pending?: boolean, started?: boolean, socketPath?: string|null, instanceId?: string|null, error?: string, fallbackReason?: string }}
|
|
1355
|
+
*/
|
|
1356
|
+
function ensureHostImpl(viewId) {
|
|
1357
|
+
const row = loadRow(root, viewId);
|
|
1358
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
1359
|
+
const host = row.host ?? null;
|
|
1360
|
+
// Adopt-and-spawn (issue #70 Task 12): a `starting` claim whose runner was never
|
|
1361
|
+
// spawned (the launcher crashed between claim and spawn, or recoverHost claimed
|
|
1362
|
+
// a replacement without spawning) would pend forever. Take it over ONLY when the
|
|
1363
|
+
// claim is provably stale or its claimer is gone. Recovery-created claims carry
|
|
1364
|
+
// claimPid:null (their transaction is complete; spawning is the adopter's job),
|
|
1365
|
+
// so claimerGone holds immediately and they adopt without a grace wait. Fresh
|
|
1366
|
+
// claims with a live claimPid stay untouched: their launcher may legitimately be
|
|
1367
|
+
// between claim and spawn inside the grace window.
|
|
1368
|
+
if (host?.state === "starting" && host.runnerSpawnedAt == null && host.instanceId != null) {
|
|
1369
|
+
const claimStale = nowImpl() - (host.claimAt ?? 0) > resolveHostStartGraceMs();
|
|
1370
|
+
const claimerGone = !isAlive(host.claimPid ?? null);
|
|
1371
|
+
if (claimStale || claimerGone) return adoptClaimedHost(viewId, row.meta, host);
|
|
1372
|
+
}
|
|
1373
|
+
if (row.hostActive) {
|
|
1374
|
+
return { ok: true, pending: true, socketPath: row.host?.socketPath ?? null, instanceId: row.host?.instanceId ?? null };
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Default probe semantics: success is cached for the process lifetime and a
|
|
1378
|
+
// failed probe retries on a short TTL. Forcing refresh here would spawn a
|
|
1379
|
+
// probe process on every keypress-driven prewarm when PTY support is broken.
|
|
1380
|
+
const pty = ptySupport();
|
|
1381
|
+
if (!pty.ok) return { ok: false, error: "PTY unavailable", fallbackReason: nodePtyFallbackMessage(pty) };
|
|
1382
|
+
if (isAgentBusy(row)) return { ok: false, error: "A non-live background run is active for this session" };
|
|
1383
|
+
// A missing file is a session that was put on the board before its first message. Pi starts
|
|
1384
|
+
// it at that path, as it would have.
|
|
1385
|
+
|
|
1386
|
+
const lock = tryAcquireLockImpl(root, viewId, "host-start", { identity: serviceIdentity() });
|
|
1387
|
+
if (!lock.acquired) {
|
|
1388
|
+
// Someone else (another board/service process) is mid-launch — its claim
|
|
1389
|
+
// will appear on disk; treat that as an in-flight start, not a failure.
|
|
1390
|
+
return { ok: true, pending: true, socketPath: null, instanceId: null };
|
|
1391
|
+
}
|
|
1392
|
+
try {
|
|
1393
|
+
const inner = readHost(root, viewId);
|
|
1394
|
+
if (inner && (inner.state === "starting" || inner.state === "alive" || inner.state === "stopping")) {
|
|
1395
|
+
return { ok: true, pending: true, socketPath: inner.socketPath ?? null, instanceId: inner.instanceId ?? null };
|
|
1396
|
+
}
|
|
1397
|
+
const res = startHostUnderLease(row.meta, null, { markQueued: false });
|
|
1398
|
+
if (res.ok && res.status === "started") pruneWarmHosts({ keepViewId: viewId });
|
|
1399
|
+
return res.ok
|
|
1400
|
+
? { ok: true, started: res.status === "started", pending: res.status !== "started", socketPath: res.socketPath, instanceId: res.instanceId }
|
|
1401
|
+
: res;
|
|
1402
|
+
} finally {
|
|
1403
|
+
try { lock.lease.release(); } catch { /* best effort */ }
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Finish an abandoned host claim: spawn its runner under the `host-start` lease
|
|
1409
|
+
* (issue #70 Task 12). The claim's config was never written (the claimer died
|
|
1410
|
+
* before spawn, or recoverHost claims without writing one — Task 11 rider a), so
|
|
1411
|
+
* it is rebuilt from the view meta here. A failed spawn marks the claim failed
|
|
1412
|
+
* fenced (clearing the dead claimer's fields) so the next ensure can claim anew.
|
|
1413
|
+
* @param {string} viewId
|
|
1414
|
+
* @param {import("../core/types.mjs").ViewMeta} meta
|
|
1415
|
+
* @param {import("../core/types.mjs").HostStatus} host
|
|
1416
|
+
*/
|
|
1417
|
+
function adoptClaimedHost(viewId, meta, host) {
|
|
1418
|
+
const instanceId = host.instanceId;
|
|
1419
|
+
const lock = tryAcquireLockImpl(root, viewId, "host-start", { identity: serviceIdentity() });
|
|
1420
|
+
if (!lock.acquired) {
|
|
1421
|
+
// Another adopter/spawner is mid-transaction — its result lands on disk.
|
|
1422
|
+
return { ok: true, pending: true, socketPath: host.socketPath ?? null, instanceId };
|
|
1423
|
+
}
|
|
1424
|
+
try {
|
|
1425
|
+
const current = readHost(root, viewId);
|
|
1426
|
+
if (!current || current.instanceId !== instanceId || current.state !== "starting" || current.runnerSpawnedAt != null) {
|
|
1427
|
+
// The record moved on (adopted elsewhere, revoked, or replaced) — surface it.
|
|
1428
|
+
return { ok: true, pending: true, socketPath: current?.socketPath ?? host.socketPath ?? null, instanceId: current?.instanceId ?? instanceId };
|
|
1429
|
+
}
|
|
1430
|
+
// Stale-model guard (issue #90): the claim is an abandoned record, so mark
|
|
1431
|
+
// it failed fenced (same shape as the spawn-failure path below — the next
|
|
1432
|
+
// ensure can claim anew) instead of spawning a child doomed to exit 1.
|
|
1433
|
+
const modelError = validateViewModelMeta(meta);
|
|
1434
|
+
if (modelError) {
|
|
1435
|
+
updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: modelError, claimPid: null, claimIdentity: null }));
|
|
1436
|
+
return { ok: true, pending: true, socketPath: null, instanceId };
|
|
1437
|
+
}
|
|
1438
|
+
const configPath = current.configPath ?? P.hostConfigPathFor(root, viewId, instanceId);
|
|
1439
|
+
const socketPath = current.socketPath ?? P.hostEndpointPathFor(process.platform, root, viewId, instanceId);
|
|
1440
|
+
/** @type {import("../core/types.mjs").HostConfig} */
|
|
1441
|
+
const config = {
|
|
1442
|
+
root,
|
|
1443
|
+
viewId,
|
|
1444
|
+
sessionFile: meta.sessionFile,
|
|
1445
|
+
cwd: meta.cwd,
|
|
1446
|
+
initialPrompt: null,
|
|
1447
|
+
piCommand: opts.piCommand,
|
|
1448
|
+
piArgsPrefix: opts.piArgsPrefix,
|
|
1449
|
+
model: meta.defaultModel ?? null,
|
|
1450
|
+
thinkingLevel: meta.defaultThinking ?? null,
|
|
1451
|
+
tools: null,
|
|
1452
|
+
env: {},
|
|
1453
|
+
cols: current.cols || 120,
|
|
1454
|
+
rows: current.rows || 36,
|
|
1455
|
+
screenLogMaxBytes: normalizeScreenLogMaxBytes(readLaunchPrefs(root).screenLogMaxSize),
|
|
1456
|
+
instanceId,
|
|
1457
|
+
configPath,
|
|
1458
|
+
socketPath,
|
|
1459
|
+
};
|
|
1460
|
+
if (!existsSync(configPath)) atomicWriteJson(configPath, config);
|
|
1461
|
+
let pid = null;
|
|
1462
|
+
let spawnError = null;
|
|
1463
|
+
try {
|
|
1464
|
+
({ pid } = launchHostImpl(root, config, { runnerScript: ptyRunnerScript }));
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
spawnError = err instanceof Error ? err.message : String(err);
|
|
1467
|
+
}
|
|
1468
|
+
if (pid == null) {
|
|
1469
|
+
const message = spawnError ?? "PTY host runner failed to spawn (adopted claim)";
|
|
1470
|
+
// Clear the dead claimer's fields (failed fenced — keeps the record
|
|
1471
|
+
// replaceable without depending on claimer liveness; canReplaceHost no
|
|
1472
|
+
// longer consults claim fields, see issue #99).
|
|
1473
|
+
updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, state: "failed", endedAt: nowImpl(), exitCode: 1, error: message, claimPid: null, claimIdentity: null }));
|
|
1474
|
+
removeFile(configPath);
|
|
1475
|
+
return { ok: true, pending: true, socketPath: null, instanceId };
|
|
1476
|
+
}
|
|
1477
|
+
updateOwnedHost(root, viewId, instanceId, (h) => ({ ...h, runnerPid: pid, runnerSpawnedAt: nowImpl() }));
|
|
1478
|
+
appendDiagnostic(root, viewId, { source: "service", code: "host_adopted", message: "Abandoned host claim adopted; runner spawned", details: { pid, instanceId } });
|
|
1479
|
+
return { ok: true, started: true, socketPath, instanceId };
|
|
1480
|
+
} finally {
|
|
1481
|
+
try { lock.lease.release(); } catch { /* best effort */ }
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* The single attach authority (issue #70 Task 12): resolves a view to a PTY
|
|
1487
|
+
* target via REAL socket probes, waiting out cold starts, adopting abandoned
|
|
1488
|
+
* claims, and running one bounded recovery per invocation. Never downgrades to
|
|
1489
|
+
* `session` while a host claim is active. All waits go through sleepFn — the
|
|
1490
|
+
* resolver runs on the UI thread and must never block on a lock.
|
|
1491
|
+
* @param {string} viewId
|
|
1492
|
+
* @param {number} timeoutMs
|
|
1493
|
+
* @returns {Promise<{kind:"pty",socketPath:string|null,sessionFile:string,instanceId:string|null}|{kind:"session",sessionFile:string}|{kind:"pending",sessionFile:string,reason:string}|{kind:"missing"}>}
|
|
1494
|
+
*/
|
|
1495
|
+
async function resolveAttachTargetInner(viewId, timeoutMs) {
|
|
1496
|
+
const deadline = nowImpl() + timeoutMs;
|
|
1497
|
+
let ensured = false;
|
|
1498
|
+
let recovered = false;
|
|
1499
|
+
// Last adopt-and-spawn attempt (issue #70 CI wave 2): adoption is retried
|
|
1500
|
+
// across loop iterations while the stale-claim conditions hold — a single
|
|
1501
|
+
// failed/contended attempt must not pend the resolver to its deadline. The
|
|
1502
|
+
// host-start lease inside adoptClaimedHost serializes attempts (idempotent:
|
|
1503
|
+
// a successful adoption flips runnerSpawnedAt and the conditions stop
|
|
1504
|
+
// holding), and this timestamp rate-limits post-adopt re-ensures.
|
|
1505
|
+
let lastAdoptAt = 0;
|
|
1506
|
+
/** @param {string} reason @param {string} sessionFile */
|
|
1507
|
+
const pending = (sessionFile, reason) => ({ kind: "pending", sessionFile, reason });
|
|
1508
|
+
for (;;) {
|
|
1509
|
+
const row = loadRow(root, viewId);
|
|
1510
|
+
if (!row) return { kind: "missing" };
|
|
1511
|
+
const sessionFile = row.meta.sessionFile;
|
|
1512
|
+
if (nowImpl() >= deadline) return pending(sessionFile, "host start timed out");
|
|
1513
|
+
const host = row.host ?? null;
|
|
1514
|
+
|
|
1515
|
+
// No active claim: live JSON runners pend (no parallel PTY child); otherwise
|
|
1516
|
+
// ensure exactly one host start per invocation, then fall through to probing.
|
|
1517
|
+
if (!host || !row.hostActive) {
|
|
1518
|
+
if (isAgentBusy(row)) return pending(sessionFile, "background run active");
|
|
1519
|
+
// A claim that turned terminal AFTER our adopt attempt (spawn failed —
|
|
1520
|
+
// adoptClaimedHost marks it failed fenced) may be re-claimed, at most
|
|
1521
|
+
// once per grace window: the fresh claim's own grace period spaces the
|
|
1522
|
+
// attempts out and the resolver deadline bounds the total, preserving the
|
|
1523
|
+
// once-per-invocation ensure guarantee as a rate limit rather than a
|
|
1524
|
+
// pend-forever on one bad attempt.
|
|
1525
|
+
if (
|
|
1526
|
+
(host?.state === "exited" || host?.state === "failed") &&
|
|
1527
|
+
lastAdoptAt > 0 &&
|
|
1528
|
+
nowImpl() - lastAdoptAt >= resolveHostStartGraceMs()
|
|
1529
|
+
) {
|
|
1530
|
+
ensured = false;
|
|
1531
|
+
}
|
|
1532
|
+
if (!ensured) {
|
|
1533
|
+
ensured = true;
|
|
1534
|
+
const res = ensureHostImpl(viewId);
|
|
1535
|
+
if (!res.ok) {
|
|
1536
|
+
if (res.fallbackReason != null) {
|
|
1537
|
+
// PTY unavailable and nothing active — safe to fall back to session attach.
|
|
1538
|
+
const fresh = loadRow(root, viewId);
|
|
1539
|
+
return fresh && !fresh.hostActive ? { kind: "session", sessionFile: fresh.meta.sessionFile } : pending(sessionFile, res.error ?? "host ensure failed");
|
|
1540
|
+
}
|
|
1541
|
+
return pending(sessionFile, res.error ?? "host ensure failed");
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// A revoke is in flight — wait it out; never revoke twice. But a stale
|
|
1549
|
+
// `stopping` record whose processes are provably gone is a stranded
|
|
1550
|
+
// deadlock (SIGKILLed runner never finalizes; nothing else covers stopping)
|
|
1551
|
+
// — finalize it through the same bounded recovery path (CR r1 f2).
|
|
1552
|
+
if (host.state === "stopping") {
|
|
1553
|
+
const staleStop = host.instanceId != null && nowImpl() - (host.stopRequestedAt ?? host.claimAt ?? 0) >= resolveHostRecoveryGraceMs();
|
|
1554
|
+
if (staleStop && !recovered) {
|
|
1555
|
+
const probeMemo = new Map();
|
|
1556
|
+
// Fresh token memo per branch entry; never shared with probeMemo.
|
|
1557
|
+
const tokenMemo = new Map();
|
|
1558
|
+
const runnerObs = await observeHostRole(host, "runner", { sessionFile, memo: probeMemo, tokenMemo });
|
|
1559
|
+
const childObs = await observeHostRole(host, "child", { sessionFile, memo: probeMemo, tokenMemo });
|
|
1560
|
+
if (runnerObs === "unknown" || childObs === "unknown") {
|
|
1561
|
+
// Unverifiable processes on a stranded record — never force.
|
|
1562
|
+
noteRecoveryPending(viewId, host, { runner: runnerObs, child: childObs }, probeMemo, "attach");
|
|
1563
|
+
return pending(sessionFile, "recovery_pending: host identity unverifiable");
|
|
1564
|
+
}
|
|
1565
|
+
if (hostRoleEnded(runnerObs) && hostRoleEnded(childObs)) {
|
|
1566
|
+
recovered = true;
|
|
1567
|
+
try {
|
|
1568
|
+
const rec = await recoverHost(viewId, /** @type {string} */ (host.instanceId), sessionFile);
|
|
1569
|
+
if (!rec.ok && rec.error === "recovery_pending") {
|
|
1570
|
+
return pending(sessionFile, "recovery_pending: host identity unverifiable");
|
|
1571
|
+
}
|
|
1572
|
+
} catch {
|
|
1573
|
+
// recoverHost rethrows non-ESRCH signal failures — surface, don't crash.
|
|
1574
|
+
}
|
|
1575
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const legacy = host.instanceId == null;
|
|
1584
|
+
const withinGrace = host.state === "starting" && (legacy || (host.claimPid != null && nowImpl() - (host.claimAt ?? 0) < resolveHostStartGraceMs()));
|
|
1585
|
+
const probe = await probeHostImpl(host.socketPath ?? "", { expectedViewId: viewId, expectedInstanceId: host.instanceId ?? null });
|
|
1586
|
+
if (probe.classification === "ready") {
|
|
1587
|
+
return { kind: "pty", socketPath: host.socketPath ?? null, sessionFile, instanceId: host.instanceId ?? null };
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
if (host.state === "starting") {
|
|
1591
|
+
// Gap-closer: a claim whose runner was never spawned (recovery claim or an
|
|
1592
|
+
// abandoned launch) is adopted past grace. Recovery-created claims carry
|
|
1593
|
+
// claimPid:null — no launcher to protect — so they are NOT within grace and
|
|
1594
|
+
// adopt immediately; claims with a live claimPid wait out the window like
|
|
1595
|
+
// any launcher mid-transaction (contract §6).
|
|
1596
|
+
// Retried per iteration: a contended or failed attempt must not pend
|
|
1597
|
+
// the resolver to its deadline (issue #70 CI wave 2).
|
|
1598
|
+
if (!legacy && host.runnerSpawnedAt == null && probe.classification === "missing" && !withinGrace) {
|
|
1599
|
+
lastAdoptAt = nowImpl();
|
|
1600
|
+
ensureHostImpl(viewId);
|
|
1601
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
if (withinGrace || legacy) {
|
|
1605
|
+
// A legacy starting host whose runner is provably dead would wait out
|
|
1606
|
+
// the grace window forever (withinGrace is always true for legacy) —
|
|
1607
|
+
// finalize it now instead (issue #87).
|
|
1608
|
+
if (legacy && finalizeDeadLegacyHost(root, viewId, host, probe.classification, nowImpl(), tryAcquireLockImpl)) {
|
|
1609
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
// Normal cold start (or a legacy starting host — legacy is never recovered,
|
|
1613
|
+
// spec §10.1): wait out the grace window.
|
|
1614
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
} else if (probe.classification === "starting" || probe.classification === "occupied") {
|
|
1618
|
+
// Alive record, mid-transition or a foreign listener — wait, don't touch.
|
|
1619
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1620
|
+
continue;
|
|
1621
|
+
} else if (legacy) {
|
|
1622
|
+
// Legacy host unreachable. spec §10.1 says never recover — unless the
|
|
1623
|
+
// runner pid is provably dead, in which case finalize and self-heal
|
|
1624
|
+
// (issue #87).
|
|
1625
|
+
if (finalizeDeadLegacyHost(root, viewId, host, probe.classification, nowImpl(), tryAcquireLockImpl)) {
|
|
1626
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
return pending(sessionFile, "legacy host unreachable — manual restart needed");
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// Unhealthy new-protocol host past grace: one bounded recovery per invocation.
|
|
1633
|
+
if (!recovered) {
|
|
1634
|
+
recovered = true;
|
|
1635
|
+
try {
|
|
1636
|
+
const rec = await recoverHost(viewId, /** @type {string} */ (host.instanceId), sessionFile);
|
|
1637
|
+
if (!rec.ok && rec.error === "recovery_pending") {
|
|
1638
|
+
return pending(sessionFile, "recovery_pending: host identity unverifiable");
|
|
1639
|
+
}
|
|
1640
|
+
// recovered / busy_retry_later / already_recovered — loop and re-evaluate.
|
|
1641
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1642
|
+
continue;
|
|
1643
|
+
} catch {
|
|
1644
|
+
// recoverHost rethrows non-ESRCH signal failures (Task 11 rider b) — never
|
|
1645
|
+
// guess at process state; surface as pending for the next attempt.
|
|
1646
|
+
return pending(sessionFile, "recovery signal failed");
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
await sleepFnImpl(HOST_PROBE_RETRY_MS);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/** @param {import("../core/types.mjs").FollowUpKind|import("../core/types.mjs").RunKind|string} kind */
|
|
1654
|
+
function runKindForKind(kind) {
|
|
1655
|
+
switch (kind) {
|
|
1656
|
+
case "plan_request":
|
|
1657
|
+
return "plan";
|
|
1658
|
+
case "plan_change":
|
|
1659
|
+
return "plan_change";
|
|
1660
|
+
case "plan_approval":
|
|
1661
|
+
return "plan_approval";
|
|
1662
|
+
case "plan":
|
|
1663
|
+
case "reply":
|
|
1664
|
+
case "dispatch":
|
|
1665
|
+
return kind;
|
|
1666
|
+
default:
|
|
1667
|
+
return "reply";
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/** @param {import("../core/types.mjs").FollowUpItem} item */
|
|
1672
|
+
function runKindForFollowUp(item) {
|
|
1673
|
+
return runKindForKind(item.kind);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/** @param {string} viewId @param {import("../core/types.mjs").FollowUpItem} item */
|
|
1677
|
+
function promptForFollowUp(viewId, item) {
|
|
1678
|
+
const steering = readSteering(root, viewId);
|
|
1679
|
+
switch (item.kind) {
|
|
1680
|
+
case "plan_request":
|
|
1681
|
+
return buildPlanRequestPrompt(item.text);
|
|
1682
|
+
case "plan_approval":
|
|
1683
|
+
return buildApprovePlanPrompt(steering.planText);
|
|
1684
|
+
case "plan_change":
|
|
1685
|
+
return buildPlanChangesPrompt(steering.planText, item.text);
|
|
1686
|
+
default:
|
|
1687
|
+
return item.text;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* Claim the next queued follow-up and deliver it. Host-path delivery is
|
|
1693
|
+
* async: the item is only completed on input_ack; any failure releases it
|
|
1694
|
+
* back to queued for a later retry (issue #70 A13).
|
|
1695
|
+
* @param {string} viewId
|
|
1696
|
+
* @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)
|
|
1697
|
+
*/
|
|
1698
|
+
async function drainNextFollowUp(viewId) {
|
|
1699
|
+
const row = loadRow(root, viewId);
|
|
1700
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
1701
|
+
if (!canAutoDrain(row)) return { ok: false, error: "Session is not ready to drain queued follow-ups" };
|
|
1702
|
+
const claimed = claimNextFollowUp(root, viewId);
|
|
1703
|
+
if (!claimed.ok || !claimed.item) return claimed;
|
|
1704
|
+
const item = claimed.item;
|
|
1705
|
+
const prompt = promptForFollowUp(viewId, item);
|
|
1706
|
+
try {
|
|
1707
|
+
if (item.kind === "plan_approval") markExecutingApprovedPlan(root, viewId);
|
|
1708
|
+
if (row.hostActive) {
|
|
1709
|
+
const socketPath = row.host?.socketPath;
|
|
1710
|
+
// Legacy host (pre-upgrade runner, no instanceId): it never sends
|
|
1711
|
+
// input_ack, so the ack path would loop forever. Deliver with the
|
|
1712
|
+
// old fire-and-forget semantics and complete on connect (final
|
|
1713
|
+
// review finding 1).
|
|
1714
|
+
if (row.host?.instanceId == null) {
|
|
1715
|
+
const sent = sendHostMessage(row, { type: "input", data: `${prompt}\r` });
|
|
1716
|
+
if (sent.ok) {
|
|
1717
|
+
completeFollowUp(root, viewId, item.id);
|
|
1718
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind: item.kind, legacy: true } });
|
|
1719
|
+
return { ok: true, sent: true, item };
|
|
1720
|
+
}
|
|
1721
|
+
releaseFollowUp(root, viewId, item.id);
|
|
1722
|
+
appendDiagnostic(root, viewId, { source: "queue", level: "warn", code: "follow_up_send_failed", message: "Queued follow-up could not be delivered to the host", details: { kind: item.kind, error: sent.error ?? "unknown", legacy: true } });
|
|
1723
|
+
return { ok: true, pending: true, item };
|
|
1724
|
+
}
|
|
1725
|
+
const sent = socketPath
|
|
1726
|
+
? await sendHostInput(socketPath, `${prompt}\r`, {
|
|
1727
|
+
requestId: item.id,
|
|
1728
|
+
commandId: item.id,
|
|
1729
|
+
viewId,
|
|
1730
|
+
instanceId: row.host.instanceId,
|
|
1731
|
+
})
|
|
1732
|
+
: { ok: false, error: "No host socket", retryable: true };
|
|
1733
|
+
if (sent.ok) {
|
|
1734
|
+
completeFollowUp(root, viewId, item.id);
|
|
1735
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind: item.kind } });
|
|
1736
|
+
return { ok: true, sent: true, item };
|
|
1737
|
+
}
|
|
1738
|
+
if (sent.ambiguous) {
|
|
1739
|
+
// §10: the prompt was accepted by a host that restarted before
|
|
1740
|
+
// delivering it — a re-send can only return cached stages, so the
|
|
1741
|
+
// honest terminal state is failed-with-reason (mirrors the queue's
|
|
1742
|
+
// existing failure marking; the item keeps its text for re-send).
|
|
1743
|
+
failFollowUp(root, viewId, item.id, "accepted_unknown: accepted but never applied; outcome unknowable (§10); not re-sent");
|
|
1744
|
+
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 } });
|
|
1745
|
+
return { ok: true, failed: true, item };
|
|
1746
|
+
}
|
|
1747
|
+
releaseFollowUp(root, viewId, item.id);
|
|
1748
|
+
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 } });
|
|
1749
|
+
return { ok: true, pending: true, item };
|
|
1750
|
+
}
|
|
1751
|
+
// Poll-path drain (reconcile): cached probe semantics — success cached for
|
|
1752
|
+
// process lifetime, failures retried on a 2s TTL; never force a real
|
|
1753
|
+
// pty.spawn on every 700ms cycle when a follow-up keeps failing to start.
|
|
1754
|
+
const pty = ptySupport();
|
|
1755
|
+
let runId = null;
|
|
1756
|
+
let hostMode = "pty";
|
|
1757
|
+
if (!pty.ok) hostMode = "json-runner";
|
|
1758
|
+
if (pty.ok) {
|
|
1759
|
+
const launched = await launchHost(row.meta, prompt);
|
|
1760
|
+
if (launched.ok && launched.status !== "started") {
|
|
1761
|
+
// An existing claim owns the view; the item stays queued and retries
|
|
1762
|
+
// after that host becomes ready (issue #70 prompt-not-lost invariant).
|
|
1763
|
+
releaseFollowUp(root, viewId, item.id);
|
|
1764
|
+
return { ok: true, pending: true };
|
|
1765
|
+
}
|
|
1766
|
+
if (!launched.ok) hostMode = "json-runner";
|
|
1767
|
+
}
|
|
1768
|
+
if (hostMode === "json-runner" && isExternalSession(row.meta)) {
|
|
1769
|
+
// Adopted (external) sessions must never continue via the json runner —
|
|
1770
|
+
// only a PTY host can resume them safely (pre-existing guard).
|
|
1771
|
+
releaseFollowUp(root, viewId, item.id);
|
|
1772
|
+
appendDiagnostic(root, viewId, { source: "queue", level: "warn", code: "follow_up_waiting_for_pty", message: "Adopted session follow-up is waiting for PTY support", details: {} });
|
|
1773
|
+
return { ok: false, error: "PTY is required to drain adopted session follow-ups safely" };
|
|
1774
|
+
}
|
|
1775
|
+
if (hostMode === "json-runner") {
|
|
1776
|
+
({ runId } = await launchForView(row.meta, prompt, runKindForFollowUp(item)));
|
|
1777
|
+
}
|
|
1778
|
+
completeFollowUp(root, viewId, item.id, { runId });
|
|
1779
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_started", message: "Queued follow-up started", details: { kind: item.kind, hostMode } });
|
|
1780
|
+
return { ok: true, started: true, item };
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
releaseFollowUp(root, viewId, item.id);
|
|
1783
|
+
appendDiagnostic(root, viewId, { source: "queue", level: "error", code: "follow_up_drain_failed", message: "Queued follow-up failed to start", details: { error: err instanceof Error ? err.message : String(err) } });
|
|
1784
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* Mirror a foreground/hosted session event into the row state. Async since
|
|
1790
|
+
* issue #91: the terminal classification routes through the View State
|
|
1791
|
+
* Coordinator command socket (fire-and-forget callers are fine — the returned
|
|
1792
|
+
* promise resolves after the classification outcome is known).
|
|
1793
|
+
* @param {import("../core/store.mjs").Row} row @param {any} event
|
|
1794
|
+
* @returns {Promise<boolean>} whether a managed row was updated
|
|
1795
|
+
*/
|
|
1796
|
+
async function syncRowEvent(row, event) {
|
|
1797
|
+
const now = Date.now();
|
|
1798
|
+
const status = statusFromRow(row);
|
|
1799
|
+
// Issue #113: the previous event's projection may still be in flight (the
|
|
1800
|
+
// coordinator journals + fsyncs before materializing), so a disk rebuild
|
|
1801
|
+
// can lag the freshest known preview/activity. Restore read-your-writes
|
|
1802
|
+
// before any event branch touches the status.
|
|
1803
|
+
foregroundPreviewCache.backfill(row.meta.id, status);
|
|
1804
|
+
// Issue #225: correct a stale disk rebuild before the new event is reduced.
|
|
1805
|
+
foregroundProjectionCache.adopt(row.meta.id, status);
|
|
1806
|
+
let evidence = readEvidence(root, row.meta.id);
|
|
1807
|
+
if (!evidence.viewId) evidence = emptyEvidenceSnapshot({ viewId: row.meta.id, source: "hosted" });
|
|
1808
|
+
try {
|
|
1809
|
+
reduceEvidence(evidence, event, now);
|
|
1810
|
+
writeEvidence(root, evidence);
|
|
1811
|
+
updateCodeRefsFromEvidence(root, row.meta.id, evidence, row.meta);
|
|
1812
|
+
} catch (err) {
|
|
1813
|
+
appendDiagnostic(root, row.meta.id, { source: "evidence", level: "warn", code: "evidence_reduce_failed", message: "Could not reduce hosted evidence", details: { error: err instanceof Error ? err.message : String(err) } });
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
if (event.type === "agent_board_waiting") {
|
|
1817
|
+
status.semanticState = event.waiting ? "needs_input" : "working";
|
|
1818
|
+
status.processState = "alive";
|
|
1819
|
+
status.question = event.waiting ? "Waiting on a prompt. Attach to answer it." : null;
|
|
1820
|
+
// Counts as pending so the host isn't evicted, and so a reply can tell it's there.
|
|
1821
|
+
status.pendingQuestions = event.waiting ? [{ kind: DIALOG, question: status.question }] : [];
|
|
1822
|
+
status.summary = event.waiting ? "Waiting on a prompt" : "Running…";
|
|
1823
|
+
status.lastActivityAt = now;
|
|
1824
|
+
void writeForegroundState(row, status);
|
|
1825
|
+
return true;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
if (event.type === "input" || event.type === "before_agent_start" || event.type === "agent_start") {
|
|
1829
|
+
status.semanticState = "working";
|
|
1830
|
+
status.processState = "alive";
|
|
1831
|
+
status.currentTool = null;
|
|
1832
|
+
status.question = null;
|
|
1833
|
+
status.pendingQuestions = [];
|
|
1834
|
+
status.error = null;
|
|
1835
|
+
status.summary = "Running…";
|
|
1836
|
+
status.lastActivityAt = now;
|
|
1837
|
+
// F-lift (issue #145): pi's InputEvent carries source; "interactive"
|
|
1838
|
+
// means a human submitted this text (attach keystrokes, a dashboard
|
|
1839
|
+
// reply injected into a live host, or an auto-drained follow-up — all
|
|
1840
|
+
// arrive as PTY bytes, which pi classifies as interactive). A manual
|
|
1841
|
+
// verdict must not outlive the user speaking to the row, so this one
|
|
1842
|
+
// mirror travels as dashboard-user and passes the manual_fence guard.
|
|
1843
|
+
// Safe by construction: stale_run cannot fire (runId stays null) and
|
|
1844
|
+
// the coordinator shell never reads source — manual_fence is the only
|
|
1845
|
+
// guard this crosses. rpc/extension injections keep source "service".
|
|
1846
|
+
const userSpoke = event.type === "input" && event.source === "interactive";
|
|
1847
|
+
// Throughput path: periodic self-healing mirror, fire-and-forget.
|
|
1848
|
+
void writeForegroundState(row, status, userSpoke ? { source: "dashboard-user" } : {});
|
|
1849
|
+
return true;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
if (event.type === "agent_end") {
|
|
1853
|
+
status.pendingQuestions = status.pendingQuestions.filter((question) => question?.kind !== DIALOG);
|
|
1854
|
+
// #46-class fence (issue #91): a manual verdict is a user judgment that
|
|
1855
|
+
// outlives the turn. A late/duplicate agent_end after markCompleted must
|
|
1856
|
+
// not resurrect the row via finalizeRun + projection — skip all semantic
|
|
1857
|
+
// writes (baseline, steering, classification) and keep evidence only.
|
|
1858
|
+
// The fence read is repeated after the classification await below; the
|
|
1859
|
+
// window between the two reads is covered by the coordinator's own
|
|
1860
|
+
// manual_fence rejection plus the guarded tail write.
|
|
1861
|
+
if (isManualVerdict(readState(root, row.meta.id))) {
|
|
1862
|
+
status.evidenceSummary = summarizeEvidence(evidence);
|
|
1863
|
+
writeEvidence(root, evidence);
|
|
1864
|
+
return false;
|
|
1865
|
+
}
|
|
1866
|
+
finalizeRun(status, { exitCode: 0 }, now);
|
|
1867
|
+
finalizeEvidence(evidence, status, now);
|
|
1868
|
+
const steering = readSteering(root, row.meta.id);
|
|
1869
|
+
let classificationQueued = false;
|
|
1870
|
+
if (steering.status === "plan_requested" || steering.status === "changes_requested") {
|
|
1871
|
+
recordPlanReady(root, row.meta.id, { planText: latestEvidenceText(evidence) || status.latestAssistantPreview || evidence.summary || "Plan ready", runId: status.runId });
|
|
1872
|
+
status.semanticState = "needs_input";
|
|
1873
|
+
status.question = "Approve this plan?";
|
|
1874
|
+
status.summary = "Plan ready for approval";
|
|
1875
|
+
} else {
|
|
1876
|
+
// Baseline the exited projection BEFORE the classification command:
|
|
1877
|
+
// the coordinator's delegated auto-state rules key on processState,
|
|
1878
|
+
// and its materialized patch lands after this write and wins. When
|
|
1879
|
+
// the coordinator applies, the projection write below is skipped so
|
|
1880
|
+
// the stale in-memory fields cannot clobber the patch (#46 class).
|
|
1881
|
+
// The legacy coordinator_disabled path applies in-memory instead and
|
|
1882
|
+
// still needs the tail projection write (pre-coordinator behavior).
|
|
1883
|
+
// Awaited: this ordering IS the fence — the classification command
|
|
1884
|
+
// must observe processState "exited" or its delegated rules skip.
|
|
1885
|
+
await writeForegroundState(row, status);
|
|
1886
|
+
classificationQueued = await queueAutoState(row.meta, status, evidence);
|
|
1887
|
+
if (classificationQueued) finalizeEvidence(evidence, status, now);
|
|
1888
|
+
}
|
|
1889
|
+
status.evidenceSummary = summarizeEvidence(evidence);
|
|
1890
|
+
writeEvidence(root, evidence);
|
|
1891
|
+
updateCodeRefsFromEvidence(root, row.meta.id, evidence, row.meta);
|
|
1892
|
+
// Re-read the fence after the classification await: a manual verdict landing
|
|
1893
|
+
// between the baseline write and the coordinator's decision read gets
|
|
1894
|
+
// manual_fence back (classificationQueued=false), and writing the stale
|
|
1895
|
+
// in-memory projection here would clobber it (#46 class).
|
|
1896
|
+
if (!isManualVerdict(readState(root, row.meta.id)) && !(classificationQueued && !coordinatorDisabled())) await writeForegroundState(row, status);
|
|
1897
|
+
pruneWarmHosts({ keepViewId: row.meta.id });
|
|
1898
|
+
// Async delivery (ack-gated, issue #70 A13): fire-and-forget here — the
|
|
1899
|
+
// queue item's own state records the outcome, ordering is preserved by
|
|
1900
|
+
// the queue lock, and requestId dedup makes late retries harmless.
|
|
1901
|
+
void drainNextFollowUp(row.meta.id);
|
|
1902
|
+
return true;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
if (reduceEvent(status, event, now, { interactive: true })) {
|
|
1906
|
+
status.processState = "alive";
|
|
1907
|
+
// Throughput path: periodic self-healing mirror, fire-and-forget.
|
|
1908
|
+
void writeForegroundState(row, status);
|
|
1909
|
+
return true;
|
|
1910
|
+
}
|
|
1911
|
+
return false;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* Reconcile pre-pass (issue #230): when a warm host has died (state
|
|
1916
|
+
* `exited`/`failed` and no longer alive) while its session owns a paused
|
|
1917
|
+
* background workflow run, the row otherwise stays silent. Announce the
|
|
1918
|
+
* interruption by patching `summary` (through patch_fields so the manual
|
|
1919
|
+
* fence still governs) and record one diagnostic per run.
|
|
1920
|
+
*
|
|
1921
|
+
* The memo key is `(viewId, host.endedAt)`. A successful probe is memoized
|
|
1922
|
+
* except for the grace window right after the host ended: the workflow
|
|
1923
|
+
* engine writes the `paused` head while settling in-flight step children
|
|
1924
|
+
* during SIGTERM shutdown, and that write can land after the board finalizes
|
|
1925
|
+
* `host.state = exited` (finalization waits for the runner and child pids to
|
|
1926
|
+
* die, not for the engine's last write). A probe in that window reports
|
|
1927
|
+
* `running`, so any still-`running` result inside `NOTICE_FLIP_GRACE_MS`
|
|
1928
|
+
* stays un-memoized and re-probes next tick to catch the flip — including
|
|
1929
|
+
* when it coexists with an older `paused` run, which stays idempotent to
|
|
1930
|
+
* announce. Re-probing stops after `NOTICE_FLIP_GRACE_MS`; a `running` record
|
|
1931
|
+
* that survives the grace window is treated as a hard-kill artifact with no
|
|
1932
|
+
* notice (issue #230 follow-up). `!probe.ok` stays un-memoized so a transient
|
|
1933
|
+
* read failure retries next tick. Notice fires only for `paused`.
|
|
1934
|
+
*
|
|
1935
|
+
* An already-announced row still costs one probe per reconcile so a later
|
|
1936
|
+
* host incarnation can surface a new interrupted run; the summary write is
|
|
1937
|
+
* skipped only when it already names this same runId, and diagnostics stay
|
|
1938
|
+
* deduped per runId.
|
|
1939
|
+
*
|
|
1940
|
+
* This pre-pass deliberately sits outside the looksActive-gated loop below:
|
|
1941
|
+
* the target rows are already at `processState: "exited"`.
|
|
1942
|
+
* @param {number} now
|
|
1943
|
+
*/
|
|
1944
|
+
async function noticeInterruptedBackgroundRuns(now) {
|
|
1945
|
+
for (const row of listRows(root)) {
|
|
1946
|
+
const host = row.host;
|
|
1947
|
+
if (!host || (host.state !== "exited" && host.state !== "failed") || row.hostAlive) continue;
|
|
1948
|
+
const viewId = row.meta.id;
|
|
1949
|
+
const key = `${viewId}:${host.endedAt ?? 0}`;
|
|
1950
|
+
if (noticeProbeMemo.has(key)) continue;
|
|
1951
|
+
const probe = backgroundRunsImpl.probeBackgroundRun({
|
|
1952
|
+
sessionFile: row.meta.sessionFile ?? null,
|
|
1953
|
+
hostStartedAt: host.startedAt ?? null,
|
|
1954
|
+
workflowHome: workflowHomeImpl,
|
|
1955
|
+
});
|
|
1956
|
+
if (!probe.ok) continue; // incomplete scan: retry next tick, memo not written
|
|
1957
|
+
const runningWithinGrace = probe.running != null && now - (host.endedAt ?? 0) < NOTICE_FLIP_GRACE_MS;
|
|
1958
|
+
if (!runningWithinGrace) {
|
|
1959
|
+
noticeProbeMemo.add(key);
|
|
1960
|
+
}
|
|
1961
|
+
if (!probe.paused) continue;
|
|
1962
|
+
const runId = probe.paused.runId;
|
|
1963
|
+
const summary = `Background workflow ${runId} was interrupted — resume it from this session`;
|
|
1964
|
+
const alreadyAnnounced = typeof row.state?.summary === "string" && row.state.summary.startsWith(`Background workflow ${runId} `);
|
|
1965
|
+
if (!alreadyAnnounced) {
|
|
1966
|
+
const result = await sendLifecycleCommand({
|
|
1967
|
+
type: "state_command",
|
|
1968
|
+
viewId,
|
|
1969
|
+
runId: null,
|
|
1970
|
+
source: "service",
|
|
1971
|
+
kind: "patch_fields",
|
|
1972
|
+
expectedRevision: null,
|
|
1973
|
+
payload: { state: { summary } },
|
|
1974
|
+
}, viewId);
|
|
1975
|
+
if (result.reason === "coordinator_disabled") {
|
|
1976
|
+
const fresh = readState(root, viewId) ?? blankState(viewId);
|
|
1977
|
+
fresh.summary = summary;
|
|
1978
|
+
fresh.updatedAt = now;
|
|
1979
|
+
writeState(root, fresh);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
appendDiagnosticOnce(root, viewId, {
|
|
1983
|
+
source: "service",
|
|
1984
|
+
level: "warn",
|
|
1985
|
+
code: "background_run_interrupted",
|
|
1986
|
+
message: `Background workflow ${runId} was interrupted when its host died`,
|
|
1987
|
+
details: { runId, hostState: host.state, exitCode: host.exitCode ?? null },
|
|
1988
|
+
}, { match: ["runId"] });
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
return {
|
|
1993
|
+
root,
|
|
1994
|
+
/**
|
|
1995
|
+
* Create a new background session and launch its first run.
|
|
1996
|
+
* Worktree mode is currently disabled, but the dashboard no longer blocks
|
|
1997
|
+
* concurrent same-repo sessions on its own.
|
|
1998
|
+
* @param {string} text
|
|
1999
|
+
* @param {{
|
|
2000
|
+
* cwd?: string,
|
|
2001
|
+
* worktree?: boolean,
|
|
2002
|
+
* writeCapable?: boolean,
|
|
2003
|
+
* model?: string|null,
|
|
2004
|
+
* thinkingLevel?: "off"|"minimal"|"low"|"medium"|"high"|"xhigh"|"max"|null,
|
|
2005
|
+
* }} [dispatchOpts]
|
|
2006
|
+
* @returns {Promise<{ ok: boolean, viewId?: string, error?: string, hostMode?: "pty"|"json-runner", fallbackReason?: string }>}
|
|
2007
|
+
*/
|
|
2008
|
+
async dispatch(text, dispatchOpts = {}) {
|
|
2009
|
+
const prompt = String(text || "").trim();
|
|
2010
|
+
if (!prompt) return { ok: false, error: "Empty task" };
|
|
2011
|
+
|
|
2012
|
+
const cwd = dispatchOpts.cwd ?? opts.defaultCwd;
|
|
2013
|
+
const writeCapable = dispatchOpts.writeCapable ?? true;
|
|
2014
|
+
const defaultModel = dispatchOpts.model ?? null;
|
|
2015
|
+
const defaultThinking = dispatchOpts.thinkingLevel ?? null;
|
|
2016
|
+
const repoRoot = gitRepoRoot(cwd);
|
|
2017
|
+
|
|
2018
|
+
if (dispatchOpts.worktree) {
|
|
2019
|
+
return { ok: false, error: "Worktree mode is currently disabled." };
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const id = newViewId();
|
|
2023
|
+
const meta = createView(root, {
|
|
2024
|
+
id,
|
|
2025
|
+
name: slugifyTask(prompt),
|
|
2026
|
+
cwd,
|
|
2027
|
+
repoCwd: cwd,
|
|
2028
|
+
repoRoot,
|
|
2029
|
+
worktreeMode: "off",
|
|
2030
|
+
worktreePath: null,
|
|
2031
|
+
defaultModel,
|
|
2032
|
+
defaultThinking,
|
|
2033
|
+
writeCapable,
|
|
2034
|
+
});
|
|
2035
|
+
const pty = ptySupport({ refresh: true });
|
|
2036
|
+
let hostMode = "json-runner";
|
|
2037
|
+
let fallbackReason;
|
|
2038
|
+
let queued;
|
|
2039
|
+
if (pty.ok) {
|
|
2040
|
+
const launched = await launchHost(meta, prompt);
|
|
2041
|
+
if (launched.ok) {
|
|
2042
|
+
hostMode = "pty";
|
|
2043
|
+
if (launched.status !== "started") {
|
|
2044
|
+
// An existing claim owns the prompt now; keep it durably queued
|
|
2045
|
+
// instead of dropping it (issue #70 prompt-not-lost invariant).
|
|
2046
|
+
const q = enqueueFollowUp(root, id, prompt, { kind: "reply", delivery: "auto", source: "user" });
|
|
2047
|
+
if (!q.ok) return q;
|
|
2048
|
+
queued = true;
|
|
2049
|
+
}
|
|
2050
|
+
} else {
|
|
2051
|
+
fallbackReason = launched.fallbackReason ?? launched.error;
|
|
2052
|
+
await launchForView(meta, prompt, "dispatch");
|
|
2053
|
+
}
|
|
2054
|
+
} else {
|
|
2055
|
+
fallbackReason = nodePtyFallbackMessage(pty);
|
|
2056
|
+
await launchForView(meta, prompt, "dispatch");
|
|
2057
|
+
}
|
|
2058
|
+
queueGeneratedTitle(meta, prompt);
|
|
2059
|
+
return {
|
|
2060
|
+
ok: true,
|
|
2061
|
+
viewId: id,
|
|
2062
|
+
hostMode,
|
|
2063
|
+
...(queued ? { queued: true } : {}),
|
|
2064
|
+
...(hostMode === "json-runner" ? { fallbackReason } : {}),
|
|
2065
|
+
};
|
|
2066
|
+
},
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* Append a reply to an existing session. Host-path delivery is
|
|
2070
|
+
* ack-gated: the prompt is first persisted as a follow-up item, then
|
|
2071
|
+
* attempted over the control socket; only input_ack completes the item,
|
|
2072
|
+
* anything else leaves it queued for the drain loop (issue #70 A13).
|
|
2073
|
+
* @param {string} viewId
|
|
2074
|
+
* @param {string} text
|
|
2075
|
+
* @returns {Promise<{ ok: boolean, error?: string, queued?: boolean, sent?: boolean, summary?: any, hostMode?: "pty"|"json-runner", fallbackReason?: string }>}
|
|
2076
|
+
*/
|
|
2077
|
+
async reply(viewId, text, replyOpts = {}) {
|
|
2078
|
+
const prompt = String(text || "").trim();
|
|
2079
|
+
if (!prompt) return { ok: false, error: "Empty reply" };
|
|
2080
|
+
const row = loadRow(root, viewId);
|
|
2081
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
2082
|
+
// Text and Enter sent to the terminal would go to the open dialog, and could approve what it
|
|
2083
|
+
// asks. So it waits its turn, and answering the dialog takes attaching.
|
|
2084
|
+
if (row.state?.pendingQuestions?.some((question) => question?.kind === DIALOG)) {
|
|
2085
|
+
const queued = enqueueFollowUp(root, viewId, prompt, { kind: replyOpts.kind ?? "reply", delivery: "queue", source: "user" });
|
|
2086
|
+
return queued.ok ? { ok: true, queued: true, summary: queued.summary } : queued;
|
|
2087
|
+
}
|
|
2088
|
+
if (hasPendingQuestions(row)) return { ok: false, error: "Attach to answer the pending question" };
|
|
2089
|
+
const delivery = replyOpts.delivery ?? "auto";
|
|
2090
|
+
const kind = replyOpts.kind ?? "reply";
|
|
2091
|
+
if (delivery === "queue" || (delivery === "auto" && isAgentBusy(row))) {
|
|
2092
|
+
const queued = enqueueFollowUp(root, viewId, prompt, { kind, delivery, source: "user" });
|
|
2093
|
+
if (queued.ok) appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_queued", message: "Follow-up queued", details: { kind, queuedCount: queued.summary?.queuedCount } });
|
|
2094
|
+
return queued.ok ? { ok: true, queued: true, summary: queued.summary } : queued;
|
|
2095
|
+
}
|
|
2096
|
+
if (row.hostActive) {
|
|
2097
|
+
// Durable queue-first delivery: the item id is the requestId, so a
|
|
2098
|
+
// lost ack after a child write de-dupes on retry instead of duplicating.
|
|
2099
|
+
const queued = enqueueFollowUp(root, viewId, prompt, { kind, delivery: "auto", source: "user" });
|
|
2100
|
+
if (!queued.ok) return queued;
|
|
2101
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_queued", message: "Follow-up queued", details: { kind, queuedCount: queued.summary?.queuedCount } });
|
|
2102
|
+
// Legacy host: no instanceId → no input_ack → fire-and-forget (final
|
|
2103
|
+
// review finding 1); connect success completes the item.
|
|
2104
|
+
if (row.host?.instanceId == null) {
|
|
2105
|
+
const sent = sendHostMessage(row, { type: "input", data: `${prompt}\r` });
|
|
2106
|
+
if (sent.ok) {
|
|
2107
|
+
completeFollowUp(root, viewId, queued.item.id);
|
|
2108
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind, legacy: true } });
|
|
2109
|
+
return { ok: true, sent: true, item: queued.item };
|
|
2110
|
+
}
|
|
2111
|
+
return { ok: true, queued: true, summary: queued.summary };
|
|
2112
|
+
}
|
|
2113
|
+
const socketPath = row.host?.socketPath;
|
|
2114
|
+
const sent = socketPath
|
|
2115
|
+
? await sendHostInput(socketPath, `${prompt}\r`, {
|
|
2116
|
+
requestId: queued.item.id,
|
|
2117
|
+
commandId: queued.item.id,
|
|
2118
|
+
viewId,
|
|
2119
|
+
instanceId: row.host.instanceId,
|
|
2120
|
+
})
|
|
2121
|
+
: { ok: false, error: "No host socket", retryable: true };
|
|
2122
|
+
if (sent.ok) {
|
|
2123
|
+
completeFollowUp(root, viewId, queued.item.id);
|
|
2124
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_sent", message: "Queued follow-up sent to live host", details: { kind } });
|
|
2125
|
+
return { ok: true, sent: true, item: queued.item };
|
|
2126
|
+
}
|
|
2127
|
+
if (sent.ambiguous) {
|
|
2128
|
+
// §10: see drainNextFollowUp — never re-queue, never complete.
|
|
2129
|
+
failFollowUp(root, viewId, queued.item.id, "accepted_unknown: accepted but never applied; outcome unknowable (§10); not re-sent");
|
|
2130
|
+
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 } });
|
|
2131
|
+
return { ok: true, failed: true, item: queued.item };
|
|
2132
|
+
}
|
|
2133
|
+
// Not an error: starting host, busy socket or lost ack — the drain
|
|
2134
|
+
// loop retries once the host acks (issue #70 prompt-not-lost).
|
|
2135
|
+
return { ok: true, queued: true, summary: queued.summary };
|
|
2136
|
+
}
|
|
2137
|
+
if (row.alive) return { ok: false, error: "A run is already active for this session" };
|
|
2138
|
+
const pty = ptySupport({ refresh: true });
|
|
2139
|
+
let hostMode = null;
|
|
2140
|
+
let fallbackReason;
|
|
2141
|
+
if (pty.ok) {
|
|
2142
|
+
const launched = await launchHost(row.meta, prompt);
|
|
2143
|
+
if (launched.ok) {
|
|
2144
|
+
if (launched.status !== "started") {
|
|
2145
|
+
// The existing claim will consume the prompt later — keep it queued
|
|
2146
|
+
// instead of dropping it (issue #70 prompt-not-lost invariant).
|
|
2147
|
+
const q = enqueueFollowUp(root, viewId, prompt, { kind, delivery: "auto", source: "user" });
|
|
2148
|
+
if (q.ok) {
|
|
2149
|
+
appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_queued", message: "Follow-up queued", details: { kind, queuedCount: q.summary?.queuedCount } });
|
|
2150
|
+
return { ok: true, queued: true, summary: q.summary };
|
|
2151
|
+
}
|
|
2152
|
+
return q;
|
|
2153
|
+
}
|
|
2154
|
+
hostMode = "pty";
|
|
2155
|
+
} else {
|
|
2156
|
+
fallbackReason = launched.fallbackReason ?? launched.error;
|
|
2157
|
+
}
|
|
2158
|
+
} else {
|
|
2159
|
+
fallbackReason = nodePtyFallbackMessage(pty);
|
|
2160
|
+
}
|
|
2161
|
+
if (!hostMode) {
|
|
2162
|
+
if (isExternalSession(row.meta)) return { ok: false, error: "PTY is required to continue an adopted foreground session safely" };
|
|
2163
|
+
await launchForView(row.meta, prompt, runKindForKind(kind));
|
|
2164
|
+
hostMode = "json-runner";
|
|
2165
|
+
}
|
|
2166
|
+
return { ok: true, hostMode, ...(hostMode === "json-runner" ? { fallbackReason } : {}) };
|
|
2167
|
+
},
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* Stop the active run for a view (SIGTERM the runner → it finalizes as `stopped`).
|
|
2171
|
+
* @param {string} viewId
|
|
2172
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
2173
|
+
*/
|
|
2174
|
+
stop(viewId) {
|
|
2175
|
+
const row = loadRow(root, viewId);
|
|
2176
|
+
if (row?.hostActive) {
|
|
2177
|
+
// Interrupt is turn-abort, not host-stop; an already-stopping host has nothing to abort.
|
|
2178
|
+
if (row.host?.state === "stopping") return { ok: true };
|
|
2179
|
+
return sendHostMessage(row, { type: "interrupt" });
|
|
2180
|
+
}
|
|
2181
|
+
const state = readState(root, viewId);
|
|
2182
|
+
if (!state?.currentRunId) return { ok: false, error: "No active run" };
|
|
2183
|
+
const pid = readPid(root, viewId, state.currentRunId);
|
|
2184
|
+
if (!pid) return { ok: false, error: "No runner pid" };
|
|
2185
|
+
killProcess(pid);
|
|
2186
|
+
return { ok: true };
|
|
2187
|
+
},
|
|
2188
|
+
|
|
2189
|
+
/** @param {string} viewId */
|
|
2190
|
+
terminateHost(viewId) {
|
|
2191
|
+
const row = loadRow(root, viewId);
|
|
2192
|
+
if (!row?.host) return { ok: false, error: "No live host" };
|
|
2193
|
+
if (row.host.instanceId) return requestHostStop(viewId, row.host.instanceId, "user");
|
|
2194
|
+
if (!row.hostAlive) return { ok: false, error: "No live host" };
|
|
2195
|
+
return sendHostMessage(row, { type: "terminate" });
|
|
2196
|
+
},
|
|
2197
|
+
|
|
2198
|
+
/**
|
|
2199
|
+
* File-based host revoke (issue #70). See the closure doc above; exposed for
|
|
2200
|
+
* the attach resolver and lifecycle callers.
|
|
2201
|
+
* @param {string} viewId @param {string|null} expectedInstanceId @param {string} reason
|
|
2202
|
+
*/
|
|
2203
|
+
requestHostStop,
|
|
2204
|
+
|
|
2205
|
+
/**
|
|
2206
|
+
* Revoke + wait + replace a broken host (issue #70). Only the attach
|
|
2207
|
+
* resolver / recovery-aware reconcile should call this.
|
|
2208
|
+
* @param {string} viewId @param {string} expectedInstanceId
|
|
2209
|
+
*/
|
|
2210
|
+
recoverHost,
|
|
2211
|
+
|
|
2212
|
+
/**
|
|
2213
|
+
* The single async attach authority (issue #70 Task 12): real-probes the
|
|
2214
|
+
* current claim, waits out cold starts, adopts abandoned claims, and runs one
|
|
2215
|
+
* bounded recovery per invocation. Concurrent calls for the same view share
|
|
2216
|
+
* one in-flight promise. `attachTarget()` remains a pure sync hint.
|
|
2217
|
+
* @param {string} viewId
|
|
2218
|
+
* @param {{ timeoutMs?: number }} [resolveOpts]
|
|
2219
|
+
* @returns {Promise<{kind:"pty",socketPath:string|null,sessionFile:string,instanceId:string|null}|{kind:"session",sessionFile:string}|{kind:"pending",sessionFile:string,reason:string}|{kind:"missing"}>}
|
|
2220
|
+
*/
|
|
2221
|
+
async resolveAttachTarget(viewId, resolveOpts = {}) {
|
|
2222
|
+
const existing = inflightAttachResolvers.get(viewId);
|
|
2223
|
+
if (existing) return existing;
|
|
2224
|
+
const run = resolveAttachTargetInner(viewId, resolveOpts.timeoutMs ?? resolveAttachResolveTimeoutMs())
|
|
2225
|
+
.finally(() => { inflightAttachResolvers.delete(viewId); });
|
|
2226
|
+
inflightAttachResolvers.set(viewId, run);
|
|
2227
|
+
return run;
|
|
2228
|
+
},
|
|
2229
|
+
|
|
2230
|
+
/**
|
|
2231
|
+
* Ensure there is an interactive PTY host for this session. Used for fast attach
|
|
2232
|
+
* and dashboard prewarm. Idempotent (issue #70): an existing active claim — even
|
|
2233
|
+
* a fresh `starting` one with no runner pid yet — is surfaced as `pending`, never
|
|
2234
|
+
* replaced or double-spawned; a provably abandoned claim is adopted and spawned
|
|
2235
|
+
* (Task 12) instead of pending forever. Real socket health is the attach
|
|
2236
|
+
* resolver's job; this path never probes, kills, or spawns past an active claim.
|
|
2237
|
+
* @param {string} viewId
|
|
2238
|
+
* @returns {{ ok: boolean, pending?: boolean, started?: boolean, socketPath?: string|null, instanceId?: string|null, error?: string, fallbackReason?: string }}
|
|
2239
|
+
*/
|
|
2240
|
+
ensureHost(viewId) {
|
|
2241
|
+
return ensureHostImpl(viewId);
|
|
2242
|
+
},
|
|
2243
|
+
|
|
2244
|
+
/** @param {string} viewId */
|
|
2245
|
+
prewarmHost(viewId) {
|
|
2246
|
+
const row = loadRow(root, viewId);
|
|
2247
|
+
if (!row || isAgentBusy(row)) return { ok: false, error: row ? "Session is busy" : "Unknown session" };
|
|
2248
|
+
return this.ensureHost(viewId);
|
|
2249
|
+
},
|
|
2250
|
+
|
|
2251
|
+
/** @param {string} viewId */
|
|
2252
|
+
attachTarget(viewId) {
|
|
2253
|
+
const row = loadRow(root, viewId);
|
|
2254
|
+
if (!row) return { kind: "missing" };
|
|
2255
|
+
if (row.hostAlive && row.host?.socketPath) {
|
|
2256
|
+
return { kind: "pty", socketPath: row.host.socketPath, sessionFile: row.meta.sessionFile };
|
|
2257
|
+
}
|
|
2258
|
+
return { kind: "session", sessionFile: row.meta.sessionFile };
|
|
2259
|
+
},
|
|
2260
|
+
|
|
2261
|
+
async adoptSession(adoptOpts = {}) {
|
|
2262
|
+
const sessionFile = String(adoptOpts.sessionFile || "").trim();
|
|
2263
|
+
if (!sessionFile) return { ok: false, error: "No session file to adopt" };
|
|
2264
|
+
const existing = rowForSession(sessionFile);
|
|
2265
|
+
if (existing) {
|
|
2266
|
+
existing.meta.archived = false;
|
|
2267
|
+
if (adoptOpts.name) existing.meta.name = String(adoptOpts.name).trim() || existing.meta.name;
|
|
2268
|
+
writeMeta(root, existing.meta);
|
|
2269
|
+
if (!isAgentBusy(existing)) {
|
|
2270
|
+
await adoptIdleState(existing.meta.id, readState(root, existing.meta.id) ?? existing.state ?? blankState(existing.meta.id));
|
|
2271
|
+
}
|
|
2272
|
+
appendDiagnostic(root, existing.meta.id, { source: "service", code: "session_adopted", message: "Existing session adopted into Agent Board", details: { reused: true } });
|
|
2273
|
+
return { ok: true, viewId: existing.meta.id, reused: true };
|
|
2274
|
+
}
|
|
2275
|
+
const cwd = adoptOpts.cwd ?? opts.defaultCwd;
|
|
2276
|
+
const id = newViewId();
|
|
2277
|
+
const repoRoot = gitRepoRoot(cwd);
|
|
2278
|
+
const meta = createView(root, {
|
|
2279
|
+
id,
|
|
2280
|
+
name: adoptOpts.name ?? "background-session",
|
|
2281
|
+
cwd,
|
|
2282
|
+
repoCwd: cwd,
|
|
2283
|
+
repoRoot,
|
|
2284
|
+
worktreeMode: "off",
|
|
2285
|
+
worktreePath: null,
|
|
2286
|
+
defaultModel: adoptOpts.model ?? null,
|
|
2287
|
+
defaultThinking: adoptOpts.thinkingLevel ?? null,
|
|
2288
|
+
writeCapable: true,
|
|
2289
|
+
sessionFile,
|
|
2290
|
+
});
|
|
2291
|
+
const state = readState(root, id) ?? blankState(id);
|
|
2292
|
+
await adoptIdleState(id, state);
|
|
2293
|
+
appendDiagnostic(root, id, { source: "service", code: "session_adopted", message: "Current session adopted into Agent Board", details: { reused: false } });
|
|
2294
|
+
return { ok: true, viewId: meta.id, reused: false };
|
|
2295
|
+
},
|
|
2296
|
+
|
|
2297
|
+
getRoot() {
|
|
2298
|
+
return root;
|
|
2299
|
+
},
|
|
2300
|
+
|
|
2301
|
+
getLaunchPrefs() {
|
|
2302
|
+
return readLaunchPrefs(root);
|
|
2303
|
+
},
|
|
2304
|
+
|
|
2305
|
+
saveLaunchPrefs(prefs) {
|
|
2306
|
+
writeLaunchPrefs(root, prefs ?? {});
|
|
2307
|
+
return { ok: true };
|
|
2308
|
+
},
|
|
2309
|
+
|
|
2310
|
+
/** @param {string} viewId @param {boolean} pinned */
|
|
2311
|
+
setPinned(viewId, pinned) {
|
|
2312
|
+
const meta = loadRow(root, viewId)?.meta;
|
|
2313
|
+
if (!meta) return { ok: false, error: "Unknown session" };
|
|
2314
|
+
meta.pinned = pinned;
|
|
2315
|
+
writeMeta(root, meta);
|
|
2316
|
+
return { ok: true };
|
|
2317
|
+
},
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* Gives these rows this order, numbered from the lowest they had: rows created in the same
|
|
2321
|
+
* millisecond have no order between them to swap.
|
|
2322
|
+
* @param {string[]} viewIds
|
|
2323
|
+
*/
|
|
2324
|
+
setRowOrder(viewIds) {
|
|
2325
|
+
const metas = viewIds.map((id) => loadRow(root, id)?.meta);
|
|
2326
|
+
if (metas.some((meta) => !meta)) return { ok: false, error: "Unknown session" };
|
|
2327
|
+
const base = Math.min(...metas.map((meta) => meta.order ?? meta.createdAt ?? 0));
|
|
2328
|
+
metas.forEach((meta, index) => {
|
|
2329
|
+
meta.order = base + index;
|
|
2330
|
+
writeMeta(root, meta);
|
|
2331
|
+
});
|
|
2332
|
+
return { ok: true };
|
|
2333
|
+
},
|
|
2334
|
+
|
|
2335
|
+
/** @param {string} viewId @param {string} name */
|
|
2336
|
+
rename(viewId, name) {
|
|
2337
|
+
const clean = String(name || "").trim();
|
|
2338
|
+
if (!clean) return { ok: false, error: "Empty name" };
|
|
2339
|
+
const meta = loadRow(root, viewId)?.meta;
|
|
2340
|
+
if (!meta) return { ok: false, error: "Unknown session" };
|
|
2341
|
+
meta.name = clean;
|
|
2342
|
+
writeMeta(root, meta);
|
|
2343
|
+
return { ok: true };
|
|
2344
|
+
},
|
|
2345
|
+
|
|
2346
|
+
/** @param {string} viewId @returns {Promise<{ ok: boolean, error?: string }>} */
|
|
2347
|
+
markVisited(viewId) {
|
|
2348
|
+
return markVisited(viewId);
|
|
2349
|
+
},
|
|
2350
|
+
|
|
2351
|
+
/**
|
|
2352
|
+
* Explicitly mark an inactive session as done. Successful runs settle as
|
|
2353
|
+
* `idle` until the user reviews and confirms this action from the dashboard.
|
|
2354
|
+
* @param {string} viewId
|
|
2355
|
+
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
2356
|
+
*/
|
|
2357
|
+
markCompleted(viewId) {
|
|
2358
|
+
return completeView(viewId);
|
|
2359
|
+
},
|
|
2360
|
+
|
|
2361
|
+
/** @param {string} viewId @returns {Promise<{ ok: boolean, error?: string }>} */
|
|
2362
|
+
holdView(viewId) {
|
|
2363
|
+
return holdView(viewId);
|
|
2364
|
+
},
|
|
2365
|
+
|
|
2366
|
+
/** @param {string} viewId @returns {Promise<{ ok: boolean, error?: string }>} */
|
|
2367
|
+
clearHoldView(viewId) {
|
|
2368
|
+
return clearHoldView(viewId);
|
|
2369
|
+
},
|
|
2370
|
+
|
|
2371
|
+
/**
|
|
2372
|
+
* Bulk mark sessions done, skipping live/already-done rows.
|
|
2373
|
+
* @param {string[]} viewIds
|
|
2374
|
+
* @returns {{ ok: boolean, completed: number, skipped: number, completedIds: string[] }}
|
|
2375
|
+
*/
|
|
2376
|
+
queueFollowUp(viewId, text, queueOpts = {}) {
|
|
2377
|
+
const res = enqueueFollowUp(root, viewId, text, queueOpts);
|
|
2378
|
+
if (res.ok) appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_queued", message: "Follow-up queued", details: { kind: queueOpts.kind ?? "reply" } });
|
|
2379
|
+
return res;
|
|
2380
|
+
},
|
|
2381
|
+
|
|
2382
|
+
clearFollowUps(viewId) {
|
|
2383
|
+
const res = clearQueuedFollowUps(root, viewId);
|
|
2384
|
+
if (res.ok) appendDiagnostic(root, viewId, { source: "queue", code: "follow_ups_cleared", message: "Queued follow-ups cleared", details: { cancelled: res.cancelled } });
|
|
2385
|
+
return res;
|
|
2386
|
+
},
|
|
2387
|
+
|
|
2388
|
+
removeLastFollowUp(viewId) {
|
|
2389
|
+
const res = removeLastFollowUp(root, viewId);
|
|
2390
|
+
if (res.ok) appendDiagnostic(root, viewId, { source: "queue", code: "follow_up_removed", message: "Last queued follow-up removed", details: { itemId: res.item?.id } });
|
|
2391
|
+
return res;
|
|
2392
|
+
},
|
|
2393
|
+
|
|
2394
|
+
followUps(viewId) {
|
|
2395
|
+
const queue = readFollowUpQueue(root, viewId);
|
|
2396
|
+
return { queue, summary: summarizeFollowUpQueue(queue) };
|
|
2397
|
+
},
|
|
2398
|
+
|
|
2399
|
+
/** Async (ack-gated) since issue #70 A13; see the internal implementation above. @param {string} viewId */
|
|
2400
|
+
drainNextFollowUp(viewId) {
|
|
2401
|
+
return drainNextFollowUp(viewId);
|
|
2402
|
+
},
|
|
2403
|
+
|
|
2404
|
+
async requestPlan(viewId, text = "") {
|
|
2405
|
+
const row = loadRow(root, viewId);
|
|
2406
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
2407
|
+
requestPlanState(root, viewId, { note: text || null });
|
|
2408
|
+
if (isAgentBusy(row)) return this.queueFollowUp(viewId, text, { kind: "plan_request", delivery: "queue", source: "steering" });
|
|
2409
|
+
return this.reply(viewId, buildPlanRequestPrompt(text), { delivery: "now", kind: "plan_request" });
|
|
2410
|
+
},
|
|
2411
|
+
|
|
2412
|
+
async approvePlan(viewId) {
|
|
2413
|
+
const row = loadRow(root, viewId);
|
|
2414
|
+
const state = readSteering(root, viewId);
|
|
2415
|
+
const approved = approvePlanState(root, viewId);
|
|
2416
|
+
if (!approved.ok) return approved;
|
|
2417
|
+
if (row && isAgentBusy(row)) return this.queueFollowUp(viewId, "approved", { kind: "plan_approval", delivery: "queue", source: "steering" });
|
|
2418
|
+
markExecutingApprovedPlan(root, viewId);
|
|
2419
|
+
return this.reply(viewId, buildApprovePlanPrompt(state.planText), { delivery: "now", kind: "plan_approval" });
|
|
2420
|
+
},
|
|
2421
|
+
|
|
2422
|
+
async requestPlanChanges(viewId, feedback) {
|
|
2423
|
+
const row = loadRow(root, viewId);
|
|
2424
|
+
const state = readSteering(root, viewId);
|
|
2425
|
+
const changed = requestPlanChangesState(root, viewId, feedback);
|
|
2426
|
+
if (!changed.ok) return changed;
|
|
2427
|
+
if (row && isAgentBusy(row)) return this.queueFollowUp(viewId, feedback, { kind: "plan_change", delivery: "queue", source: "steering" });
|
|
2428
|
+
return this.reply(viewId, buildPlanChangesPrompt(state.planText, feedback), { delivery: "now", kind: "plan_change" });
|
|
2429
|
+
},
|
|
2430
|
+
|
|
2431
|
+
steering(viewId) {
|
|
2432
|
+
const state = readSteering(root, viewId);
|
|
2433
|
+
return { state, summary: summarizeSteering(state) };
|
|
2434
|
+
},
|
|
2435
|
+
|
|
2436
|
+
/**
|
|
2437
|
+
* Bulk mark sessions done, skipping live/already-done rows.
|
|
2438
|
+
* @param {string[]} viewIds
|
|
2439
|
+
* @returns {Promise<{ ok: boolean, completed: number, skipped: number, completedIds: string[] }>}
|
|
2440
|
+
*/
|
|
2441
|
+
async markCompletedMany(viewIds) {
|
|
2442
|
+
const ids = [...new Set((viewIds ?? []).filter(Boolean))];
|
|
2443
|
+
let completed = 0;
|
|
2444
|
+
let skipped = 0;
|
|
2445
|
+
const completedIds = [];
|
|
2446
|
+
for (const viewId of ids) {
|
|
2447
|
+
const row = loadRow(root, viewId);
|
|
2448
|
+
if (!row || row.state?.semanticState === "completed") {
|
|
2449
|
+
skipped += 1;
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2452
|
+
const res = await completeView(viewId);
|
|
2453
|
+
if (res.ok) {
|
|
2454
|
+
completed += 1;
|
|
2455
|
+
completedIds.push(viewId);
|
|
2456
|
+
} else skipped += 1;
|
|
2457
|
+
}
|
|
2458
|
+
return { ok: true, completed, skipped, completedIds };
|
|
2459
|
+
},
|
|
2460
|
+
|
|
2461
|
+
/**
|
|
2462
|
+
* Soft-delete a row: archive it (removed from the dashboard) but preserve the session
|
|
2463
|
+
* file.
|
|
2464
|
+
* @param {string} viewId
|
|
2465
|
+
*/
|
|
2466
|
+
archive(viewId) {
|
|
2467
|
+
return archiveView(viewId);
|
|
2468
|
+
},
|
|
2469
|
+
|
|
2470
|
+
/**
|
|
2471
|
+
* Bulk archive explicit row ids, skipping live/missing rows.
|
|
2472
|
+
* @param {string[]} viewIds
|
|
2473
|
+
* @returns {Promise<{ ok: boolean, archived: number, skipped: number }>}
|
|
2474
|
+
*/
|
|
2475
|
+
async archiveMany(viewIds) {
|
|
2476
|
+
const ids = [...new Set((viewIds ?? []).filter(Boolean))];
|
|
2477
|
+
let archived = 0;
|
|
2478
|
+
let skipped = 0;
|
|
2479
|
+
for (const viewId of ids) {
|
|
2480
|
+
const row = loadRow(root, viewId);
|
|
2481
|
+
if (!row || isAgentBusy(row)) {
|
|
2482
|
+
skipped += 1;
|
|
2483
|
+
continue;
|
|
2484
|
+
}
|
|
2485
|
+
const res = await archiveView(viewId);
|
|
2486
|
+
if (res.ok) archived += 1;
|
|
2487
|
+
else skipped += 1;
|
|
2488
|
+
}
|
|
2489
|
+
return { ok: true, archived, skipped };
|
|
2490
|
+
},
|
|
2491
|
+
|
|
2492
|
+
/**
|
|
2493
|
+
* Archive every non-live visible row in a semantic state. Live rows are skipped
|
|
2494
|
+
* so bulk cleanup cannot accidentally kill work.
|
|
2495
|
+
* @param {import("../core/types.mjs").SemanticState} state
|
|
2496
|
+
* @returns {{ ok: boolean, archived: number, skipped: number, error?: string }}
|
|
2497
|
+
*/
|
|
2498
|
+
archiveByState(state) {
|
|
2499
|
+
let archived = 0;
|
|
2500
|
+
let skipped = 0;
|
|
2501
|
+
for (const row of listRows(root)) {
|
|
2502
|
+
if (row.state?.semanticState !== state) continue;
|
|
2503
|
+
if (isAgentBusy(row)) {
|
|
2504
|
+
skipped += 1;
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
if (row.host?.instanceId) stopHostRow(row, "archive");
|
|
2508
|
+
else if (row.hostAlive) sendHostMessage(row, { type: "terminate" });
|
|
2509
|
+
foregroundPreviewCache.forget(row.meta.id);
|
|
2510
|
+
foregroundProjectionCache.forget(row.meta.id);
|
|
2511
|
+
row.meta.archived = true;
|
|
2512
|
+
writeMeta(root, row.meta);
|
|
2513
|
+
archived += 1;
|
|
2514
|
+
}
|
|
2515
|
+
return { ok: true, archived, skipped };
|
|
2516
|
+
},
|
|
2517
|
+
|
|
2518
|
+
/**
|
|
2519
|
+
* Recovery: reconcile rows whose runner died without finalizing (e.g. machine crash
|
|
2520
|
+
* or the runner was killed). If a terminal status exists, project it; otherwise mark
|
|
2521
|
+
* the row failed/stale. Safe to call on every dashboard open and on session_start.
|
|
2522
|
+
* @returns {Promise<number>} number of rows reconciled.
|
|
2523
|
+
*/
|
|
2524
|
+
async reconcile() {
|
|
2525
|
+
const now = Date.now();
|
|
2526
|
+
await noticeInterruptedBackgroundRuns(now);
|
|
2527
|
+
let fixed = 0;
|
|
2528
|
+
for (const row of listRows(root)) {
|
|
2529
|
+
const s = row.state;
|
|
2530
|
+
const looksActive = s?.processState === "alive" || s?.semanticState === "queued" || s?.semanticState === "working";
|
|
2531
|
+
if (!s || !looksActive) continue;
|
|
2532
|
+
if (!s.currentRunId) {
|
|
2533
|
+
if (row.host && !row.hostAlive && (row.host.state === "starting" || row.host.state === "alive" || row.host.state === "exited" || row.host.state === "failed")) {
|
|
2534
|
+
// A starting claim inside the launch grace is a normal cold start (issue #70):
|
|
2535
|
+
// its runner pid may legitimately be absent — never finalize it to failed yet.
|
|
2536
|
+
if (row.host.state === "starting" && now - (row.host.claimAt ?? row.host.startedAt ?? 0) < resolveHostStartGraceMs()) continue;
|
|
2537
|
+
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);
|
|
2538
|
+
// F6: failed verdicts must carry a reason (legacy guaranteed an
|
|
2539
|
+
// error string). The row has no currentRunId here, so only the
|
|
2540
|
+
// state half participates (no status.json to sync).
|
|
2541
|
+
const result = await sendLifecycleCommand({
|
|
2542
|
+
type: "state_command",
|
|
2543
|
+
viewId: row.meta.id,
|
|
2544
|
+
runId: null,
|
|
2545
|
+
source: "service",
|
|
2546
|
+
kind: "reconcile_finalize",
|
|
2547
|
+
expectedRevision: null,
|
|
2548
|
+
payload: {
|
|
2549
|
+
semanticState: failed ? "failed" : "idle",
|
|
2550
|
+
summary: failed ? "Failed (PTY host exited)" : "Needs instructions",
|
|
2551
|
+
reason: failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null,
|
|
2552
|
+
},
|
|
2553
|
+
}, row.meta.id);
|
|
2554
|
+
if (result.status !== "applied") {
|
|
2555
|
+
if (result.reason === "coordinator_disabled") {
|
|
2556
|
+
s.semanticState = failed ? "failed" : "idle";
|
|
2557
|
+
s.processState = "exited";
|
|
2558
|
+
s.hasError = failed;
|
|
2559
|
+
s.needsInput = false;
|
|
2560
|
+
s.question = null;
|
|
2561
|
+
s.pendingQuestions = [];
|
|
2562
|
+
s.error = failed ? (s.error ?? row.host.error ?? "PTY host exited unexpectedly") : null;
|
|
2563
|
+
s.summary = failed ? "Failed (PTY host exited)" : "Needs instructions";
|
|
2564
|
+
s.updatedAt = now;
|
|
2565
|
+
writeState(root, s);
|
|
2566
|
+
} else continue; // rejected/ambiguous: nothing mutated, not fixed
|
|
2567
|
+
}
|
|
2568
|
+
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 } });
|
|
2569
|
+
fixed += 1;
|
|
2570
|
+
}
|
|
2571
|
+
continue;
|
|
2572
|
+
}
|
|
2573
|
+
if (row.alive) continue;
|
|
2574
|
+
let status = readStatus(root, row.meta.id, s.currentRunId);
|
|
2575
|
+
if (statusRevisionDesynced(s, status)) {
|
|
2576
|
+
// TOCTOU guard: `s` is a listRows snapshot, and earlier rows'
|
|
2577
|
+
// awaited commands may have let the on-disk pair advance past
|
|
2578
|
+
// it — re-read BOTH files fresh and act only on a pair that
|
|
2579
|
+
// still disagrees. (The residual window between the two fresh
|
|
2580
|
+
// reads is sub-ms under the coordinator's view-lock pairing
|
|
2581
|
+
// invariant.)
|
|
2582
|
+
const reread = rereadPair(root, row.meta.id, s.currentRunId);
|
|
2583
|
+
if (reread.desynced) {
|
|
2584
|
+
// Half-materialized pair (a coordinator crashed between its paired
|
|
2585
|
+
// writes): don't combine the mismatched halves into one decision —
|
|
2586
|
+
// record it and kick a coordinator so boot replay repairs the pair.
|
|
2587
|
+
// The row is skipped this pass (not fixed); the next pass sees the
|
|
2588
|
+
// repaired stamps and proceeds normally.
|
|
2589
|
+
// Episode throttle: a stuck pair logs once, not once per 700ms
|
|
2590
|
+
// reconcile pass — diagnostics must stay bounded for unrepairable
|
|
2591
|
+
// pairs (torn transient beat has no journal record; coordinator off).
|
|
2592
|
+
// Episode throttle gates the DIAGNOSTIC (a stuck pair logs once —
|
|
2593
|
+
// CR r1); the repair KICK is retried independently while it keeps
|
|
2594
|
+
// failing (CR r2 issue-4): a transient ensureCoordinator failure
|
|
2595
|
+
// (spawn window exhausted) must not consume the recovery path —
|
|
2596
|
+
// an idle dashboard would otherwise never run boot replay. A
|
|
2597
|
+
// successful kick stops the retries: the coordinator is up, boot
|
|
2598
|
+
// replay already repaired a repairable pair, and an
|
|
2599
|
+
// unrepairable-torn pair gains nothing from re-kicking.
|
|
2600
|
+
// (shouldLog records the episode — evaluate it exactly once.)
|
|
2601
|
+
const logEpisode = desyncThrottle.shouldLog(row.meta.id, reread.state.materializedRevision, reread.status.materializedRevision);
|
|
2602
|
+
if (logEpisode) {
|
|
2603
|
+
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 } });
|
|
2604
|
+
}
|
|
2605
|
+
if (logEpisode || desyncThrottle.shouldRetryKick(row.meta.id)) {
|
|
2606
|
+
void ensureCoordinatorImpl(root).then((res) => {
|
|
2607
|
+
if (res?.ok) desyncThrottle.clearKickFailed(row.meta.id);
|
|
2608
|
+
else desyncThrottle.markKickFailed(row.meta.id);
|
|
2609
|
+
}).catch(() => desyncThrottle.markKickFailed(row.meta.id));
|
|
2610
|
+
}
|
|
2611
|
+
continue;
|
|
2612
|
+
}
|
|
2613
|
+
// Snapshot was stale but the fresh pair agrees — project from it.
|
|
2614
|
+
status = reread.status;
|
|
2615
|
+
}
|
|
2616
|
+
if (status?.endedAt) {
|
|
2617
|
+
// The run's terminal status exists but the row was never materialized
|
|
2618
|
+
// from it (crash between the two writes, or a pre-coordinator row).
|
|
2619
|
+
// Project mode lets the status's own verdict govern (completed rows
|
|
2620
|
+
// must not be forced to failed/idle).
|
|
2621
|
+
const result = await sendLifecycleCommand({
|
|
2622
|
+
type: "state_command",
|
|
2623
|
+
viewId: row.meta.id,
|
|
2624
|
+
runId: s.currentRunId,
|
|
2625
|
+
source: "service",
|
|
2626
|
+
kind: "reconcile_finalize",
|
|
2627
|
+
expectedRevision: null,
|
|
2628
|
+
payload: { project: true },
|
|
2629
|
+
}, row.meta.id);
|
|
2630
|
+
if (result.status !== "applied" && result.reason !== "coordinator_disabled") {
|
|
2631
|
+
// Fix round 2 (F5 leftover): a rejected/ambiguous project-mode
|
|
2632
|
+
// row (manual fence, no_change, stale_run, timeout) mutated
|
|
2633
|
+
// nothing — it must not count toward the fixed tally.
|
|
2634
|
+
continue;
|
|
2635
|
+
}
|
|
2636
|
+
if (result.reason === "coordinator_disabled") writeState(root, projectViewState(status, now, readState(root, row.meta.id) ?? row.state ?? null));
|
|
2637
|
+
} else {
|
|
2638
|
+
const result = await sendLifecycleCommand({
|
|
2639
|
+
type: "state_command",
|
|
2640
|
+
viewId: row.meta.id,
|
|
2641
|
+
runId: s.currentRunId,
|
|
2642
|
+
source: "service",
|
|
2643
|
+
kind: "reconcile_finalize",
|
|
2644
|
+
expectedRevision: null,
|
|
2645
|
+
payload: {
|
|
2646
|
+
semanticState: "failed",
|
|
2647
|
+
summary: "Failed (runner exited)",
|
|
2648
|
+
reason: s.error ?? "Runner exited unexpectedly",
|
|
2649
|
+
},
|
|
2650
|
+
}, row.meta.id);
|
|
2651
|
+
if (result.status !== "applied") {
|
|
2652
|
+
if (result.reason === "coordinator_disabled") {
|
|
2653
|
+
s.semanticState = "failed";
|
|
2654
|
+
s.processState = "exited";
|
|
2655
|
+
s.hasError = true;
|
|
2656
|
+
s.needsInput = false;
|
|
2657
|
+
s.error = s.error ?? "Runner exited unexpectedly";
|
|
2658
|
+
s.summary = "Failed (runner exited)";
|
|
2659
|
+
s.updatedAt = now;
|
|
2660
|
+
writeState(root, s);
|
|
2661
|
+
} else continue;
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
fixed += 1;
|
|
2665
|
+
}
|
|
2666
|
+
for (const row of listRows(root)) {
|
|
2667
|
+
if ((row.state?.followUps?.queuedCount ?? 0) > 0 && canAutoDrain(row)) {
|
|
2668
|
+
// Delivery is ack-gated and async now (issue #70 A13); the queue
|
|
2669
|
+
// item state, not this counter, records the outcome.
|
|
2670
|
+
void drainNextFollowUp(row.meta.id);
|
|
2671
|
+
fixed += 1;
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
return fixed;
|
|
2675
|
+
},
|
|
2676
|
+
|
|
2677
|
+
/**
|
|
2678
|
+
* Mirror lifecycle/events from a managed session that is currently attached in
|
|
2679
|
+
* the foreground. Without this, a row that was completed/needs_input can keep
|
|
2680
|
+
* looking stale after the user types a follow-up in the real Pi session.
|
|
2681
|
+
* @param {string|undefined} sessionFile
|
|
2682
|
+
* @param {any} event
|
|
2683
|
+
* @returns {Promise<boolean>} whether a managed row was updated
|
|
2684
|
+
*/
|
|
2685
|
+
syncForegroundEvent(sessionFile, event) {
|
|
2686
|
+
if (!sessionFile || !event?.type) return Promise.resolve(false);
|
|
2687
|
+
const row = rowForSession(sessionFile);
|
|
2688
|
+
if (!row) return Promise.resolve(false);
|
|
2689
|
+
return syncRowEvent(row, event);
|
|
2690
|
+
},
|
|
2691
|
+
|
|
2692
|
+
/** @param {string|undefined} viewId @param {any} event */
|
|
2693
|
+
syncHostedEvent(viewId, event) {
|
|
2694
|
+
if (!viewId || !event?.type) return Promise.resolve(false);
|
|
2695
|
+
const row = loadRow(root, viewId);
|
|
2696
|
+
if (!row) return Promise.resolve(false);
|
|
2697
|
+
return syncRowEvent(row, event);
|
|
2698
|
+
},
|
|
2699
|
+
|
|
2700
|
+
/** @returns {import("../core/store.mjs").Row[]} all visible rows. */
|
|
2701
|
+
rows() {
|
|
2702
|
+
return listRows(root);
|
|
2703
|
+
},
|
|
2704
|
+
|
|
2705
|
+
/**
|
|
2706
|
+
* Live node-pty / PTY host health snapshot for dashboard chrome.
|
|
2707
|
+
* - `ok` reflects whether this process can currently launch PTY hosts.
|
|
2708
|
+
* - `staleHosts` counts rows whose last persisted host claimed `alive` but the
|
|
2709
|
+
* runner pid is gone, which often explains attach prompts / degraded UX.
|
|
2710
|
+
*/
|
|
2711
|
+
ptyHealth() {
|
|
2712
|
+
const support = ptySupport();
|
|
2713
|
+
const rows = listRows(root);
|
|
2714
|
+
const staleHosts = rows.filter((row) => row.host?.state === "alive" && !row.hostAlive).length;
|
|
2715
|
+
const liveHosts = rows.filter((row) => row.hostAlive).length;
|
|
2716
|
+
const stalled = rows.filter((row) => row.state?.diagnostics?.stalled).length;
|
|
2717
|
+
const diagnosticErrors = rows.reduce((sum, row) => sum + (row.state?.diagnostics?.errorCount ?? 0), 0);
|
|
2718
|
+
return {
|
|
2719
|
+
ok: Boolean(support.ok),
|
|
2720
|
+
reason: support.ok ? null : support.reason ?? "PTY unavailable",
|
|
2721
|
+
issue: support.ok ? null : (support.issue ?? diagnoseNodePtyFailure(support.reason ?? null)),
|
|
2722
|
+
staleHosts,
|
|
2723
|
+
liveHosts,
|
|
2724
|
+
stalled,
|
|
2725
|
+
diagnosticErrors,
|
|
2726
|
+
};
|
|
2727
|
+
},
|
|
2728
|
+
|
|
2729
|
+
evidence(viewId) {
|
|
2730
|
+
const row = loadRow(root, viewId);
|
|
2731
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
2732
|
+
return {
|
|
2733
|
+
ok: true,
|
|
2734
|
+
evidence: readEvidence(root, viewId),
|
|
2735
|
+
paths: {
|
|
2736
|
+
evidence: P.evidencePath(root, viewId),
|
|
2737
|
+
diagnostics: P.diagnosticsPath(root, viewId),
|
|
2738
|
+
session: row.meta.sessionFile,
|
|
2739
|
+
screenLog: P.screenLogPath(root, viewId),
|
|
2740
|
+
},
|
|
2741
|
+
};
|
|
2742
|
+
},
|
|
2743
|
+
|
|
2744
|
+
diagnostics(viewId, diagOpts = {}) {
|
|
2745
|
+
const row = loadRow(root, viewId);
|
|
2746
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
2747
|
+
return { ok: true, events: tailDiagnostics(root, viewId, { limit: diagOpts.limit ?? 50 }) };
|
|
2748
|
+
},
|
|
2749
|
+
|
|
2750
|
+
clearDiagnostics(viewId) {
|
|
2751
|
+
const row = loadRow(root, viewId);
|
|
2752
|
+
if (!row) return { ok: false, error: "Unknown session" };
|
|
2753
|
+
return clearDiagnostics(root, viewId);
|
|
2754
|
+
},
|
|
2755
|
+
|
|
2756
|
+
/** @param {string} viewId @returns {import("../core/store.mjs").Row|null} */
|
|
2757
|
+
row(viewId) {
|
|
2758
|
+
return loadRow(root, viewId);
|
|
2759
|
+
},
|
|
2760
|
+
|
|
2761
|
+
/**
|
|
2762
|
+
* Evict idle warm PTY hosts (TTL / warm-pool cap). Safe to call anytime:
|
|
2763
|
+
* busy and attached hosts are never touched.
|
|
2764
|
+
* @param {{ keepViewId?: string|null }} [pruneOpts]
|
|
2765
|
+
*/
|
|
2766
|
+
pruneWarmHosts(pruneOpts = {}) {
|
|
2767
|
+
// Delegates to the createService-closure pruneWarmHosts (same name; NOT recursive).
|
|
2768
|
+
pruneWarmHosts(pruneOpts);
|
|
2769
|
+
},
|
|
2770
|
+
};
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
/** @param {import("../core/types.mjs").ViewMeta} meta */
|
|
2774
|
+
function isExternalSession(meta) {
|
|
2775
|
+
const normalized = resolve(meta.sessionFile).replace(/\\/g, "/");
|
|
2776
|
+
return !normalized.endsWith(`/sessions/${meta.id}.jsonl`);
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
/** @param {import("../core/types.mjs").EvidenceSnapshot} evidence */
|
|
2780
|
+
function latestEvidenceText(evidence) {
|
|
2781
|
+
return evidence.assistantEvidence?.[evidence.assistantEvidence.length - 1]?.text ?? "";
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
/** @param {import("../core/types.mjs").ViewState} state */
|
|
2785
|
+
function completionSummary(state) {
|
|
2786
|
+
if (!isGenericStatusText(state.summary)) return compactSummary(state.summary);
|
|
2787
|
+
return "Done";
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
/** @param {string} text */
|
|
2791
|
+
function compactSummary(text) {
|
|
2792
|
+
const cleaned = String(text || "").replace(/\s+/g, " ").trim();
|
|
2793
|
+
if (!cleaned) return "Done";
|
|
2794
|
+
const first = firstSentence(cleaned);
|
|
2795
|
+
return truncate(first.length >= 12 ? first : cleaned, 80);
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
/** Launch-time identity this service process stamps on leases and claims. */
|
|
2799
|
+
function serviceIdentity() {
|
|
2800
|
+
return { pid: process.pid, startToken: captureStartToken(process.pid) };
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
/**
|
|
2804
|
+
* Finalize a provably-dead legacy host as `exited` so the resolver loop's next
|
|
2805
|
+
* iteration sees hostActive=false and claims a fresh new-protocol host
|
|
2806
|
+
* (issue #87). The gate is `canFinalizeLegacyHost` — anything unverifiable
|
|
2807
|
+
* stays pending (spec §10.1 conservatism). The write itself is serialized
|
|
2808
|
+
* under the host-start lease (same gate as recoverHost/claim) with a
|
|
2809
|
+
* live-record re-check: a concurrent resolver may have already finalized and
|
|
2810
|
+
* claimed a new-protocol host, and a stale `alive` snapshot must never clobber
|
|
2811
|
+
* that replacement's record (issue #87 CR r1).
|
|
2812
|
+
* @param {string} root
|
|
2813
|
+
* @param {string} viewId
|
|
2814
|
+
* @param {import("../core/types.mjs").HostStatus} host
|
|
2815
|
+
* @param {string} probeClassification
|
|
2816
|
+
* @param {number} now
|
|
2817
|
+
* @param {(r: string, v: string, name: string, o?: object) => ({ acquired: true, lease: { release: () => void } } | { acquired: false, reason: string })} acquireHostStartLock
|
|
2818
|
+
* @returns {boolean} true when finalized (caller should `continue` the loop).
|
|
2819
|
+
*/
|
|
2820
|
+
function finalizeDeadLegacyHost(root, viewId, host, probeClassification, now, acquireHostStartLock) {
|
|
2821
|
+
// Same pid fallback as loadRow: legacy records carry no runnerPid property —
|
|
2822
|
+
// the pid lives only in the host-pid.json mirror.
|
|
2823
|
+
const hostPid = Object.hasOwn(host, "runnerPid") ? host.runnerPid : readHostPid(root, viewId);
|
|
2824
|
+
if (!canFinalizeLegacyHost({ host, hostPid, hostPidAlive: isAlive(hostPid), probeClassification })) return false;
|
|
2825
|
+
// Serialize with claims: only write while the live record still matches our
|
|
2826
|
+
// (possibly stale) snapshot.
|
|
2827
|
+
const gate = acquireHostStartLock(root, viewId, "host-start", { identity: serviceIdentity() });
|
|
2828
|
+
if (!gate.acquired) return false; // busy — record left untouched; caller pends/waits this attempt, a later one finalizes
|
|
2829
|
+
try {
|
|
2830
|
+
const cur = readHost(root, viewId);
|
|
2831
|
+
if (!cur || cur.instanceId != null) return false; // already claimed by a new-protocol host
|
|
2832
|
+
if (cur.state !== "starting" && cur.state !== "alive") return false; // already finalized
|
|
2833
|
+
if (cur.socketPath !== host.socketPath || cur.state !== host.state) return false; // snapshot drifted
|
|
2834
|
+
const finalizedAt = now;
|
|
2835
|
+
writeHost(root, { ...cur, state: "exited", endedAt: finalizedAt, lastSeenAt: finalizedAt, error: "legacy host finalized: runner pid dead" });
|
|
2836
|
+
} finally {
|
|
2837
|
+
try { gate.lease.release(); } catch { /* best effort */ }
|
|
2838
|
+
}
|
|
2839
|
+
try {
|
|
2840
|
+
appendDiagnostic(root, viewId, {
|
|
2841
|
+
source: "service",
|
|
2842
|
+
level: "info",
|
|
2843
|
+
code: "legacy_host_finalized",
|
|
2844
|
+
message: `Finalized stale legacy host (pid ${hostPid} dead, probe ${probeClassification}) — next attach claims a fresh host`,
|
|
2845
|
+
details: { hostPid, probeClassification, previousState: host.state },
|
|
2846
|
+
});
|
|
2847
|
+
} catch { /* best effort */ }
|
|
2848
|
+
return true;
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
/** Conservative pre-identity observation (issue #70 Task 10): a recorded pid that
|
|
2852
|
+
* is still alive is always `unknown` — never safe to release; not recorded or
|
|
2853
|
+
* provably gone is `dead`. Task 11 replaces this with identity-aware observation. */
|
|
2854
|
+
function conservativeObservation(pid) {
|
|
2855
|
+
if (pid == null) return "dead";
|
|
2856
|
+
return isAlive(pid) ? "unknown" : "dead";
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
/** Signal an identity previously observed as "owned". ESRCH is tolerated (the
|
|
2860
|
+
* process may exit between observation and signal); anything else rethrows. */
|
|
2861
|
+
function defaultSignalOwnedProcess(identity, signal) {
|
|
2862
|
+
try {
|
|
2863
|
+
process.kill(identity.pid, signal);
|
|
2864
|
+
} catch (err) {
|
|
2865
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ESRCH") throw err;
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
/** @param {import("../core/types.mjs").HostStatus|null} host */
|
|
2870
|
+
function observeHostForReplace(host) {
|
|
2871
|
+
return {
|
|
2872
|
+
host,
|
|
2873
|
+
runnerObservation: conservativeObservation(host?.runnerPid ?? null),
|
|
2874
|
+
childObservation: conservativeObservation(host?.childPid ?? null),
|
|
2875
|
+
launchLeaseActive: false,
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
/**
|
|
2880
|
+
* Send a one-shot JSONL command to a live host socket.
|
|
2881
|
+
* @param {import("../core/store.mjs").Row} row
|
|
2882
|
+
* @param {Record<string, unknown>} message
|
|
2883
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
2884
|
+
*/
|
|
2885
|
+
function sendHostMessage(row, message) {
|
|
2886
|
+
const socketPath = row.host?.socketPath;
|
|
2887
|
+
if (!socketPath) return { ok: false, error: "No host socket" };
|
|
2888
|
+
// On Windows the control socket is a named pipe, which never exists as a
|
|
2889
|
+
// filesystem entry, so existsSync can't be used as a readiness probe there.
|
|
2890
|
+
// A missing pipe surfaces as a connection error on the socket below.
|
|
2891
|
+
if (process.platform !== "win32" && !existsSync(socketPath)) return { ok: false, error: "Host socket is not ready" };
|
|
2892
|
+
try {
|
|
2893
|
+
const socket = createConnection(socketPath);
|
|
2894
|
+
socket.on("connect", () => {
|
|
2895
|
+
socket.write(JSON.stringify(message) + "\n");
|
|
2896
|
+
socket.end();
|
|
2897
|
+
});
|
|
2898
|
+
socket.on("error", () => {});
|
|
2899
|
+
return { ok: true };
|
|
2900
|
+
} catch (err) {
|
|
2901
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
/**
|
|
2906
|
+
* Default window service waits for the runner's input_ack (or, on the staged
|
|
2907
|
+
* path, `cmd_ack applied`) before treating a host input as undelivered
|
|
2908
|
+
* (issue #70 A13; phase 5 D4).
|
|
2909
|
+
*/
|
|
2910
|
+
const HOST_INPUT_ACK_TIMEOUT_MS = 2_000;
|
|
2911
|
+
|
|
2912
|
+
/**
|
|
2913
|
+
* Reconcile-query one commandId against a host control socket (§10 discipline:
|
|
2914
|
+
* a timeout must be resolved by query, never by a blind retry).
|
|
2915
|
+
* @returns {Promise<"accepted_unknown"|"reconciled_clean"|"unavailable">}
|
|
2916
|
+
*/
|
|
2917
|
+
function reconcileQueryCommand(socketPath, commandId, opts = {}) {
|
|
2918
|
+
const { viewId, instanceId, clientId = "service", connect = createConnection, timeoutMs = HOST_INPUT_ACK_TIMEOUT_MS } = opts;
|
|
2919
|
+
return new Promise((resolve) => {
|
|
2920
|
+
let settled = false;
|
|
2921
|
+
let buffer = "";
|
|
2922
|
+
/** @type {import("node:net").Socket|null} */
|
|
2923
|
+
let socket = null;
|
|
2924
|
+
const finish = (verdict) => {
|
|
2925
|
+
if (settled) return;
|
|
2926
|
+
settled = true;
|
|
2927
|
+
clearTimeout(timer);
|
|
2928
|
+
try { socket?.destroy(); } catch { /* best effort */ }
|
|
2929
|
+
resolve(verdict);
|
|
2930
|
+
};
|
|
2931
|
+
const timer = setTimeout(() => finish("unavailable"), timeoutMs);
|
|
2932
|
+
timer.unref?.();
|
|
2933
|
+
try {
|
|
2934
|
+
socket = connect(socketPath);
|
|
2935
|
+
} catch {
|
|
2936
|
+
finish("unavailable");
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
socket.on("connect", () => {
|
|
2940
|
+
try {
|
|
2941
|
+
socket.write(JSON.stringify({ type: "reconcile", commandId: `reconcile-${commandId}`, clientId, seq: 1, viewId, instanceId }) + "\n");
|
|
2942
|
+
} catch {
|
|
2943
|
+
finish("unavailable");
|
|
2944
|
+
}
|
|
2945
|
+
});
|
|
2946
|
+
socket.on("data", (chunk) => {
|
|
2947
|
+
buffer += chunk.toString("utf8");
|
|
2948
|
+
const lines = buffer.split("\n");
|
|
2949
|
+
buffer = lines.pop() ?? "";
|
|
2950
|
+
for (const line of lines) {
|
|
2951
|
+
if (!line.trim()) continue;
|
|
2952
|
+
let msg;
|
|
2953
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
2954
|
+
if (msg?.type !== "reconcile_result") continue;
|
|
2955
|
+
const unresolved = Array.isArray(msg.unresolved) ? msg.unresolved : [];
|
|
2956
|
+
finish(unresolved.some((u) => u?.commandId === commandId) ? "accepted_unknown" : "reconciled_clean");
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
});
|
|
2960
|
+
socket.on("error", () => finish("unavailable"));
|
|
2961
|
+
socket.on("close", () => finish("unavailable"));
|
|
2962
|
+
});
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
/**
|
|
2966
|
+
* Staged durable-input delivery (phase 5 D4): enveloped `{durable:true}` input
|
|
2967
|
+
* expecting `cmd_ack accepted → applied` against a generation-capable runner,
|
|
2968
|
+
* with the §10 reconcile-query on timeout. Runners whose hello lacks a
|
|
2969
|
+
* `generation` token predate the control lifecycle: the SAME connection falls
|
|
2970
|
+
* back to the legacy requestId/input_ack contract (byte-identical bytes), so
|
|
2971
|
+
* phase-4 owned hosts (instanceId known, envelope-ignorant) keep their dedup
|
|
2972
|
+
* instead of double-writing.
|
|
2973
|
+
*
|
|
2974
|
+
* Result shapes (never rejects):
|
|
2975
|
+
* - `{ok:true, applied:true}` — the runner wrote the child.
|
|
2976
|
+
* - `{ok:false, ambiguous:true, error:"accepted_unknown", retryable:false}` —
|
|
2977
|
+
* accepted but never applied (restart/write-failure window); the caller must
|
|
2978
|
+
* NOT re-send (§10) and must NOT treat the prompt as delivered.
|
|
2979
|
+
* - `{ok:false, error, retryable}` — everything else (starting window, dead
|
|
2980
|
+
* socket, legacy timeout); re-send is safe (journal dedup or requestId dedup).
|
|
2981
|
+
* @param {string} socketPath
|
|
2982
|
+
* @param {string} text
|
|
2983
|
+
* @param {{ requestId?: string|null, commandId?: string|null, viewId?: string, instanceId?: string|null, clientId?: string, timeoutMs?: number, connect?: (path: string) => import("node:net").Socket }} [opts]
|
|
2984
|
+
*/
|
|
2985
|
+
function sendHostInputStaged(socketPath, text, opts = {}) {
|
|
2986
|
+
const commandId = opts.commandId ?? opts.requestId;
|
|
2987
|
+
const clientId = opts.clientId ?? "service";
|
|
2988
|
+
const timeoutMs = opts.timeoutMs ?? HOST_INPUT_ACK_TIMEOUT_MS;
|
|
2989
|
+
const connect = opts.connect ?? createConnection;
|
|
2990
|
+
const { viewId, instanceId } = opts;
|
|
2991
|
+
return new Promise((resolve) => {
|
|
2992
|
+
let settled = false;
|
|
2993
|
+
let buffer = "";
|
|
2994
|
+
let helloSeen = false;
|
|
2995
|
+
let sawAccepted = false;
|
|
2996
|
+
/** @type {import("node:net").Socket|null} */
|
|
2997
|
+
let socket = null;
|
|
2998
|
+
const finish = (result) => {
|
|
2999
|
+
if (settled) return;
|
|
3000
|
+
settled = true;
|
|
3001
|
+
clearTimeout(timer);
|
|
3002
|
+
try { socket?.destroy(); } catch { /* best effort */ }
|
|
3003
|
+
resolve(result);
|
|
3004
|
+
};
|
|
3005
|
+
/** §10: resolve the deadline by query, never by a blind retry. */
|
|
3006
|
+
const resolveByReconcile = (fallbackError) => {
|
|
3007
|
+
if (settled) return;
|
|
3008
|
+
reconcileQueryCommand(socketPath, commandId, { viewId, instanceId, clientId, connect, timeoutMs }).then((verdict) => {
|
|
3009
|
+
if (verdict === "accepted_unknown") finish({ ok: false, ambiguous: true, error: "accepted_unknown", retryable: false });
|
|
3010
|
+
else if (verdict === "reconciled_clean") finish({ ok: false, error: fallbackError, retryable: true });
|
|
3011
|
+
else finish({ ok: false, error: fallbackError, retryable: true });
|
|
3012
|
+
});
|
|
3013
|
+
};
|
|
3014
|
+
const timer = setTimeout(() => resolveByReconcile("timeout"), timeoutMs);
|
|
3015
|
+
timer.unref?.();
|
|
3016
|
+
try {
|
|
3017
|
+
socket = connect(socketPath);
|
|
3018
|
+
} catch (err) {
|
|
3019
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
socket.on("data", (chunk) => {
|
|
3023
|
+
buffer += chunk.toString("utf8");
|
|
3024
|
+
const lines = buffer.split("\n");
|
|
3025
|
+
buffer = lines.pop() ?? "";
|
|
3026
|
+
for (const line of lines) {
|
|
3027
|
+
if (!line.trim()) continue;
|
|
3028
|
+
let msg;
|
|
3029
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
3030
|
+
if (!helloSeen) {
|
|
3031
|
+
if (msg?.type !== "hello") continue; // wait for the capability answer
|
|
3032
|
+
helloSeen = true;
|
|
3033
|
+
try {
|
|
3034
|
+
if (typeof msg.generation === "string" && msg.generation) {
|
|
3035
|
+
socket.write(JSON.stringify({ type: "input", durable: true, commandId, clientId, seq: 1, viewId, instanceId, data: text }) + "\n");
|
|
3036
|
+
} else {
|
|
3037
|
+
// Pre-lifecycle runner: legacy requestId contract, same bytes.
|
|
3038
|
+
socket.write(JSON.stringify({ type: "input", requestId: commandId, data: text }) + "\n");
|
|
3039
|
+
}
|
|
3040
|
+
} catch (err) {
|
|
3041
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
|
|
3042
|
+
}
|
|
3043
|
+
continue;
|
|
3044
|
+
}
|
|
3045
|
+
if (msg?.type === "cmd_ack" && msg.commandId === commandId) {
|
|
3046
|
+
if (msg.stage === "applied") finish({ ok: true, applied: true });
|
|
3047
|
+
if (msg.stage === "accepted") {
|
|
3048
|
+
sawAccepted = true;
|
|
3049
|
+
// accepted → applied is synchronous runner-side (Task 2 ruling 4);
|
|
3050
|
+
// a dropped socket between them is resolved by the deadline's query.
|
|
3051
|
+
}
|
|
3052
|
+
continue;
|
|
3053
|
+
}
|
|
3054
|
+
if (msg?.type === "input_ack" && msg.requestId === commandId) {
|
|
3055
|
+
// Legacy fallback answer on this same connection.
|
|
3056
|
+
finish({ ok: true });
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
if (msg?.type === "error") {
|
|
3060
|
+
const code = msg.code ?? msg.message ?? "host_error";
|
|
3061
|
+
if (code === "command_failed") {
|
|
3062
|
+
// Taxonomy: reconcile by commandId before any retry decision.
|
|
3063
|
+
resolveByReconcile(code);
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
finish({ ok: false, error: code, retryable: code !== "envelope_invalid" });
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
// hello/status/output lines are ignored — keep waiting.
|
|
3070
|
+
}
|
|
3071
|
+
});
|
|
3072
|
+
socket.on("error", (err) => {
|
|
3073
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
3074
|
+
finish({ ok: false, error: code ?? (err instanceof Error ? err.message : String(err)), retryable: true });
|
|
3075
|
+
});
|
|
3076
|
+
socket.on("close", () => {
|
|
3077
|
+
// A close after an accepted ack is the §10 window — resolve by query.
|
|
3078
|
+
if (sawAccepted) resolveByReconcile("socket_closed");
|
|
3079
|
+
else finish({ ok: false, error: "socket_closed", retryable: true });
|
|
3080
|
+
});
|
|
3081
|
+
});
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
/**
|
|
3085
|
+
* Send one input line to a host control socket and wait for the runner's
|
|
3086
|
+
* input_ack (issue #70 A13). Fire-and-forget "connected" no longer counts as
|
|
3087
|
+
* delivered: only `{type:"input_ack", requestId}` does.
|
|
3088
|
+
*
|
|
3089
|
+
* Delivery contract is durable at-least-once, coordinated through the queue
|
|
3090
|
+
* item id used as requestId:
|
|
3091
|
+
* - If an ack is lost AFTER the runner wrote the child, a re-send with the
|
|
3092
|
+
* same requestId hits the runner's dedup table and re-acks without a second
|
|
3093
|
+
* child write.
|
|
3094
|
+
* - If the host instance restarted, the new instance never saw the requestId
|
|
3095
|
+
* and writes the prompt once.
|
|
3096
|
+
* Legacy (pre-instanceId) runners ignore requestId and never ack: this helper
|
|
3097
|
+
* then times out and reports a retryable failure, leaving the prompt queued —
|
|
3098
|
+
* legacy hosts are attach-only by spec, so queued prompts surface on attach.
|
|
3099
|
+
* Never rejects: every failure resolves `{ok:false, error, retryable:true}`.
|
|
3100
|
+
* @param {string} socketPath
|
|
3101
|
+
* @param {string} text
|
|
3102
|
+
* @param {{ requestId?: string|null, timeoutMs?: number, connect?: (path: string) => import("node:net").Socket }} [opts]
|
|
3103
|
+
* @returns {Promise<{ ok: boolean, error?: string, retryable?: boolean }>}
|
|
3104
|
+
*/
|
|
3105
|
+
export function sendHostInput(socketPath, text, opts = {}) {
|
|
3106
|
+
// Staged durable path (phase 5 D4): the caller knows the host fence
|
|
3107
|
+
// (viewId + instanceId) and a stable commandId — envelope it and expect
|
|
3108
|
+
// accepted → applied with the §10 reconcile-query on timeout. Runners whose
|
|
3109
|
+
// hello lacks a generation fall back to the legacy requestId contract on
|
|
3110
|
+
// the same connection, so the legacy branch below stays for callers that
|
|
3111
|
+
// have no fence (pre-instanceId hosts).
|
|
3112
|
+
if (opts.viewId && opts.instanceId != null && (opts.commandId || opts.requestId)) {
|
|
3113
|
+
return sendHostInputStaged(socketPath, text, opts);
|
|
3114
|
+
}
|
|
3115
|
+
const requestId = opts.requestId ?? null;
|
|
3116
|
+
const timeoutMs = opts.timeoutMs ?? HOST_INPUT_ACK_TIMEOUT_MS;
|
|
3117
|
+
const connect = opts.connect ?? createConnection;
|
|
3118
|
+
return new Promise((resolve) => {
|
|
3119
|
+
let settled = false;
|
|
3120
|
+
let buffer = "";
|
|
3121
|
+
/** @type {import("node:net").Socket|null} */
|
|
3122
|
+
let socket = null;
|
|
3123
|
+
const finish = (result) => {
|
|
3124
|
+
if (settled) return;
|
|
3125
|
+
settled = true;
|
|
3126
|
+
clearTimeout(timer);
|
|
3127
|
+
try { socket?.destroy(); } catch { /* best effort */ }
|
|
3128
|
+
resolve(result);
|
|
3129
|
+
};
|
|
3130
|
+
const timer = setTimeout(() => finish({ ok: false, error: "timeout", retryable: true }), timeoutMs);
|
|
3131
|
+
timer.unref?.();
|
|
3132
|
+
try {
|
|
3133
|
+
socket = connect(socketPath);
|
|
3134
|
+
} catch (err) {
|
|
3135
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
socket.on("connect", () => {
|
|
3139
|
+
try {
|
|
3140
|
+
socket.write(JSON.stringify({ type: "input", requestId, data: text }) + "\n");
|
|
3141
|
+
} catch (err) {
|
|
3142
|
+
finish({ ok: false, error: err instanceof Error ? err.message : String(err), retryable: true });
|
|
3143
|
+
}
|
|
3144
|
+
});
|
|
3145
|
+
socket.on("data", (chunk) => {
|
|
3146
|
+
buffer += chunk.toString("utf8");
|
|
3147
|
+
const lines = buffer.split("\n");
|
|
3148
|
+
buffer = lines.pop() ?? "";
|
|
3149
|
+
for (const line of lines) {
|
|
3150
|
+
if (!line.trim()) continue;
|
|
3151
|
+
let msg;
|
|
3152
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
3153
|
+
if (msg?.type === "input_ack" && (requestId == null || msg.requestId === requestId)) {
|
|
3154
|
+
finish({ ok: true });
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
if (msg?.type === "error") {
|
|
3158
|
+
// e.g. {type:"error", code:"host_starting", requestId} while the child boots.
|
|
3159
|
+
finish({ ok: false, error: msg.code ?? msg.message ?? "host_error", retryable: true });
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
// hello/status/output lines are ignored — keep waiting for the ack.
|
|
3163
|
+
}
|
|
3164
|
+
});
|
|
3165
|
+
socket.on("error", (err) => {
|
|
3166
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
3167
|
+
finish({ ok: false, error: code ?? (err instanceof Error ? err.message : String(err)), retryable: true });
|
|
3168
|
+
});
|
|
3169
|
+
socket.on("close", () => finish({ ok: false, error: "socket_closed", retryable: true }));
|
|
3170
|
+
});
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
let cachedPtySupport;
|
|
3174
|
+
const requireForPty = createRequire(import.meta.url);
|
|
3175
|
+
const PTY_SUPPORT_ERROR_TTL_MS = 2_000;
|
|
3176
|
+
|
|
3177
|
+
function ptyHostAvailability(opts = {}) {
|
|
3178
|
+
if (process.env.AGENT_BOARD_DISABLE_PTY === "1" || process.env.AGENT_VIEW_DISABLE_PTY === "1") {
|
|
3179
|
+
return { ok: false, reason: "AGENT_BOARD_DISABLE_PTY=1", issue: diagnoseNodePtyFailure("AGENT_BOARD_DISABLE_PTY=1") };
|
|
3180
|
+
}
|
|
3181
|
+
if (process.env.AGENT_BOARD_FORCE_PTY === "1" || process.env.AGENT_VIEW_FORCE_PTY === "1") return { ok: true };
|
|
3182
|
+
return ptySpawnSupported(opts);
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
export function envInt(name, fallback, min, max, legacyName) {
|
|
3186
|
+
const raw = process.env[name] ?? (legacyName ? process.env[legacyName] : undefined);
|
|
3187
|
+
if (raw === undefined || raw === "") return fallback;
|
|
3188
|
+
const n = Number(raw);
|
|
3189
|
+
if (!Number.isFinite(n)) return fallback;
|
|
3190
|
+
return Math.max(min, Math.min(max, Math.floor(n)));
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
export function shouldProbePtySupport(cached, opts = {}, now = Date.now()) {
|
|
3194
|
+
if (!cached) return true;
|
|
3195
|
+
if (cached.ok) return false;
|
|
3196
|
+
if (opts.refresh) return true;
|
|
3197
|
+
const ttlMs = opts.maxAgeMs ?? PTY_SUPPORT_ERROR_TTL_MS;
|
|
3198
|
+
return now - (cached.checkedAt ?? 0) >= ttlMs;
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
function ptySpawnSupported(opts = {}) {
|
|
3202
|
+
const now = Date.now();
|
|
3203
|
+
if (!shouldProbePtySupport(cachedPtySupport, opts, now)) return cachedPtySupport;
|
|
3204
|
+
try {
|
|
3205
|
+
ensureNodePtySpawnHelperExecutable(requireForPty);
|
|
3206
|
+
const pty = requireForPty("node-pty");
|
|
3207
|
+
const proc = pty.spawn(process.execPath, ["-e", "process.exit(0)"], {
|
|
3208
|
+
name: "xterm-256color",
|
|
3209
|
+
cols: 20,
|
|
3210
|
+
rows: 5,
|
|
3211
|
+
cwd: process.cwd(),
|
|
3212
|
+
env: process.env,
|
|
3213
|
+
});
|
|
3214
|
+
proc.kill?.();
|
|
3215
|
+
cachedPtySupport = { ok: true, checkedAt: now };
|
|
3216
|
+
} catch (err) {
|
|
3217
|
+
cachedPtySupport = { ok: false, reason: err instanceof Error ? err.message : String(err), checkedAt: now };
|
|
3218
|
+
cachedPtySupport.issue = diagnoseNodePtyFailure(cachedPtySupport.reason, { probe: probeNodePtyEnvironment(requireForPty) });
|
|
3219
|
+
}
|
|
3220
|
+
return cachedPtySupport;
|
|
3221
|
+
}
|
|
3222
|
+
|