@edgehero/pi-dispatch 4.3.1 → 5.0.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/.env.example +84 -3
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1639 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { sanitizeJobId } from "./run-history.mjs";
|
|
4
|
+
import { STEER_STDIN_BUDGET_BYTES, steerFileJson, steerFrame } from "./steer-framing.mjs";
|
|
5
|
+
import * as inboxModule from "./steer-inbox.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The worker's transport into a running job's inbox (issue #646, DES-STEER-VIA-READONLY-INBOX-MOUNT,
|
|
9
|
+
* INT-STEER-INBOX-CONTRACT): opened by run-container.mjs for a steer-ARMED attempt, beside the live transcript tail.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IT DOES, every `pollMs` (the cancel poll's shape: re-entrancy guarded, every fault swallowed and logged by code):
|
|
12
|
+
* 1. refreshes the run's liveness (`refreshLive`: the open key and, for a forge job, its target pointer), which is what
|
|
13
|
+
* lets a push be accepted at all;
|
|
14
|
+
* 2. writes each `queued` message, in the inbox's order, into the host directory mounted read-only at `/inbox`:
|
|
15
|
+
* framed (steer-framing.mjs), written to `.<id>.tmp` with mode 0444, marked `written`, then renamed to
|
|
16
|
+
* `<seq:06d>-<id>.json`. The runner only ever sees complete files under the final pattern;
|
|
17
|
+
* 3. unlinks the file of every message that is no longer waiting (`withdrawn`, `delivered`, `late`).
|
|
18
|
+
* At its stop it HOLDS the liveness keys for STEER_HOLD_TTL_MS rather than dropping them (`holdLive`), so the run still
|
|
19
|
+
* takes messages until its settle closes the inbox or its next attempt registers again.
|
|
20
|
+
*
|
|
21
|
+
* MARK BEFORE RENAME, not after. A `queued` message's withdrawal is promised as GUARANTEED (steer-inbox.mjs), so a file
|
|
22
|
+
* the runner can read must never exist for a message that could still be withdrawn as `queued`. Marking `written` first
|
|
23
|
+
* and renaming only when that compare and set succeeded makes the promise true: a withdraw that wins the race leaves
|
|
24
|
+
* the temporary file, which is removed, and nothing was visible. A rename that fails after the mark leaves a `written`
|
|
25
|
+
* message with no file, which the next tick writes again (a `written` message not on this attempt's disk is placed
|
|
26
|
+
* whatever its history: a retry's directory is new and empty).
|
|
27
|
+
*
|
|
28
|
+
* WRITING WAITS FOR THE BASELINE. `afterBaseline(prior)` is called once the live tail has read the staged transcript
|
|
29
|
+
* to its starting size (steer-ack.mjs `onBaseline`). Until then nothing is written, so a #645-restored transcript's
|
|
30
|
+
* deliveries are known before any file appears. At that moment the relay RE-BASELINES: a message the inbox calls
|
|
31
|
+
* `delivered` whose id is not among the transcript's prior lines goes back to `written`, and is handed over again,
|
|
32
|
+
* because the transcript this attempt starts from never saw it (a checkpoint older than the delivery, or a plain
|
|
33
|
+
* retry whose session starts empty).
|
|
34
|
+
*
|
|
35
|
+
* THE SECOND TRANSPORT, STDIN FRAMES (issue #686, INT-STEER-INBOX-CONTRACT). Given `frames` (`{ exitKey, macRunId }`,
|
|
36
|
+
* a stdin-steered attempt on a venue that copies its files), the relay writes each waiting message as a frame on the
|
|
37
|
+
* exec's stdin instead of a file (steer-framing.mjs `steerFrame`), once `attach(stdin, { written })` hands it the pipe
|
|
38
|
+
* the key frame went down. Everything else is the same pass: the same baseline wait, the same order, the same marks
|
|
39
|
+
* (`queued` to `written` BEFORE the write, so a message withdrawn while queued is never written), the same
|
|
40
|
+
* `steer_frame_refused` for an entry it cannot frame. What differs:
|
|
41
|
+
* - THE BUDGET. The relay counts every byte it wrote, the key frame's included, and a frame that would take the total
|
|
42
|
+
* past STEER_STDIN_BUDGET_BYTES is NOT written: the message moves to `late`, logged `steer_frame_refused {jobId,
|
|
43
|
+
* id}`, and is never cut. The venue's exec refuses a stdin past its limit by failing the whole run (measured).
|
|
44
|
+
* - NO FILE TO UNLINK. A message withdrawn after its frame was written is still handed over by the runner; its
|
|
45
|
+
* verified ack then moves it to `delivered`, as for the mount's best-effort `written` withdrawal.
|
|
46
|
+
* - BACKPRESSURE. A write the pipe did not take at once is waited out (`drain`), so the relay never buffers more than
|
|
47
|
+
* one frame ahead of the pipe; the wait ends early at the stop, a close or an error.
|
|
48
|
+
* - THE PIPE'S END. The stop closes stdin (`end`), which the runner reads as EOF, no more messages; an attempt that
|
|
49
|
+
* ended first has closed it already. A write error (EPIPE: the exec gone) is caught on the stream and logged once
|
|
50
|
+
* as the step `stdin`, never thrown: nothing a dead pipe does may take the worker down.
|
|
51
|
+
*
|
|
52
|
+
* Never throws; `start` and `stop` resolve whatever Valkey or the disk does. Logs carry the job id, message ids, step
|
|
53
|
+
* names, codes and counts, never a message's text.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
export const RELAY_POLL_MS = 2_000;
|
|
57
|
+
|
|
58
|
+
// The reset of a CLOSED inbox for a new run under the same run id. KEYS: list, state, fallback, closed. One script, so
|
|
59
|
+
// it is all or nothing, and it acts only while `closed` exists: while it does every push is refused (`closed`), so no
|
|
60
|
+
// message accepted for the new run can be in what it deletes. An open inbox (a #645 recovery, an InfraRetry) is left
|
|
61
|
+
// exactly as it is.
|
|
62
|
+
const RESET_SCRIPT = `if redis.call('EXISTS', KEYS[4]) == 0 then return 0 end
|
|
63
|
+
redis.call('DEL', KEYS[1], KEYS[2], KEYS[3], KEYS[4])
|
|
64
|
+
return 1`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reset a closed inbox (review finding F1). A console retry of a failed job keeps its job id, so its run id, and found
|
|
68
|
+
* the previous run's CLOSED inbox: every push answered `closed` for an hour, and the rebaseline moved that run's
|
|
69
|
+
* `delivered` messages back to `written` and handed them to a run that was never sent them. The previous run's outcome
|
|
70
|
+
* is already recorded (the close happens before the record write), so its messages are not needed any more.
|
|
71
|
+
* `{ reset: boolean }` or `{ error }`; never throws.
|
|
72
|
+
*/
|
|
73
|
+
export async function resetClosedInbox({ redis, runId, inbox = inboxModule } = {}) {
|
|
74
|
+
try {
|
|
75
|
+
const k = inbox.inboxKeys(runId);
|
|
76
|
+
const r = await redis.eval(RESET_SCRIPT, 4, k.list, k.state, k.fallback, k.closed);
|
|
77
|
+
return { reset: Number(r) === 1 };
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return { error: codeOf(e) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const defaultFs = { writeFileSync, renameSync, unlinkSync };
|
|
84
|
+
const codeOf = (e) => (typeof e?.code === "string" && e.code !== "" ? e.code : typeof e?.error === "string" ? e.error : "error");
|
|
85
|
+
|
|
86
|
+
/** The inbox file's name for the entry at list position `index` (0-based): the runner reads them in name order. */
|
|
87
|
+
export function inboxFileName(index, id) {
|
|
88
|
+
return `${String(index + 1).padStart(6, "0")}-${id}.json`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The target pointer, the framing ref and the forge word for a job (job data as queued): a forge job's target, or none
|
|
93
|
+
* for a local or cron job, which only the operator surfaces can reach. Never throws.
|
|
94
|
+
*/
|
|
95
|
+
export function steerTargetOf(job, { steerTargetKey = inboxModule.steerTargetKey } = {}) {
|
|
96
|
+
const kind = typeof job?.kind === "string" ? job.kind : null;
|
|
97
|
+
const target = job?.target !== null && typeof job?.target === "object" ? job.target : null;
|
|
98
|
+
if (kind === null || kind === "local" || target === null) return { targetKey: null, ref: null, forge: null };
|
|
99
|
+
const n = typeof target.number === "number" ? target.number : typeof target.number === "string" && /^[1-9][0-9]{0,15}$/.test(target.number) ? Number(target.number) : null;
|
|
100
|
+
const isPr = target.type === "pull_request";
|
|
101
|
+
const targetKey = n !== null && typeof job.repo === "string" ? steerTargetKey({ kind, repo: job.repo, targetType: isPr ? "pull_request" : "issue", number: n }) : null;
|
|
102
|
+
let ref = null;
|
|
103
|
+
if (Number.isSafeInteger(n) && n > 0) {
|
|
104
|
+
if (kind === "gitlab") ref = isPr ? `merge request !${n}` : `issue #${n}`;
|
|
105
|
+
else if (kind === "azure") ref = isPr ? `pull request !${n}` : `work item #${n}`;
|
|
106
|
+
else ref = isPr ? `PR #${n}` : `issue #${n}`;
|
|
107
|
+
}
|
|
108
|
+
return { targetKey, ref, forge: /^[a-z]{1,16}$/.test(kind) ? kind : null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `makeInboxRelay({ redis, runId, jobId, inboxDir, targetKey, host, ref, forge, now, log, pollMs, fs, frames })` ->
|
|
113
|
+
* `{ start(), afterBaseline(prior), delivered(id), tick(), stop(), counts(), attach(stdin, { written }) }`.
|
|
114
|
+
* `frames` (issue #686): `{ exitKey, macRunId, budget? }` selects the stdin frame transport (the header); absent, the
|
|
115
|
+
* file transport, byte-identical to before. `macRunId` is the container's PI_JOB_ID, which the runner MACs a frame by.
|
|
116
|
+
*/
|
|
117
|
+
export function makeInboxRelay({ redis, runId, jobId = runId, inboxDir, targetKey = null, host, ref = null, forge = null, now = Date.now, log = () => {}, pollMs = RELAY_POLL_MS, fs = defaultFs, inbox = inboxModule, frames = null, onDelivered = null } = {}) {
|
|
118
|
+
const framed = frames !== null && typeof frames === "object" && typeof frames.exitKey === "string" && frames.exitKey !== "" && typeof frames.macRunId === "string" && frames.macRunId !== "";
|
|
119
|
+
const budget = framed && Number.isSafeInteger(frames.budget) && frames.budget > 0 ? frames.budget : STEER_STDIN_BUDGET_BYTES;
|
|
120
|
+
// The frame transport's own state: the pipe, whether it can still take a write, the bytes written (the key frame's
|
|
121
|
+
// included, from `attach`), the next frame's position (the key frame is 1), and the writes waiting on `drain`.
|
|
122
|
+
let sink = null;
|
|
123
|
+
let sinkDead = false;
|
|
124
|
+
let sinkEnded = false;
|
|
125
|
+
let streamBytes = 0;
|
|
126
|
+
let nextSeq = 2;
|
|
127
|
+
const drainWaiters = new Set();
|
|
128
|
+
const logId = jobId === null || jobId === undefined ? null : sanitizeJobId(jobId);
|
|
129
|
+
let timer = null;
|
|
130
|
+
let started = false;
|
|
131
|
+
let stopped = false;
|
|
132
|
+
let baselined = false;
|
|
133
|
+
let rebaseline = null; // the prior ids, until the first tick after the baseline has applied them
|
|
134
|
+
let priorAcked = new Set(); // ids whose prior transcript line carries an ack verified under this attempt's key (F5)
|
|
135
|
+
let closedSaid = false;
|
|
136
|
+
let ready = false; // `start` has reset and registered (L6)
|
|
137
|
+
let running = null;
|
|
138
|
+
const onDisk = new Map(); // id -> file path this attempt placed and has not unlinked
|
|
139
|
+
const counts = { written: 0, rewritten: 0, unlinked: 0, refused: 0, rebaselined: 0, delivered: 0, faults: 0 };
|
|
140
|
+
const said = new Set();
|
|
141
|
+
|
|
142
|
+
const fault = (step, code) => {
|
|
143
|
+
counts.faults += 1;
|
|
144
|
+
const key = `${step}:${code}`;
|
|
145
|
+
if (said.has(key)) return;
|
|
146
|
+
said.add(key);
|
|
147
|
+
log("steer_relay_fault", { jobId: logId, step, code });
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const unlinkQuiet = (path) => {
|
|
151
|
+
try {
|
|
152
|
+
fs.unlinkSync(path);
|
|
153
|
+
return true;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
if (e?.code !== "ENOENT") fault("unlink", codeOf(e));
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const unlinkId = (id) => {
|
|
161
|
+
const path = onDisk.get(id);
|
|
162
|
+
if (path === undefined) return;
|
|
163
|
+
onDisk.delete(id);
|
|
164
|
+
// A frame has no file: what was written is in the pipe, and only the runner's ack says what became of it.
|
|
165
|
+
if (framed) return;
|
|
166
|
+
if (unlinkQuiet(path)) counts.unlinked += 1;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** Write one frame's bytes, waiting out backpressure. True when the pipe took them (buffered counts); never rejects. */
|
|
170
|
+
const writeBytes = (bytes) =>
|
|
171
|
+
new Promise((resolve) => {
|
|
172
|
+
if (sink === null || sinkDead || sinkEnded) {
|
|
173
|
+
resolve(false);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
let more;
|
|
177
|
+
try {
|
|
178
|
+
more = sink.write(bytes);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
sinkDead = true;
|
|
181
|
+
fault("stdin", codeOf(e));
|
|
182
|
+
resolve(false);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (more !== false) {
|
|
186
|
+
resolve(true);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// Accepted into the stream's buffer, so `true` whatever ends the wait: the drain, the stop, a close or an error.
|
|
190
|
+
let done = false;
|
|
191
|
+
const settle = () => {
|
|
192
|
+
if (done) return;
|
|
193
|
+
done = true;
|
|
194
|
+
drainWaiters.delete(settle);
|
|
195
|
+
try {
|
|
196
|
+
sink?.off?.("drain", settle);
|
|
197
|
+
sink?.off?.("close", settle);
|
|
198
|
+
} catch {}
|
|
199
|
+
resolve(true);
|
|
200
|
+
};
|
|
201
|
+
drainWaiters.add(settle);
|
|
202
|
+
try {
|
|
203
|
+
sink.once("drain", settle);
|
|
204
|
+
sink.once("close", settle);
|
|
205
|
+
} catch {
|
|
206
|
+
settle();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/** The frame transport's `place`: one waiting message as a frame on the pipe, under the budget, marked first. */
|
|
211
|
+
const writeFrame = async (entry, state) => {
|
|
212
|
+
if (sink === null || sinkDead || sinkEnded) return; // not attached yet, or the pipe is gone: it stays waiting
|
|
213
|
+
const body = steerFileJson({ entry, ref, forge });
|
|
214
|
+
const frame = body === null ? null : steerFrame({ exitKey: frames.exitKey, runId: frames.macRunId, seq: nextSeq, body });
|
|
215
|
+
if (frame === null || streamBytes + frame.length > budget) {
|
|
216
|
+
// Unframable (a defect or a hand-written entry), or past what the venue's stdin holds: answered as late, never
|
|
217
|
+
// cut and never written as something the person did not send.
|
|
218
|
+
counts.refused += 1;
|
|
219
|
+
log("steer_frame_refused", { jobId: logId, id: entry.id });
|
|
220
|
+
const m = await inbox.markState({ redis, runId, id: entry.id, from: [state], to: "late" });
|
|
221
|
+
if (m?.error) fault("mark-late", codeOf(m));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (state === "queued") {
|
|
225
|
+
const m = await inbox.markState({ redis, runId, id: entry.id, from: ["queued"], to: "written" });
|
|
226
|
+
if (m?.error || m?.ok !== true) {
|
|
227
|
+
// Withdrawn (or otherwise moved) in the meantime: the frame is never written.
|
|
228
|
+
if (m?.error) fault("mark-written", codeOf(m));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
nextSeq += 1;
|
|
233
|
+
streamBytes += frame.length;
|
|
234
|
+
onDisk.set(entry.id, nextSeq - 1);
|
|
235
|
+
if (!(await writeBytes(frame))) return;
|
|
236
|
+
if (state === "queued") counts.written += 1;
|
|
237
|
+
else counts.rewritten += 1;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** Close the pipe once (the runner reads EOF: no more messages), releasing any write waiting on `drain` first. */
|
|
241
|
+
const endSink = () => {
|
|
242
|
+
for (const settle of [...drainWaiters]) settle();
|
|
243
|
+
if (sink === null || sinkEnded) return;
|
|
244
|
+
sinkEnded = true;
|
|
245
|
+
try {
|
|
246
|
+
sink.end();
|
|
247
|
+
} catch (e) {
|
|
248
|
+
fault("stdin", codeOf(e));
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** Place one waiting message's file. `state` is what the inbox said: `queued` is marked first, `written` re-placed. */
|
|
253
|
+
const place = async (entry, index, state) => {
|
|
254
|
+
if (framed) return writeFrame(entry, state);
|
|
255
|
+
const body = steerFileJson({ entry, ref, forge });
|
|
256
|
+
if (body === null) {
|
|
257
|
+
// The inbox refuses at push what could not be framed, so this is a defect or a hand-written entry: the
|
|
258
|
+
// message is answered as late (the operator sees it), never written as something the person did not send.
|
|
259
|
+
counts.refused += 1;
|
|
260
|
+
log("steer_frame_refused", { jobId: logId, id: entry.id });
|
|
261
|
+
const m = await inbox.markState({ redis, runId, id: entry.id, from: [state], to: "late" });
|
|
262
|
+
if (m?.error) fault("mark-late", codeOf(m));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const tmp = join(inboxDir, `.${entry.id}.tmp`);
|
|
266
|
+
const final = join(inboxDir, inboxFileName(index, entry.id));
|
|
267
|
+
try {
|
|
268
|
+
try {
|
|
269
|
+
fs.unlinkSync(tmp);
|
|
270
|
+
} catch {}
|
|
271
|
+
fs.writeFileSync(tmp, body, { mode: 0o444, flag: "wx" });
|
|
272
|
+
} catch (e) {
|
|
273
|
+
fault("write", codeOf(e));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (state === "queued") {
|
|
277
|
+
const m = await inbox.markState({ redis, runId, id: entry.id, from: ["queued"], to: "written" });
|
|
278
|
+
if (m?.error || m?.ok !== true) {
|
|
279
|
+
// Withdrawn (or otherwise moved) in the meantime: the file never becomes visible.
|
|
280
|
+
unlinkQuiet(tmp);
|
|
281
|
+
if (m?.error) fault("mark-written", codeOf(m));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
fs.renameSync(tmp, final);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
unlinkQuiet(tmp);
|
|
289
|
+
fault("rename", codeOf(e));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
onDisk.set(entry.id, final);
|
|
293
|
+
if (state === "queued") counts.written += 1;
|
|
294
|
+
else counts.rewritten += 1;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const pass = async () => {
|
|
298
|
+
// Review round 1, L6: nothing before `start` has reset a closed inbox and registered the run. The stdin transport
|
|
299
|
+
// releases the baseline at the spawn, which can come before `start`'s reset resolves: a pass then would read, and
|
|
300
|
+
// write into, the previous run's closed inbox. `start` runs the pass this skipped once it is ready.
|
|
301
|
+
if (!ready) return;
|
|
302
|
+
const r = await inbox.refreshLive({ redis, runId, targetKey, host, now });
|
|
303
|
+
if (r?.error) fault("refresh", codeOf(r));
|
|
304
|
+
if (!baselined || stopped) return;
|
|
305
|
+
const read = await inbox.readInbox({ redis, runId });
|
|
306
|
+
if (read?.error) {
|
|
307
|
+
fault("read", codeOf(read));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// A CLOSED inbox is never written into and never re-baselined (F1): its run has ended, and only `start`'s reset may
|
|
311
|
+
// reopen it for a new run. Reached when that reset failed, or when something closed the inbox under a live run.
|
|
312
|
+
if (read.closed === true) {
|
|
313
|
+
if (!closedSaid) {
|
|
314
|
+
closedSaid = true;
|
|
315
|
+
log("steer_inbox_closed", { jobId: logId });
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const state = { ...read.state };
|
|
320
|
+
if (rebaseline !== null) {
|
|
321
|
+
// A message still waiting whose ack is already in the staged transcript, VERIFIED (F5): it was delivered, the
|
|
322
|
+
// worker just never marked it (a crash between pi writing the line and the mark). Marked, never re-written.
|
|
323
|
+
for (const id of priorAcked) {
|
|
324
|
+
if (state[id] !== "queued" && state[id] !== "written") continue;
|
|
325
|
+
const m = await inbox.markState({ redis, runId, id, from: ["queued", "written"], to: "delivered" });
|
|
326
|
+
if (m?.error) {
|
|
327
|
+
fault("rebaseline", codeOf(m));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (m?.ok === true) {
|
|
331
|
+
state[id] = "delivered";
|
|
332
|
+
counts.delivered += 1;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const prior = rebaseline;
|
|
336
|
+
for (const [id, st] of Object.entries(state)) {
|
|
337
|
+
if (st !== "delivered" || prior.has(id)) continue;
|
|
338
|
+
const m = await inbox.markState({ redis, runId, id, from: ["delivered"], to: "written" });
|
|
339
|
+
if (m?.error) {
|
|
340
|
+
fault("rebaseline", codeOf(m));
|
|
341
|
+
return; // tried again on the next tick, before anything is written
|
|
342
|
+
}
|
|
343
|
+
if (m?.ok === true) {
|
|
344
|
+
state[id] = "written";
|
|
345
|
+
counts.rebaselined += 1;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
rebaseline = null;
|
|
349
|
+
}
|
|
350
|
+
for (let i = 0; i < read.entries.length; i++) {
|
|
351
|
+
if (stopped) return;
|
|
352
|
+
const entry = read.entries[i];
|
|
353
|
+
const st = state[entry.id];
|
|
354
|
+
if (st === "withdrawn" || st === "delivered" || st === "late") {
|
|
355
|
+
unlinkId(entry.id);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if ((st === "queued" || st === "written") && !onDisk.has(entry.id)) await place(entry, i, st);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const tick = () => {
|
|
363
|
+
if (running || stopped) return running ?? Promise.resolve();
|
|
364
|
+
running = (async () => {
|
|
365
|
+
try {
|
|
366
|
+
await pass();
|
|
367
|
+
} catch (e) {
|
|
368
|
+
fault("tick", codeOf(e));
|
|
369
|
+
}
|
|
370
|
+
})().finally(() => {
|
|
371
|
+
running = null;
|
|
372
|
+
});
|
|
373
|
+
return running;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
/** Register the run as live and begin the poll. Idempotent; never rejects. */
|
|
378
|
+
async start() {
|
|
379
|
+
if (started || stopped) return;
|
|
380
|
+
started = true;
|
|
381
|
+
// F1: a closed inbox under this run id belongs to a run that already ended (a console retry keeps the job id).
|
|
382
|
+
// Reset BEFORE registering, so no push can be accepted into the old run's keys.
|
|
383
|
+
const reset = await (typeof inbox.resetClosedInbox === "function" ? inbox.resetClosedInbox : resetClosedInbox)({ redis, runId, inbox });
|
|
384
|
+
if (reset?.error) fault("reset", codeOf(reset));
|
|
385
|
+
else if (reset?.reset === true) log("steer_inbox_reset", { jobId: logId });
|
|
386
|
+
try {
|
|
387
|
+
const r = await inbox.registerLive({ redis, runId, targetKey, host, now });
|
|
388
|
+
if (r?.error) fault("register", codeOf(r));
|
|
389
|
+
} catch (e) {
|
|
390
|
+
fault("register", codeOf(e));
|
|
391
|
+
}
|
|
392
|
+
if (stopped) return;
|
|
393
|
+
ready = true;
|
|
394
|
+
timer = setInterval(() => void tick(), pollMs);
|
|
395
|
+
timer.unref?.();
|
|
396
|
+
// A baseline that came first (the stdin transport's, at the spawn) is acted on now rather than a poll later.
|
|
397
|
+
if (baselined) void tick();
|
|
398
|
+
},
|
|
399
|
+
/** The tail's first pass is done: re-baseline against `prior` (ids in the staged transcript) and start writing. */
|
|
400
|
+
afterBaseline(prior = new Set(), verified = new Set()) {
|
|
401
|
+
if (baselined || stopped) return;
|
|
402
|
+
baselined = true;
|
|
403
|
+
rebaseline = prior instanceof Set ? prior : new Set(prior ?? []);
|
|
404
|
+
priorAcked = verified instanceof Set ? verified : new Set(verified ?? []);
|
|
405
|
+
void tick();
|
|
406
|
+
},
|
|
407
|
+
/** A verified ack (steer-ack.mjs): mark the message delivered and unlink its file. Never rejects. */
|
|
408
|
+
async delivered(id) {
|
|
409
|
+
// `withdrawn` too: a written message is withdrawn best effort, and a verified ack is proof the agent got it.
|
|
410
|
+
const m = await inbox.markState({ redis, runId, id, from: ["written", "queued", "withdrawn"], to: "delivered" });
|
|
411
|
+
if (m?.error) fault("mark-delivered", codeOf(m));
|
|
412
|
+
else if (m?.ok === true) {
|
|
413
|
+
counts.delivered += 1;
|
|
414
|
+
try { await onDelivered?.({ runId, id, at: now() }); } catch { fault("touch-delivered", "touch-index-unavailable"); }
|
|
415
|
+
}
|
|
416
|
+
unlinkId(id);
|
|
417
|
+
},
|
|
418
|
+
tick,
|
|
419
|
+
/**
|
|
420
|
+
* Stop for good: the timer, a pass in flight, then HOLD the liveness keys (review round 1, F3; steer-inbox.mjs
|
|
421
|
+
* `holdLive`). Not an unregister: until the settle closes the inbox (or the next attempt's relay re-registers the
|
|
422
|
+
* run), a comment must still land in it rather than fall through to an enqueue the run's semantic window swallows.
|
|
423
|
+
* What lands after this point is written by the next attempt or settles as `late`. Not a close either.
|
|
424
|
+
*/
|
|
425
|
+
async stop() {
|
|
426
|
+
if (stopped) return { ...counts };
|
|
427
|
+
stopped = true;
|
|
428
|
+
if (timer) clearInterval(timer);
|
|
429
|
+
timer = null;
|
|
430
|
+
// A write waiting on `drain` would hold the pass in flight forever on a pipe nobody reads: released first.
|
|
431
|
+
for (const settle of [...drainWaiters]) settle();
|
|
432
|
+
try {
|
|
433
|
+
await running;
|
|
434
|
+
} catch {}
|
|
435
|
+
endSink();
|
|
436
|
+
if (started) {
|
|
437
|
+
try {
|
|
438
|
+
const r = await inbox.holdLive({ redis, runId, targetKey, host, now });
|
|
439
|
+
if (r?.error) fault("hold", codeOf(r));
|
|
440
|
+
} catch (e) {
|
|
441
|
+
fault("hold", codeOf(e));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return { ...counts };
|
|
445
|
+
},
|
|
446
|
+
counts: () => ({ ...counts }),
|
|
447
|
+
/**
|
|
448
|
+
* End the frame transport's pipe now, the relay otherwise running on (its marks and acks still land): the runner's
|
|
449
|
+
* exit line is out, so nothing more is read from the pipe, and a CLI that waited for EOF before it exits must not
|
|
450
|
+
* wait on this relay (review round 1, M1). Idempotent; a no-op on the file transport.
|
|
451
|
+
*/
|
|
452
|
+
closeStdin() {
|
|
453
|
+
if (framed) endSink();
|
|
454
|
+
},
|
|
455
|
+
/**
|
|
456
|
+
* The frame transport's pipe (issue #686): the exec's stdin, after the key frame (`written`, its bytes, counted
|
|
457
|
+
* against the budget) went down it. Errors on it are caught here for good, so an EPIPE never reaches the process.
|
|
458
|
+
* A no-op on the file transport or once stopped (then the pipe is closed at once).
|
|
459
|
+
*/
|
|
460
|
+
attach(stdin, { written = 0 } = {}) {
|
|
461
|
+
if (!framed || sink !== null || stdin === null || typeof stdin !== "object" || typeof stdin.write !== "function") return;
|
|
462
|
+
sink = stdin;
|
|
463
|
+
streamBytes = Number.isSafeInteger(written) && written > 0 ? written : 0;
|
|
464
|
+
try {
|
|
465
|
+
stdin.on?.("error", (e) => {
|
|
466
|
+
sinkDead = true;
|
|
467
|
+
fault("stdin", codeOf(e));
|
|
468
|
+
for (const settle of [...drainWaiters]) settle();
|
|
469
|
+
});
|
|
470
|
+
stdin.on?.("close", () => {
|
|
471
|
+
sinkDead = true;
|
|
472
|
+
for (const settle of [...drainWaiters]) settle();
|
|
473
|
+
});
|
|
474
|
+
} catch {}
|
|
475
|
+
if (stopped) {
|
|
476
|
+
endSink();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (baselined) void tick();
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
}
|