@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,673 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Detached View State Coordinator (issue #91, spec D3) — the single logical
|
|
4
|
+
* writer of state.json/status.json for one board root.
|
|
5
|
+
*
|
|
6
|
+
* Usage: node state-coordinator.mjs <root>
|
|
7
|
+
*
|
|
8
|
+
* Design contract:
|
|
9
|
+
* - Exactly one instance per root: a token-fenced lease (acquireOwnedViewLock on
|
|
10
|
+
* the pseudo-view "_coordinator") is grabbed before anything else; a second
|
|
11
|
+
* instance exits 0 immediately ("busy" is an expected, idempotent outcome).
|
|
12
|
+
* - Every semantic mutation arrives as a `state_command` over the JSONL control
|
|
13
|
+
* socket and is decided by the pure layer (state-commands.mjs). This process
|
|
14
|
+
* owns ALL side effects: durable journal append (fsync BEFORE materialize, so
|
|
15
|
+
* boot replay can repair the crash window), materialization under a per-view
|
|
16
|
+
* file lock, checkpoint + journal GC, and socket lifecycle.
|
|
17
|
+
* - Transient kinds (TRANSIENT_KINDS, run_progress) bypass the durable
|
|
18
|
+
* machinery: validate → decide → materialize → reply, with no journal
|
|
19
|
+
* append, no dedupe, and no checkpoint trigger — a periodic snapshot whose
|
|
20
|
+
* next beat supersedes it. They still bump materializedRevision.
|
|
21
|
+
* - Idempotency: a processed commandId returns its original result forever —
|
|
22
|
+
* from the in-memory ring first (bounded, covers the post-GC window), then
|
|
23
|
+
* from the journal (findProcessedCommand). Duplicates never re-append.
|
|
24
|
+
* - Revisions: one global monotonic materializedRevision counter, initialized at
|
|
25
|
+
* boot to max(checkpoint, max journal revision, max revision across views'
|
|
26
|
+
* state.json), incremented once per applied command, and stamped identically
|
|
27
|
+
* on state.json and the command's run status.json (spec 根治条件 5 scope).
|
|
28
|
+
* - Boot replay re-materializes every applied journal record from its stored
|
|
29
|
+
* patches; per-file guards stamp only files strictly behind the recorded
|
|
30
|
+
* revision, so a half-materialized pair (state.json written, status.json not)
|
|
31
|
+
* repairs just the lagging half without moving the other one backwards. The
|
|
32
|
+
* decision-time status binding (statusRunId) is recorded in the journal, so
|
|
33
|
+
* replay is deterministic — no re-decision, no re-binding.
|
|
34
|
+
* - Legacy adoption: a view without materializedRevision is stamped as part of
|
|
35
|
+
* its first applied command's single materialization write (no extra write).
|
|
36
|
+
* - Root guard (issue #241): a board root that disappears — or is REPLACED by a
|
|
37
|
+
* write that recreates it in the delete window — makes the supervised
|
|
38
|
+
* heartbeat step down with exit 0 and one stderr line. Neither path writes a
|
|
39
|
+
* diagnostic or state: every append helper ensureDirs its parent and would
|
|
40
|
+
* recreate the deleted root skeleton.
|
|
41
|
+
* - AGENT_BOARD_COORDINATOR=off makes the process exit 0 immediately (tests and
|
|
42
|
+
* the client's degradation path).
|
|
43
|
+
*/
|
|
44
|
+
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
45
|
+
import { randomBytes } from "node:crypto";
|
|
46
|
+
import { createServer } from "node:net";
|
|
47
|
+
import { readJson } from "../src/core/atomic.mjs";
|
|
48
|
+
import {
|
|
49
|
+
appendCommand,
|
|
50
|
+
findProcessedCommand,
|
|
51
|
+
gcJournal,
|
|
52
|
+
readCheckpoint,
|
|
53
|
+
readJournal,
|
|
54
|
+
repairJournalTail,
|
|
55
|
+
writeCheckpoint,
|
|
56
|
+
} from "../src/core/coordinator-journal.mjs";
|
|
57
|
+
import { classifyEndpointHealth, shouldUnlinkEndpoint, socketIdentityObservation } from "../src/core/host-coordination.mjs";
|
|
58
|
+
import { appendDiagnostic } from "../src/core/diagnostics.mjs";
|
|
59
|
+
import { acquireOwnedViewLock, describeLeaseConflict, heartbeatFailureAction, withViewLockSync } from "../src/core/locks.mjs";
|
|
60
|
+
import { captureStartToken } from "../src/core/pid.mjs";
|
|
61
|
+
import { resolveTestMs } from "../src/core/test-knobs.mjs";
|
|
62
|
+
import * as P from "../src/core/paths.mjs";
|
|
63
|
+
import { readState, readStatus, writeState, writeStatus } from "../src/core/store.mjs";
|
|
64
|
+
import { TRANSIENT_KINDS, decideStateTransition, validateCommand } from "../src/core/state-commands.mjs";
|
|
65
|
+
import { COORDINATOR_PROTOCOL_VERSION } from "../src/core/coordinator-protocol.mjs";
|
|
66
|
+
|
|
67
|
+
/** In-memory processed-command ring size (FIFO). Beyond the journal, this covers
|
|
68
|
+
* idempotency when the journal prefix has already been GC'd away. */
|
|
69
|
+
const PROCESSED_RING_MAX = 1000;
|
|
70
|
+
/** Journal growth (bytes) that triggers checkpoint + GC. Keeps boot replay bounded. */
|
|
71
|
+
const CHECKPOINT_THRESHOLD_BYTES = 262_144;
|
|
72
|
+
/** Lease heartbeat — matches pty-runner's host-start lease cadence. */
|
|
73
|
+
const HEARTBEAT_MS = 1000;
|
|
74
|
+
/** Lifecycle kinds whose legacy write sites stamped state.lastActivityAt on
|
|
75
|
+
* every persist (service markQueued/archive/adopt, job-runner plan-ready,
|
|
76
|
+
* reconcile terminal sites). The decision layer stays clock-free, so the
|
|
77
|
+
* shell adds the wall-clock stamp — into the patch BEFORE the journal
|
|
78
|
+
* append, so boot replay re-applies the exact same fields deterministically
|
|
79
|
+
* (there is no clock at replay time either). (Task 1 review F5.) */
|
|
80
|
+
const LAST_ACTIVITY_STAMP_KINDS = new Set(["mark_queued", "archive_view", "adopt_session", "reconcile_finalize", "plan_ready", "mark_completed", "mark_holding", "clear_holding", "host_run_failed"]);
|
|
81
|
+
/** Kinds whose status patch may CREATE the run's status file: they carry the
|
|
82
|
+
* full status content for a run that has no materialized status yet. Every
|
|
83
|
+
* other kind keeps PR #1's "patch presence ≠ file requirement" semantics
|
|
84
|
+
* (e.g. mark_completed's `status: {autoState: null}` on a legacy row without
|
|
85
|
+
* a status file must not fabricate one).
|
|
86
|
+
*
|
|
87
|
+
* `run_progress` joins them (issue #91 hard-down residual closure): its beat
|
|
88
|
+
* patch IS the runner's full in-memory status, and when the coordinator was
|
|
89
|
+
* down through the runner's boot window (mark_queued applied, run_started
|
|
90
|
+
* lost) the first post-recovery beat is the only surviving source that can
|
|
91
|
+
* recreate the file — without it beats and finalize reject stale_run forever
|
|
92
|
+
* and the row can never converge. The decision layer gates this on a
|
|
93
|
+
* qualified live-run beat (full-shaped patch, matching string runId). */
|
|
94
|
+
const STATUS_BOOTSTRAP_KINDS = new Set(["run_started", "followup_started", "run_progress"]);
|
|
95
|
+
|
|
96
|
+
const root = process.argv[2];
|
|
97
|
+
if (!root) {
|
|
98
|
+
process.stderr.write("state-coordinator: missing root argument\n");
|
|
99
|
+
process.exit(2);
|
|
100
|
+
}
|
|
101
|
+
if (process.env.AGENT_BOARD_COORDINATOR === "off") process.exit(0);
|
|
102
|
+
|
|
103
|
+
main().catch((err) => {
|
|
104
|
+
process.stderr.write(`state-coordinator: fatal ${err?.stack || err}\n`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Append a coordinator-scoped diagnostic. `appendDiagnostic` throws when the
|
|
110
|
+
* disk is failing, so callers fence the write where a crash would be worse
|
|
111
|
+
* than a lost diagnostic (issue #224 D4). The AGENT_BOARD_TEST_FAIL_DIAGNOSTIC
|
|
112
|
+
* knob forces that throw so A14b can prove the step-down survives it.
|
|
113
|
+
* @param {string} code
|
|
114
|
+
* @param {Record<string, any>} details
|
|
115
|
+
* @param {"info"|"warn"|"error"} [level]
|
|
116
|
+
* @param {string} [source]
|
|
117
|
+
*/
|
|
118
|
+
function appendCoordinatorDiagnostic(code, details, level = "info", source = "coordinator") {
|
|
119
|
+
if (process.env.AGENT_BOARD_TEST_FAIL_DIAGNOSTIC === "1") {
|
|
120
|
+
throw new Error(`forced coordinator diagnostic failure (${code})`);
|
|
121
|
+
}
|
|
122
|
+
appendDiagnostic(root, "_coordinator", { source, level, code, details });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether the board root is provably gone. Only `ENOENT` counts (issue #241):
|
|
127
|
+
* a transient access failure (EACCES/EBUSY, e.g. a scanner holding the
|
|
128
|
+
* directory) must never kill a healthy coordinator, so it reads as "present".
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
function rootVanished() {
|
|
132
|
+
try {
|
|
133
|
+
statSync(root);
|
|
134
|
+
return false;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
return /** @type {NodeJS.ErrnoException} */ (err)?.code === "ENOENT";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Snapshot the board root directory's filesystem identity (issue #241).
|
|
142
|
+
* `{dev, ino}` identifies one exact directory: a delete-and-recreate at the same
|
|
143
|
+
* path gets a new inode, which is the only way to tell "the root came back" from
|
|
144
|
+
* "the root never left" once a write's ensureDir has recreated it. Returns null
|
|
145
|
+
* when the root is missing or the filesystem reports no usable identity (ino 0,
|
|
146
|
+
* or no dev/ino at all), which degrades the check to existence-only.
|
|
147
|
+
* @returns {{dev: number, ino: number}|null}
|
|
148
|
+
*/
|
|
149
|
+
function captureRootIdentity() {
|
|
150
|
+
try {
|
|
151
|
+
const st = statSync(root);
|
|
152
|
+
if (!Number.isFinite(st.dev) || !Number.isFinite(st.ino) || st.ino === 0) return null;
|
|
153
|
+
return { dev: st.dev, ino: st.ino };
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether the board root is gone or was replaced since boot (issue #241).
|
|
161
|
+
* Existence alone is not enough: `appendCommand`/`writeState`/lease acquisition
|
|
162
|
+
* ensureDir their parent, so a write landing after the delete RECREATES the path
|
|
163
|
+
* and a plain stat then reads "present" (final-review I1). Comparing against the
|
|
164
|
+
* identity captured at boot detects the replacement on the next supervised tick,
|
|
165
|
+
* including the boot-window race where lease acquisition recreates the root.
|
|
166
|
+
* Only `ENOENT` counts as missing; any other stat error (EACCES/EBUSY) reads as
|
|
167
|
+
* present so a transient access failure never kills a healthy coordinator. With
|
|
168
|
+
* no captured identity the check degrades to existence-only (never a false
|
|
169
|
+
* step-down).
|
|
170
|
+
* @param {{dev: number, ino: number}|null} identity
|
|
171
|
+
* @returns {"missing"|"replaced"|null}
|
|
172
|
+
*/
|
|
173
|
+
function rootVanishedOrReplaced(identity) {
|
|
174
|
+
try {
|
|
175
|
+
const st = statSync(root);
|
|
176
|
+
if (!identity) return null;
|
|
177
|
+
return st.dev !== identity.dev || st.ino !== identity.ino ? "replaced" : null;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return /** @type {NodeJS.ErrnoException} */ (err)?.code === "ENOENT" ? "missing" : null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The one stderr line every "root is gone" exit emits (issue #241): the boot
|
|
185
|
+
* guard and the supervised step-down. Neither writes a diagnostic: every
|
|
186
|
+
* append helper ensures its parent directory (src/core/atomic.mjs), so a write
|
|
187
|
+
* under <root>/views/_coordinator would recreate the deleted directory chain —
|
|
188
|
+
* the throwaway-root residue this issue exists to remove. Detached callers
|
|
189
|
+
* ignore stdio; a test pipes stderr and attributes the exit to this line.
|
|
190
|
+
* @param {"missing"|"replaced"} state
|
|
191
|
+
* @param {string} outcome
|
|
192
|
+
*/
|
|
193
|
+
function writeRootGone(state, outcome) {
|
|
194
|
+
process.stderr.write(`state-coordinator: board root ${state} (${root}); ${outcome}\n`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function main() {
|
|
198
|
+
const instanceId = randomBytes(8).toString("hex");
|
|
199
|
+
const startedAt = Date.now();
|
|
200
|
+
const heartbeatMs = resolveTestMs(process.env, "AGENT_BOARD_TEST_COORDINATOR_HEARTBEAT_MS", HEARTBEAT_MS);
|
|
201
|
+
|
|
202
|
+
// Capture the root's identity BEFORE the boot guard (issue #241, final-review
|
|
203
|
+
// I1): a delete between the guard and lease acquisition would let that
|
|
204
|
+
// ensureDir recreate the root WITH a valid lease, and the process would then
|
|
205
|
+
// serve it forever. The tick compares against this snapshot, so the recreated
|
|
206
|
+
// directory is detected on the first heartbeat.
|
|
207
|
+
const rootIdentity = captureRootIdentity();
|
|
208
|
+
|
|
209
|
+
// 0. A root that is already gone must fail closed (issue #241 A3b). Lease
|
|
210
|
+
// acquisition runs ensureDir, which would happily REBUILD the deleted board
|
|
211
|
+
// and serve that empty root forever — the exact orphan shape this issue is
|
|
212
|
+
// about. "Nothing to serve" is an expected outcome like a lost lease, so it
|
|
213
|
+
// exits 0, writes no diagnostic, and logs the shared stderr line.
|
|
214
|
+
if (rootVanished()) {
|
|
215
|
+
writeRootGone("missing", "nothing to serve; exiting");
|
|
216
|
+
process.exit(0);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 1. Lease first: a second coordinator is an expected, idempotent no-op.
|
|
220
|
+
// Identity carries a POSIX startToken (pty-runner pattern) so a SIGKILLed
|
|
221
|
+
// coordinator's lease can be reclaimed by the next instance after pid reuse,
|
|
222
|
+
// plus the in-process instanceId the pong reports (issue #239 A4) so a client
|
|
223
|
+
// can tell whether the live endpoint is still the lease's owner.
|
|
224
|
+
// heartbeatMs declares the staleness contract (issue #224): a holder that
|
|
225
|
+
// stops heartbeating becomes reclaimable on its own staleness.
|
|
226
|
+
/** @type {import("../src/core/locks.mjs").Lease} */
|
|
227
|
+
let lease;
|
|
228
|
+
try {
|
|
229
|
+
lease = acquireOwnedViewLock(root, "_coordinator", "state-coordinator", {
|
|
230
|
+
waitMs: 500,
|
|
231
|
+
identity: { pid: process.pid, startToken: captureStartToken(process.pid), instanceId },
|
|
232
|
+
heartbeatMs,
|
|
233
|
+
});
|
|
234
|
+
} catch (err) {
|
|
235
|
+
// Record the conflict instead of exiting silently (issue #224 D6). The
|
|
236
|
+
// diagnostic write is best-effort: it must never turn the expected
|
|
237
|
+
// idempotent exit into a crash.
|
|
238
|
+
const summary = describeLeaseConflict(root, "_coordinator", "state-coordinator");
|
|
239
|
+
if (summary) {
|
|
240
|
+
try { appendCoordinatorDiagnostic("coordinator_lease_unavailable", summary, "error"); } catch { /* best effort */ }
|
|
241
|
+
}
|
|
242
|
+
process.stderr.write(`state-coordinator: lease unavailable (${err?.code ?? err?.message ?? "unknown"}); another instance may own it; exiting\n`);
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
// Leave a trace when this acquire reclaimed a stale lease (issue #224 D6).
|
|
246
|
+
if (lease.reclaimed) {
|
|
247
|
+
try { appendCoordinatorDiagnostic("lease_reclaimed", { ...lease.reclaimed }, "info", "lock"); } catch { /* best effort */ }
|
|
248
|
+
}
|
|
249
|
+
// Boot-edge touch (issue #224 D5): reflect acquisition progress before the
|
|
250
|
+
// supervised interval starts. Never counted toward the failure counter.
|
|
251
|
+
try { lease.touch(); } catch { /* best effort */ }
|
|
252
|
+
|
|
253
|
+
// 2. Boot state: journal summary, processed-command ring, replay, counter init.
|
|
254
|
+
// Repair a crash-torn tail FIRST: without it the first post-restart append
|
|
255
|
+
// concatenates onto the torn line and that fsynced record becomes invisible
|
|
256
|
+
// to every readJournal / replay / revision scan below.
|
|
257
|
+
repairJournalTail(root);
|
|
258
|
+
const checkpoint = readCheckpoint(root) ?? { materializedRevision: 0, journalBytes: 0 };
|
|
259
|
+
const journal = readJournal(root);
|
|
260
|
+
/** @type {Map<string, { result: { status: string, reason: string|null }, materializedRevision: number }>} */
|
|
261
|
+
const processedCommands = new Map();
|
|
262
|
+
for (const record of journal) {
|
|
263
|
+
const commandId = record?.command?.commandId;
|
|
264
|
+
if (commandId && !processedCommands.has(commandId)) {
|
|
265
|
+
processedCommands.set(commandId, {
|
|
266
|
+
result: record.result ?? { status: "rejected", reason: "unknown" },
|
|
267
|
+
materializedRevision: record.materializedRevision ?? 0,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Replay in journal order: re-materialize every applied record from its stored
|
|
273
|
+
// patches; materialize's per-file guards write only files strictly behind the
|
|
274
|
+
// recorded revision, so this repairs just the half that missed its write in a
|
|
275
|
+
// crash window and never moves the other half backwards.
|
|
276
|
+
for (const record of journal) {
|
|
277
|
+
if (record?.result?.status !== "applied") continue;
|
|
278
|
+
const viewId = record?.command?.viewId;
|
|
279
|
+
if (!viewId) continue;
|
|
280
|
+
materialize(viewId, record.command ?? {}, record.mutate ?? {}, record.materializedRevision ?? 0, record.statusRunId ?? null);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Global revision counter: never reissue a revision that exists anywhere.
|
|
284
|
+
let revisionCounter = Math.max(checkpoint.materializedRevision ?? 0, 0);
|
|
285
|
+
for (const record of journal) revisionCounter = Math.max(revisionCounter, record.materializedRevision ?? 0);
|
|
286
|
+
if (existsSync(P.viewsDir(root))) {
|
|
287
|
+
for (const entry of readdirSync(P.viewsDir(root))) {
|
|
288
|
+
const state = readJson(P.statePath(root, entry), null);
|
|
289
|
+
revisionCounter = Math.max(revisionCounter, state?.materializedRevision ?? 0);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
let lastCheckpointBytes = 0;
|
|
293
|
+
|
|
294
|
+
// Boot-edge touch (issue #224 D5): replay is done; the heartbeat now
|
|
295
|
+
// reflects live progress before the endpoint binds. Never counted.
|
|
296
|
+
try { lease.touch(); } catch { /* best effort */ }
|
|
297
|
+
|
|
298
|
+
// 3. Control socket (JSONL, pty-runner's line-buffered server shape).
|
|
299
|
+
const socketPath = P.coordinatorEndpointPathFor(process.platform, root);
|
|
300
|
+
if (process.platform !== "win32" && existsSync(socketPath)) {
|
|
301
|
+
// Lease is ours: any leftover socket file belongs to a dead coordinator.
|
|
302
|
+
try { unlinkSync(socketPath); } catch { /* best effort */ }
|
|
303
|
+
}
|
|
304
|
+
/** {dev,ino} recorded at bind time; cleanup unlinks only this exact inode. */
|
|
305
|
+
let boundSocketIdentity = null;
|
|
306
|
+
/** Set by the supervised tick when the bound endpoint is gone or replaced:
|
|
307
|
+
* shutdown must then leave the path alone even while the lease still looks
|
|
308
|
+
* ours (issue #255). */
|
|
309
|
+
let endpointCompromised = false;
|
|
310
|
+
|
|
311
|
+
const server = createServer((socket) => {
|
|
312
|
+
let buffer = "";
|
|
313
|
+
socket.on("data", (chunk) => {
|
|
314
|
+
buffer += chunk.toString("utf8");
|
|
315
|
+
const lines = buffer.split("\n");
|
|
316
|
+
buffer = lines.pop() ?? "";
|
|
317
|
+
for (const line of lines) handleClientLine(line, socket);
|
|
318
|
+
});
|
|
319
|
+
socket.on("error", () => {}); // client vanished mid-request; nothing to answer
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
server.on("error", (err) => {
|
|
323
|
+
process.stderr.write(`state-coordinator: server error ${err instanceof Error ? err.message : String(err)}\n`);
|
|
324
|
+
shutdown(1);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Boot-edge touch (issue #224 D5): the endpoint bind is the last boot
|
|
328
|
+
// boundary; the supervised interval takes over once listen succeeds.
|
|
329
|
+
// Never counted toward the failure counter.
|
|
330
|
+
try { lease.touch(); } catch { /* best effort */ }
|
|
331
|
+
|
|
332
|
+
// Consecutive supervised heartbeat failures. Only interval touches count;
|
|
333
|
+
// the boot-edge touches above are deliberately ignored (issue #224 D4/D5).
|
|
334
|
+
let consecutiveTouchFailures = 0;
|
|
335
|
+
let heartbeatTimer = null;
|
|
336
|
+
|
|
337
|
+
server.listen(socketPath, () => {
|
|
338
|
+
if (process.platform !== "win32") {
|
|
339
|
+
try {
|
|
340
|
+
const st = statSync(socketPath);
|
|
341
|
+
boundSocketIdentity = { dev: st.dev, ino: st.ino };
|
|
342
|
+
} catch (err) {
|
|
343
|
+
// Without the bind identity both endpoint cleanup and self-supervision
|
|
344
|
+
// degrade to a no-op; say so instead of failing silently (issue #255).
|
|
345
|
+
process.stderr.write(`state-coordinator: endpoint identity unavailable (${err instanceof Error ? err.message : String(err)}); self-supervision disabled\n`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Supervision starts only after the endpoint is bound (issue #224 D4):
|
|
349
|
+
// a live-but-stuck holder must step down before its lease can be
|
|
350
|
+
// preempted as stale, and stepping down before the socket exists would
|
|
351
|
+
// make the A14 endpoint cleanup a no-op.
|
|
352
|
+
heartbeatTimer = setInterval(() => {
|
|
353
|
+
// Step down when the board root is gone or was REPLACED (issue #241):
|
|
354
|
+
// a detached coordinator that outlives its root serves nobody, and its
|
|
355
|
+
// lease can never be reclaimed while the pid is alive. Detection reuses
|
|
356
|
+
// this tick — no second timer — so the worst case is one heartbeat.
|
|
357
|
+
//
|
|
358
|
+
// Existence alone is not enough (final-review I1): a write landing after
|
|
359
|
+
// the delete (appendCommand / writeState → ensureDir) recreates the
|
|
360
|
+
// directory, so a plain stat would read "present" and the coordinator
|
|
361
|
+
// would serve a dead board. The boot-time dev/ino identity distinguishes
|
|
362
|
+
// the recreated directory from the original one, and the same guard
|
|
363
|
+
// catches a root deleted between the boot guard and lease acquisition
|
|
364
|
+
// (that ensureDir would recreate it WITH a lease).
|
|
365
|
+
//
|
|
366
|
+
// No diagnostic is written on either path: every append helper
|
|
367
|
+
// ensureDirs its parent (src/core/atomic.mjs), so appending to
|
|
368
|
+
// <root>/views/_coordinator/diagnostics.jsonl would RECREATE the
|
|
369
|
+
// deleted directory chain — reintroducing exactly the throwaway-root
|
|
370
|
+
// residue this issue measures (U1). The shared stderr line is the
|
|
371
|
+
// attribution instead; detached callers ignore stdio, while a test
|
|
372
|
+
// spawns with a stderr pipe. Residual accepted (spec §7): a write in
|
|
373
|
+
// the delete window may leave a skeleton directory (data, not a
|
|
374
|
+
// process); this tick bounds its lifetime.
|
|
375
|
+
//
|
|
376
|
+
// A missing root also makes the touch below fail, but that path needs
|
|
377
|
+
// three consecutive failures; checking first attributes the step-down
|
|
378
|
+
// to the real cause and vacates the root immediately.
|
|
379
|
+
const rootState = rootVanishedOrReplaced(rootIdentity);
|
|
380
|
+
if (rootState) {
|
|
381
|
+
writeRootGone(rootState, "stepping down");
|
|
382
|
+
shutdown(0);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Self-supervise the endpoint (issue #255): a lease holder whose socket
|
|
386
|
+
// vanished or was replaced serves nobody — every client write fails with
|
|
387
|
+
// ENOENT while its fresh heartbeat keeps replacement coordinators losing
|
|
388
|
+
// the lease race. Step down so the next ensureCoordinator() binds a fresh
|
|
389
|
+
// endpoint. MUST stay after the root guard: a diagnostic append for a
|
|
390
|
+
// deleted root would recreate the directory skeleton (#241). `unknown`
|
|
391
|
+
// (transient stat error, or no bound identity) never steps a holder down.
|
|
392
|
+
// Short-circuit on the bound identity: without it (win32 named pipe, or a
|
|
393
|
+
// failed bind-time stat) there is nothing to compare against, and skipping
|
|
394
|
+
// the stat also avoids win32's non-trivial pipe stat side effects.
|
|
395
|
+
const endpointState = boundSocketIdentity
|
|
396
|
+
? classifyEndpointHealth(boundSocketIdentity, statSocketObservation(socketPath))
|
|
397
|
+
: "unknown";
|
|
398
|
+
if (endpointState === "missing" || endpointState === "replaced") {
|
|
399
|
+
endpointCompromised = true; // veto endpoint cleanup in shutdown (R1)
|
|
400
|
+
try {
|
|
401
|
+
appendCoordinatorDiagnostic("coordinator_endpoint_lost", { state: endpointState }, "warn");
|
|
402
|
+
} catch { /* a failing diagnostic must not block the step-down (#224 D4) */ }
|
|
403
|
+
process.stderr.write(`state-coordinator: endpoint ${endpointState} (${socketPath}); stepping down\n`);
|
|
404
|
+
shutdown(0);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
let ok;
|
|
408
|
+
try {
|
|
409
|
+
ok = process.env.AGENT_BOARD_TEST_FORCE_TOUCH_FAILURE === "1" ? false : lease.touch();
|
|
410
|
+
} catch {
|
|
411
|
+
ok = false;
|
|
412
|
+
}
|
|
413
|
+
if (!ok) {
|
|
414
|
+
consecutiveTouchFailures += 1;
|
|
415
|
+
if (heartbeatFailureAction(consecutiveTouchFailures) === "abandon") {
|
|
416
|
+
// Count → diagnostic → shutdown, in that order. The
|
|
417
|
+
// diagnostic write throws when the disk is failing (the
|
|
418
|
+
// exact step-down scenario), so it is fenced and must not
|
|
419
|
+
// block the exit.
|
|
420
|
+
try {
|
|
421
|
+
appendCoordinatorDiagnostic("coordinator_heartbeat_lost", { consecutiveFailures: consecutiveTouchFailures }, "error");
|
|
422
|
+
} catch { /* a failing diagnostic must not block the step-down */ }
|
|
423
|
+
shutdown(0);
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
consecutiveTouchFailures = 0;
|
|
427
|
+
}
|
|
428
|
+
}, heartbeatMs);
|
|
429
|
+
heartbeatTimer.unref?.();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
let shuttingDown = false;
|
|
433
|
+
process.on("SIGTERM", () => shutdown(0));
|
|
434
|
+
process.on("SIGINT", () => shutdown(0));
|
|
435
|
+
|
|
436
|
+
/** One stat attempt, classified into the supervised observation discriminator.
|
|
437
|
+
* A transient access error is `unknown`, never `missing`/`replaced` (issue #255). */
|
|
438
|
+
function statSocketObservation(path) {
|
|
439
|
+
let stat = null;
|
|
440
|
+
let errorCode = null;
|
|
441
|
+
try {
|
|
442
|
+
stat = statSync(path);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
errorCode = /** @type {NodeJS.ErrnoException} */ (err)?.code ?? "UNKNOWN";
|
|
445
|
+
}
|
|
446
|
+
return socketIdentityObservation(stat, errorCode);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Lease ownership at cleanup time; a read failure fails closed (no unlink). */
|
|
450
|
+
function safeIsOwner() {
|
|
451
|
+
try { return lease.isOwner(); } catch { return false; }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function shutdown(code) {
|
|
455
|
+
if (shuttingDown) return;
|
|
456
|
+
shuttingDown = true;
|
|
457
|
+
try { clearInterval(heartbeatTimer); } catch { /* best effort */ }
|
|
458
|
+
// Do NOT call server.close() (issue #255, nodejs/node#19729): libuv unlinks the
|
|
459
|
+
// unix-socket bind path when the handle closes, so a deposed coordinator closing
|
|
460
|
+
// after a successor bound would delete the successor's endpoint. The process is
|
|
461
|
+
// exiting anyway, and process.exit() skips the uv handle cleanup, so the implicit
|
|
462
|
+
// unlink never runs. Cleanup is explicit and ownership-gated below — the #70
|
|
463
|
+
// contract: unlink only while this instance still holds the lease AND the path
|
|
464
|
+
// still carries the inode it bound.
|
|
465
|
+
// `endpointCompromised` (set by the supervised tick) vetoes that explicit unlink
|
|
466
|
+
// as well; it is defence in depth on top of the dev+ino comparison.
|
|
467
|
+
const stillOwner = safeIsOwner();
|
|
468
|
+
if (process.platform !== "win32" && stillOwner && !endpointCompromised && boundSocketIdentity) {
|
|
469
|
+
try {
|
|
470
|
+
const st = statSync(socketPath);
|
|
471
|
+
if (shouldUnlinkEndpoint({ stillOwner, compromised: endpointCompromised, bound: boundSocketIdentity, current: { dev: st.dev, ino: st.ino } })) {
|
|
472
|
+
try { unlinkSync(socketPath); } catch { /* best effort */ }
|
|
473
|
+
}
|
|
474
|
+
} catch { /* path gone — nothing to clean */ }
|
|
475
|
+
}
|
|
476
|
+
try { lease.release(); } catch { /* best effort */ }
|
|
477
|
+
process.exit(code);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function send(socket, msg) {
|
|
481
|
+
socket.write(JSON.stringify(msg) + "\n");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function handleClientLine(line, socket) {
|
|
485
|
+
if (!line.trim()) return;
|
|
486
|
+
let msg;
|
|
487
|
+
try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
|
|
488
|
+
switch (msg?.type) {
|
|
489
|
+
case "ping":
|
|
490
|
+
send(socket, { type: "pong", instanceId, startedAt, protocolVersion: COORDINATOR_PROTOCOL_VERSION });
|
|
491
|
+
break;
|
|
492
|
+
case "state_command":
|
|
493
|
+
send(socket, { type: "state_command_result", ...processStateCommand(msg) });
|
|
494
|
+
break;
|
|
495
|
+
default:
|
|
496
|
+
send(socket, { type: "error", message: "unknown type" });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The command loop: validate → dedupe → decide → journal (fsync) → materialize
|
|
502
|
+
* → remember → reply. Rejections are journaled too, so their exact reason is
|
|
503
|
+
* replayable; duplicates short-circuit before any append.
|
|
504
|
+
* @param {object} msg
|
|
505
|
+
* @returns {{ commandId: string|null, status: string, reason: string|null, materializedRevision: number }}
|
|
506
|
+
*/
|
|
507
|
+
function processStateCommand(msg) {
|
|
508
|
+
const checked = validateCommand(msg);
|
|
509
|
+
if (!checked.ok) {
|
|
510
|
+
return {
|
|
511
|
+
commandId: typeof msg?.commandId === "string" ? msg.commandId : null,
|
|
512
|
+
status: "rejected",
|
|
513
|
+
reason: checked.error,
|
|
514
|
+
materializedRevision: 0,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const command = checked.command;
|
|
518
|
+
const transient = TRANSIENT_KINDS.includes(command.kind);
|
|
519
|
+
|
|
520
|
+
if (!transient) {
|
|
521
|
+
const known = lookupProcessed(command.commandId);
|
|
522
|
+
if (known) {
|
|
523
|
+
return { commandId: command.commandId, status: known.result.status, reason: known.result.reason, materializedRevision: known.materializedRevision };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const state = readState(root, command.viewId);
|
|
528
|
+
// Status consistency only binds the view's current run (spec 根治条件 5):
|
|
529
|
+
// no currentRunId → status.json plays no role in this command.
|
|
530
|
+
// Exception (Task 1 review F1): followup_started re-points the row at a
|
|
531
|
+
// NEW run carried in payload.newRunId (command.runId is deliberately null
|
|
532
|
+
// so the stale-run guard cannot fire against the finished parent run).
|
|
533
|
+
// Resolving the status from the parent here would overwrite the parent's
|
|
534
|
+
// status.json with the new run's bootstrap patch; resolving from newRunId
|
|
535
|
+
// finds no file and cleanly bootstraps the new run's status instead.
|
|
536
|
+
const statusRunId = command.kind === "followup_started"
|
|
537
|
+
? (typeof command.payload?.newRunId === "string" ? command.payload.newRunId : null)
|
|
538
|
+
: (command.runId ?? state?.currentRunId ?? null);
|
|
539
|
+
const status = statusRunId ? readStatus(root, command.viewId, statusRunId) : null;
|
|
540
|
+
const now = Date.now();
|
|
541
|
+
const decision = stampLegacyTimestamps(command, decideStateTransition(command, state, status, now), now);
|
|
542
|
+
const currentRevision = state?.materializedRevision ?? 0;
|
|
543
|
+
|
|
544
|
+
if (decision.action === "reject") {
|
|
545
|
+
if (transient) {
|
|
546
|
+
// Transient rejections are not replayable either — nothing was
|
|
547
|
+
// mutated, and journaling them would grow the journal for noise.
|
|
548
|
+
return { commandId: command.commandId ?? null, status: "rejected", reason: decision.reason, materializedRevision: currentRevision };
|
|
549
|
+
}
|
|
550
|
+
const result = { status: "rejected", reason: decision.reason };
|
|
551
|
+
const record = { command, result, materializedRevision: currentRevision, at: now };
|
|
552
|
+
const journalBytes = appendCommand(root, record);
|
|
553
|
+
rememberProcessed(command.commandId, result, currentRevision);
|
|
554
|
+
maybeCheckpoint(journalBytes);
|
|
555
|
+
return { commandId: command.commandId, status: "rejected", reason: decision.reason, materializedRevision: currentRevision };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const newRevision = revisionCounter + 1;
|
|
559
|
+
const result = { status: "applied", reason: decision.reason };
|
|
560
|
+
if (transient) {
|
|
561
|
+
// No journal, no processed ring, no checkpoint: a periodic snapshot —
|
|
562
|
+
// the next beat supersedes it.
|
|
563
|
+
materialize(command.viewId, command, decision.mutate, newRevision, statusRunId);
|
|
564
|
+
revisionCounter = newRevision;
|
|
565
|
+
return { commandId: command.commandId ?? null, status: "applied", reason: decision.reason, materializedRevision: newRevision };
|
|
566
|
+
}
|
|
567
|
+
// Journal first (fsync inside), materialize second: boot replay repairs
|
|
568
|
+
// the window between the two using the stored patches.
|
|
569
|
+
const record = { command, result, materializedRevision: newRevision, at: now, mutate: decision.mutate, statusRunId };
|
|
570
|
+
const journalBytes = appendCommand(root, record);
|
|
571
|
+
materialize(command.viewId, command, decision.mutate, newRevision, statusRunId);
|
|
572
|
+
revisionCounter = newRevision;
|
|
573
|
+
rememberProcessed(command.commandId, result, newRevision);
|
|
574
|
+
maybeCheckpoint(journalBytes);
|
|
575
|
+
return { commandId: command.commandId, status: "applied", reason: decision.reason, materializedRevision: newRevision };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Merge patches onto state.json (and the run's status.json when it exists)
|
|
580
|
+
* under the view's materialize lock, stamping the shared revision. Per-file
|
|
581
|
+
* guards stamp only files strictly behind the record revision: the live path
|
|
582
|
+
* always qualifies (the global counter is monotonic), while replay repairs
|
|
583
|
+
* just the half that missed its write in a crash window and never moves the
|
|
584
|
+
* other half backwards.
|
|
585
|
+
*
|
|
586
|
+
* Pairing invariant (issue #91 final review): EVERY applied revision pairs
|
|
587
|
+
* BOTH files. A state-only patch (e.g. patch_fields markVisited) restamps
|
|
588
|
+
* the existing status file's revision with no content change, and a
|
|
589
|
+
* status-only patch restamps the state file the same way — a healthy
|
|
590
|
+
* coordinator can never produce the asymmetric pair the reader-side
|
|
591
|
+
* revision check (reconcile) treats as a crash signature. Journaled
|
|
592
|
+
* state-only records heal on replay through this shared path. Residual:
|
|
593
|
+
* a torn TRANSIENT beat (crash between the two writes, never journaled)
|
|
594
|
+
* stays half-paired — ms-window × adjacent-crash, documented in the spec.
|
|
595
|
+
*
|
|
596
|
+
* A status patch without a status file is skipped — patch presence ≠ file
|
|
597
|
+
* requirement (mark_completed always emits `status: {autoState: null}`).
|
|
598
|
+
* The status run binding is the decision-time one (`statusRunId`, recorded
|
|
599
|
+
* in the journal) so replay cannot re-bind a runId-less patch to whatever
|
|
600
|
+
* run is current on disk at replay time; `state.currentRunId` remains only
|
|
601
|
+
* as a legacy fallback for records written before statusRunId existed.
|
|
602
|
+
*/
|
|
603
|
+
function materialize(viewId, command, mutate, revision, statusRunId) {
|
|
604
|
+
withViewLockSync(root, viewId, "state-materialize", () => {
|
|
605
|
+
const state = readState(root, viewId);
|
|
606
|
+
if (state && (state.materializedRevision ?? 0) < revision) {
|
|
607
|
+
writeState(root, mutate?.state
|
|
608
|
+
? { ...state, ...mutate.state, materializedRevision: revision }
|
|
609
|
+
: { ...state, materializedRevision: revision });
|
|
610
|
+
}
|
|
611
|
+
const runId = command?.runId ?? statusRunId ?? state?.currentRunId ?? null;
|
|
612
|
+
if (!runId) return;
|
|
613
|
+
const status = readStatus(root, viewId, runId);
|
|
614
|
+
if (mutate?.status) {
|
|
615
|
+
if (!status && STATUS_BOOTSTRAP_KINDS.has(command?.kind)) {
|
|
616
|
+
// run_started / followup_started / (bootstrapping) run_progress
|
|
617
|
+
// carry the full status content for a run that has no status
|
|
618
|
+
// file yet — create it. Identity fields come from the command
|
|
619
|
+
// context; the patch carries everything meaningful.
|
|
620
|
+
writeStatus(root, { version: 1, runId, viewId, ...mutate.status, materializedRevision: revision });
|
|
621
|
+
} else if (status && (status.materializedRevision ?? 0) < revision) {
|
|
622
|
+
writeStatus(root, { ...status, ...mutate.status, materializedRevision: revision });
|
|
623
|
+
}
|
|
624
|
+
} else if (status && (status.materializedRevision ?? 0) < revision) {
|
|
625
|
+
// State-only patch: restamp the existing status file's revision with
|
|
626
|
+
// no content change (pairing invariant above).
|
|
627
|
+
writeStatus(root, { ...status, materializedRevision: revision });
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Add the legacy wall-clock stamp to a decided patch (F5): see
|
|
633
|
+
* LAST_ACTIVITY_STAMP_KINDS. Applied before the journal append so the
|
|
634
|
+
* stored patches are exactly what replay re-materializes. */
|
|
635
|
+
function stampLegacyTimestamps(command, decision, now) {
|
|
636
|
+
if (decision.action !== "apply" || !LAST_ACTIVITY_STAMP_KINDS.has(command.kind)) return decision;
|
|
637
|
+
return { ...decision, mutate: { ...decision.mutate, state: { ...(decision.mutate.state ?? {}), lastActivityAt: now } } };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Memory ring first (covers the post-GC window), journal second. */
|
|
641
|
+
function lookupProcessed(commandId) {
|
|
642
|
+
const inMemory = processedCommands.get(commandId);
|
|
643
|
+
if (inMemory) return inMemory;
|
|
644
|
+
const result = findProcessedCommand(root, commandId);
|
|
645
|
+
if (!result) return null;
|
|
646
|
+
const entry = { result, materializedRevision: revisionOf(commandId) };
|
|
647
|
+
rememberProcessed(commandId, result, entry.materializedRevision);
|
|
648
|
+
return entry;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Revision recorded for a known commandId (journal scan; 0 if unrecorded). */
|
|
652
|
+
function revisionOf(commandId) {
|
|
653
|
+
for (const record of readJournal(root)) {
|
|
654
|
+
if (record?.command?.commandId === commandId) return record.materializedRevision ?? 0;
|
|
655
|
+
}
|
|
656
|
+
return 0;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function rememberProcessed(commandId, result, materializedRevision) {
|
|
660
|
+
processedCommands.set(commandId, { result, materializedRevision });
|
|
661
|
+
while (processedCommands.size > PROCESSED_RING_MAX) {
|
|
662
|
+
processedCommands.delete(processedCommands.keys().next().value);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Checkpoint + GC once the journal outgrows the threshold. GC only runs when
|
|
667
|
+
* the checkpoint write provably succeeded (journal module enforces this). */
|
|
668
|
+
function maybeCheckpoint(journalBytes) {
|
|
669
|
+
if (journalBytes - lastCheckpointBytes < CHECKPOINT_THRESHOLD_BYTES) return;
|
|
670
|
+
if (!writeCheckpoint(root, { materializedRevision: revisionCounter, journalBytes })) return;
|
|
671
|
+
lastCheckpointBytes = gcJournal(root);
|
|
672
|
+
}
|
|
673
|
+
}
|