@edgehero/pi-dispatch 4.3.0 → 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 +94 -4
- 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 +199 -30
- 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 +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- 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 +740 -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 +1554 -167
- 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 +296 -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 +782 -40
- 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,751 @@
|
|
|
1
|
+
import { clip, LIVE_OUTCOMES, LIVE_TOOL_RE, LIVE_TTL_ENDED_MS, LIVE_TTL_LIVE_MS } from "./live-transcript.mjs";
|
|
2
|
+
import { escapeControls } from "./projects.mjs";
|
|
3
|
+
import { PROGRESS_RECORD_TOOLS, sanitizeJobId } from "./run-history.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Run progress (issue #644, `DES-RUN-PROGRESS`, `INT-RUN-PROGRESS-KEY`): where a running job is, as a small summary
|
|
7
|
+
* the console, the `/dispatch` panel and an opt-in forge status comment read, beside the live transcript (#643).
|
|
8
|
+
*
|
|
9
|
+
* WHERE IT COMES FROM. The same pi session file the live transcript tails (live-transcript.mjs hands every parsed line
|
|
10
|
+
* to `consume`). pi 1.2.0 saves each message at `message_end`: an assistant message is one turn and carries its tool
|
|
11
|
+
* calls (`{type: "toolCall", id, name, arguments}`), and each tool's result is a `toolResult` message with
|
|
12
|
+
* `toolCallId`, `toolName`, `isError` and, when the tool actually ran, `durationMs` (pi-agent-core dist/agent-loop.js
|
|
13
|
+
* `createToolResultMessage`). A call with no result yet is running.
|
|
14
|
+
*
|
|
15
|
+
* WHY `durationMs` AND NEVER TIMESTAMPS. In a parallel batch pi saves every result together once the whole batch is
|
|
16
|
+
* done, so the gap between a call and its result is the batch's time, not the tool's. A result that failed before it
|
|
17
|
+
* ran (an unknown tool, a validation failure) has no `durationMs`, and its duration is null, not a guess.
|
|
18
|
+
*
|
|
19
|
+
* ADVISORY AND FORGEABLE. `/session` is mounted read-write into the job, so the agent can write any line it likes into
|
|
20
|
+
* the file this reads. Progress is display only: it never feeds classification, the budget, the exit outcome, a retry
|
|
21
|
+
* or any other decision, and every field is bounded so a forged file cannot grow it. Model text (a phase name, a note)
|
|
22
|
+
* is redacted and escaped and goes only to the key, where readers show it to operators alone; the record and the forge
|
|
23
|
+
* comment carry integers and closed words only.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The key's prefix; the key is this plus the sanitized job id, as `run:live:` is. */
|
|
27
|
+
export const PROGRESS_KEY_PREFIX = "run:progress:";
|
|
28
|
+
/** One run's progress key. */
|
|
29
|
+
export const progressKeyFor = (jobId) => `${PROGRESS_KEY_PREFIX}${sanitizeJobId(jobId)}`;
|
|
30
|
+
/** The serialized snapshot's ceiling, in UTF-8 bytes. */
|
|
31
|
+
export const PROGRESS_MAX_BYTES = 16 * 1024;
|
|
32
|
+
/** Open steps held at once; past it the oldest open step is dropped and counted as an anomaly. */
|
|
33
|
+
export const PROGRESS_OPEN_MAX = 256;
|
|
34
|
+
/** The ring of the most recent closed steps. */
|
|
35
|
+
export const PROGRESS_RECENT_MAX = 20;
|
|
36
|
+
/** How many open steps a snapshot lists, newest last. */
|
|
37
|
+
export const PROGRESS_RUNNING_MAX = 8;
|
|
38
|
+
/** Phase names kept, first-seen order. */
|
|
39
|
+
export const PROGRESS_PHASES_MAX = 32;
|
|
40
|
+
/** A phase name's and a note's length, in code points, after redaction and escaping. */
|
|
41
|
+
export const PROGRESS_PHASE_MAX = 64;
|
|
42
|
+
export const PROGRESS_NOTE_MAX = 200;
|
|
43
|
+
/** The largest `done` or `total` accepted. */
|
|
44
|
+
export const PROGRESS_COUNT_MAX = 1_000_000;
|
|
45
|
+
/** A forge status comment's id as the key carries it (an Azure id is `<thread>:<comment>`). */
|
|
46
|
+
export const COMMENT_ID_RE = /^[A-Za-z0-9:_-]{1,200}$/;
|
|
47
|
+
/** The job tool's name (image/runner/src/report-progress.mjs). */
|
|
48
|
+
export const REPORT_PROGRESS_TOOL = "report_progress";
|
|
49
|
+
/**
|
|
50
|
+
* The longest tool call id kept, in UTF-16 units. pi's ids are short (a provider's call id, tens of characters), and an
|
|
51
|
+
* id is a Map key held for as long as its step is open: a forged line of just under a megabyte holding one id would
|
|
52
|
+
* otherwise pin that megabyte, and `PROGRESS_OPEN_MAX` of them a quarter of a gigabyte per job. A longer id is an anomaly.
|
|
53
|
+
*/
|
|
54
|
+
export const PROGRESS_ID_MAX = 128;
|
|
55
|
+
/**
|
|
56
|
+
* The longest the key goes unwritten while the attempt has not ended. Each write stamps `updatedAt` with its own time, so
|
|
57
|
+
* a reader can tell a live attempt that is merely quiet (a long tool, a slow model) from a worker that died without its
|
|
58
|
+
* final write, whose key would otherwise read as running for its whole 7-day TTL.
|
|
59
|
+
*/
|
|
60
|
+
export const PROGRESS_HEARTBEAT_MS = 30_000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The shortest TTL a write that has not ended will set. The live TTL is what is left of 7 days from the writer's start,
|
|
64
|
+
* so it shrinks with every write; once less than this is left the heartbeat stops for good rather than set a key that is
|
|
65
|
+
* gone before a reader can poll it (or, as `Math.max(1, ...)` once did, set it with PX 1 forever).
|
|
66
|
+
*/
|
|
67
|
+
const LIVE_TTL_FLOOR_MS = 1_000;
|
|
68
|
+
/** The least time between two writes of the key. */
|
|
69
|
+
const WRITE_INTERVAL_MS = 1_000;
|
|
70
|
+
/** Each Valkey call's bound, as the stream's. */
|
|
71
|
+
const OP_TIMEOUT_MS = 1_000;
|
|
72
|
+
/** The whole of the sink's `finish`: the final write (one bound) and the commenter's final edit, run side by side. */
|
|
73
|
+
const SINK_FINISH_TIMEOUT_MS = 5_000;
|
|
74
|
+
/** The commenter's own bound on its final edit. */
|
|
75
|
+
const COMMENT_FINAL_TIMEOUT_MS = 4_000;
|
|
76
|
+
/** How long the sink's `finish` waits for a status comment post still in flight, inside `SINK_FINISH_TIMEOUT_MS`. */
|
|
77
|
+
const COMMENT_POST_WAIT_MS = 2_000;
|
|
78
|
+
|
|
79
|
+
/** A tool call id this module keeps: a non-empty string of at most `PROGRESS_ID_MAX` units. */
|
|
80
|
+
const isCallId = (id) => typeof id === "string" && id !== "" && id.length <= PROGRESS_ID_MAX;
|
|
81
|
+
/**
|
|
82
|
+
* An assistant message pi never runs the tools of: a provider error or an abort ends the turn before any tool executes
|
|
83
|
+
* (pi-agent-core dist/agent-loop.js returns on `stopReason` "error" or "aborted" ahead of `executeToolCalls`), and the
|
|
84
|
+
* half-streamed tool calls it may carry stay in the saved message. An auto-retry then asks again with new ids.
|
|
85
|
+
*/
|
|
86
|
+
const runsNoTools = (message) => message.stopReason === "error" || message.stopReason === "aborted";
|
|
87
|
+
/**
|
|
88
|
+
* Whether an assistant message is a turn, as the runner counts turns (image/runner/src/turn-budget.mjs): a turn that
|
|
89
|
+
* ended in a provider error is retried, and the runner counts the error and its retry once, so the error is not a turn
|
|
90
|
+
* here. An aborted message is one (the runner counts it, and pi does not retry an abort).
|
|
91
|
+
*/
|
|
92
|
+
const isTurn = (message) => message.stopReason !== "error";
|
|
93
|
+
|
|
94
|
+
const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
95
|
+
|
|
96
|
+
function bounded(promise, ms) {
|
|
97
|
+
let timer;
|
|
98
|
+
return Promise.race([
|
|
99
|
+
Promise.resolve(promise),
|
|
100
|
+
new Promise((_, reject) => {
|
|
101
|
+
timer = setTimeout(() => reject(new Error("run progress timeout")), ms);
|
|
102
|
+
}),
|
|
103
|
+
]).finally(() => clearTimeout(timer));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The marks a recovered run's runner puts on the results it synthesises for orphaned calls (issue #645). */
|
|
107
|
+
export const RECOVERY_MARKS = Object.freeze(["replayed", "interrupted", "not-started"]);
|
|
108
|
+
|
|
109
|
+
/** A toolResult's `details.piDispatchRecovery` when it is one of RECOVERY_MARKS, else null. */
|
|
110
|
+
function recoveryMarkOf(message) {
|
|
111
|
+
const d = message?.details;
|
|
112
|
+
if (d === null || typeof d !== "object" || Array.isArray(d)) return null;
|
|
113
|
+
return RECOVERY_MARKS.includes(d.piDispatchRecovery) ? d.piDispatchRecovery : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The record's bucket for a tool name: the literal from the closed list it equals, else `other`. Never the input. */
|
|
117
|
+
export function recordToolOf(name) {
|
|
118
|
+
for (const tool of PROGRESS_RECORD_TOOLS) if (name === tool) return tool;
|
|
119
|
+
return "other";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `makeProgress({ jobId, phasesEnabled, redact, now })` returns `{ consume(parsed, ts, flags), reset(), snapshot(),
|
|
124
|
+
* finalSnapshot(outcome), summary(), version(), stats() }`.
|
|
125
|
+
*
|
|
126
|
+
* - `consume(parsed, ts, { prior, replay })` takes one parsed transcript line and the time the transcript mapping
|
|
127
|
+
* gave it. `prior` marks a line that ended at or before the resume baseline (the staged file's size when the
|
|
128
|
+
* tailer started): it seeds `prior: { turns, steps }` and the phase list and current phase, never this attempt's
|
|
129
|
+
* counters. `replay` marks a line re-read after a reset (below).
|
|
130
|
+
* - `snapshot()` is the key's value (`INT-RUN-PROGRESS-KEY`) without `commentId`, which the writer adds.
|
|
131
|
+
* - `finalSnapshot(outcome)` ends the attempt once: every open step becomes `interrupted`, `ended` is true and
|
|
132
|
+
* `outcome` the `LIVE_OUTCOMES` enum. Nothing is consumed after it.
|
|
133
|
+
* - `summary()` is the run record's `progress` (integers and closed keys only, zeros left out). `toolMs` is the SUM of
|
|
134
|
+
* the closed steps' durations, not wall time: a parallel batch of three 60 s tools adds 180 s. `longestStepMs` is
|
|
135
|
+
* the longest closed step, `longestInterruptedMs` the longest of the steps still open at the end (from the call's
|
|
136
|
+
* time to the end), which is where a tool hung until the job timeout shows. `recoveryInterrupted` and
|
|
137
|
+
* `recoveryNotStarted` (issue #662) count this attempt's recovery marks of those two words; they are the processor's
|
|
138
|
+
* fallback for a recovery's counts, and `recordedProgress` keeps them out of the record's `progress`.
|
|
139
|
+
*
|
|
140
|
+
* THE RESET RULE. A reset means the file was rewritten and the tailer reads it again from 0. The counts this attempt
|
|
141
|
+
* already has are kept, and the rewritten file's bytes up to its size at the reset are taken as already seen: a
|
|
142
|
+
* `replay` line counts nothing (no turn, no step, no prior, no anomaly, no phase), with one exception, a result that
|
|
143
|
+
* closes a step still open, since that step is this attempt's and its result only arrived inside the rewrite. Lines
|
|
144
|
+
* after those bytes are counted as usual. A rewrite can therefore lose a step whose call and result were both written
|
|
145
|
+
* and rewritten between two polls; pi rewrites only on a format migration or a branch, neither of which a job does.
|
|
146
|
+
*
|
|
147
|
+
* MEMORY. At most `PROGRESS_OPEN_MAX` open steps (the oldest dropped and counted), a ring of `PROGRESS_RECENT_MAX`
|
|
148
|
+
* closed ones, `PROGRESS_PHASES_MAX` phase names, and as many prior `report_progress` calls as open steps, each keyed by
|
|
149
|
+
* an id of at most `PROGRESS_ID_MAX` units.
|
|
150
|
+
*/
|
|
151
|
+
export function makeProgress({ jobId: _jobId = null, phasesEnabled = false, redact = (t) => String(t ?? ""), now = () => Date.now() } = {}) {
|
|
152
|
+
const startedAt = now();
|
|
153
|
+
let updatedAt = startedAt;
|
|
154
|
+
let version = 0;
|
|
155
|
+
let turn = 0;
|
|
156
|
+
let steps = 0;
|
|
157
|
+
let failed = 0;
|
|
158
|
+
let interrupted = 0;
|
|
159
|
+
// Issue #645: orphaned tool calls a recovered run's runner answered by running them again (`piDispatchRecovery`
|
|
160
|
+
// "replayed"). Counted apart from `steps`: the call was made by the crashed attempt, and this attempt only closed it.
|
|
161
|
+
let replayed = 0;
|
|
162
|
+
// Issue #662: the same marks split by word, for the record's `recovered` counts when the runner's exit line is missing
|
|
163
|
+
// (a recovery whose own container was killed writes none). `interrupted` above merges "interrupted", "not-started" and
|
|
164
|
+
// the steps still open at the end, so it cannot give the runner's two counts back; these two count only the marks.
|
|
165
|
+
// Summary keys only: never in the snapshot (the admin's key parser refuses a field it does not name) and never in the
|
|
166
|
+
// record's `progress` (run-history.mjs `recordedProgress` keeps PROGRESS_COUNT_KEYS alone).
|
|
167
|
+
let recoveryInterrupted = 0;
|
|
168
|
+
let recoveryNotStarted = 0;
|
|
169
|
+
let toolMs = 0;
|
|
170
|
+
let longestStepMs = 0;
|
|
171
|
+
let longestInterruptedMs = 0;
|
|
172
|
+
const tools = Object.create(null);
|
|
173
|
+
const open = new Map(); // id -> { tool, bucket, since, phase }
|
|
174
|
+
const recent = [];
|
|
175
|
+
const phases = [];
|
|
176
|
+
let phase = null;
|
|
177
|
+
let note = null;
|
|
178
|
+
const prior = { turns: 0, steps: 0, seen: false };
|
|
179
|
+
const priorPhaseCalls = new Map(); // id -> validated phase arguments, prior lines only
|
|
180
|
+
let ended = false;
|
|
181
|
+
let outcome = null;
|
|
182
|
+
const counts = { anomalies: 0, evicted: 0, phaseRefused: 0, resets: 0 };
|
|
183
|
+
|
|
184
|
+
const touch = () => {
|
|
185
|
+
version += 1;
|
|
186
|
+
updatedAt = now();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** The display name: the live stream's charset, and `?` when redaction would change it. */
|
|
190
|
+
const displayTool = (name) => {
|
|
191
|
+
if (typeof name !== "string" || !LIVE_TOOL_RE.test(name)) return "?";
|
|
192
|
+
try {
|
|
193
|
+
return redact(name) === name ? name : "?";
|
|
194
|
+
} catch {
|
|
195
|
+
return "?";
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const pushRecent = (entry) => {
|
|
200
|
+
recent.push(entry);
|
|
201
|
+
while (recent.length > PROGRESS_RECENT_MAX) recent.shift();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Redacted, then escaped, then cut by code points; null when the redactor threw (never the raw text). */
|
|
205
|
+
const modelText = (text, max) => {
|
|
206
|
+
try {
|
|
207
|
+
return clip(escapeControls(String(redact(text))), max).text;
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A `report_progress` call's arguments, validated as the runner validates them (a phase of 1 to 64 code points, an
|
|
215
|
+
* optional `done` from 0 and `total` from 1, both at most `PROGRESS_COUNT_MAX` with `done <= total`, an optional note
|
|
216
|
+
* of at most 200 code points), then the text made safe. Null for anything else: a forged call is ignored whole.
|
|
217
|
+
*/
|
|
218
|
+
const phaseArgsOf = (args) => {
|
|
219
|
+
if (args === null || typeof args !== "object" || Array.isArray(args)) return null;
|
|
220
|
+
const { phase: name, done, total, note: text } = args;
|
|
221
|
+
if (typeof name !== "string") return null;
|
|
222
|
+
const points = [...name].length;
|
|
223
|
+
if (points < 1 || points > PROGRESS_PHASE_MAX || name.trim() === "") return null;
|
|
224
|
+
if (done !== undefined && !(isCount(done) && done <= PROGRESS_COUNT_MAX)) return null;
|
|
225
|
+
if (total !== undefined && !(isCount(total) && total >= 1 && total <= PROGRESS_COUNT_MAX)) return null;
|
|
226
|
+
if (done !== undefined && total !== undefined && done > total) return null;
|
|
227
|
+
if (text !== undefined && !(typeof text === "string" && [...text].length <= PROGRESS_NOTE_MAX)) return null;
|
|
228
|
+
const safeName = modelText(name, PROGRESS_PHASE_MAX);
|
|
229
|
+
if (safeName === null || safeName === "") return null;
|
|
230
|
+
const safeNote = text === undefined || text === "" ? null : modelText(text, PROGRESS_NOTE_MAX);
|
|
231
|
+
if (text !== undefined && text !== "" && safeNote === null) return null;
|
|
232
|
+
return { name: safeName, done: done ?? null, total: total ?? null, note: safeNote };
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** Applies one accepted phase report. A new name past the cap leaves the current phase as it was. */
|
|
236
|
+
const applyPhase = (p, withNote) => {
|
|
237
|
+
let index = phases.indexOf(p.name);
|
|
238
|
+
if (index === -1) {
|
|
239
|
+
if (phases.length >= PROGRESS_PHASES_MAX) {
|
|
240
|
+
counts.phaseRefused += 1;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
phases.push(p.name);
|
|
244
|
+
index = phases.length - 1;
|
|
245
|
+
}
|
|
246
|
+
phase = { index, done: p.done, total: p.total };
|
|
247
|
+
if (withNote) note = p.note;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const consumePrior = (message) => {
|
|
251
|
+
if (message.role === "assistant") {
|
|
252
|
+
if (isTurn(message)) prior.turns += 1;
|
|
253
|
+
prior.seen = true;
|
|
254
|
+
if (!phasesEnabled || !Array.isArray(message.content) || runsNoTools(message)) return;
|
|
255
|
+
for (const part of message.content) {
|
|
256
|
+
if (part?.type !== "toolCall" || part.name !== REPORT_PROGRESS_TOOL || !isCallId(part.id)) continue;
|
|
257
|
+
const args = phaseArgsOf(part.arguments);
|
|
258
|
+
if (args === null) continue;
|
|
259
|
+
priorPhaseCalls.set(part.id, args);
|
|
260
|
+
while (priorPhaseCalls.size > PROGRESS_OPEN_MAX) priorPhaseCalls.delete(priorPhaseCalls.keys().next().value);
|
|
261
|
+
}
|
|
262
|
+
} else if (message.role === "toolResult") {
|
|
263
|
+
prior.steps += 1;
|
|
264
|
+
prior.seen = true;
|
|
265
|
+
const args = isCallId(message.toolCallId) ? priorPhaseCalls.get(message.toolCallId) : undefined;
|
|
266
|
+
if (args === undefined) return;
|
|
267
|
+
priorPhaseCalls.delete(message.toolCallId);
|
|
268
|
+
if (message.isError === false) applyPhase(args, false);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const closeStep = (id, message, ts) => {
|
|
273
|
+
const step = open.get(id);
|
|
274
|
+
open.delete(id);
|
|
275
|
+
const isError = message.isError === true;
|
|
276
|
+
const durationMs = isCount(message.durationMs) ? message.durationMs : null;
|
|
277
|
+
steps += 1;
|
|
278
|
+
if (isError) failed += 1;
|
|
279
|
+
if (durationMs !== null) {
|
|
280
|
+
toolMs = Math.min(Number.MAX_SAFE_INTEGER, toolMs + durationMs);
|
|
281
|
+
if (durationMs > longestStepMs) longestStepMs = durationMs;
|
|
282
|
+
}
|
|
283
|
+
tools[step.bucket] = (tools[step.bucket] ?? 0) + 1;
|
|
284
|
+
pushRecent({ tool: step.tool, status: isError ? "error" : "ok", durationMs, at: ts });
|
|
285
|
+
// The runner validated the call: an error result means it refused it, and only `isError: false` is a report.
|
|
286
|
+
if (step.phase && message.isError === false) applyPhase(step.phase, true);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const snapshot = () => {
|
|
290
|
+
const running = [...open.values()].slice(-PROGRESS_RUNNING_MAX).map((s) => ({ tool: s.tool, since: s.since }));
|
|
291
|
+
return {
|
|
292
|
+
v: 1,
|
|
293
|
+
turn,
|
|
294
|
+
steps,
|
|
295
|
+
failed,
|
|
296
|
+
interrupted,
|
|
297
|
+
running: ended ? [] : running,
|
|
298
|
+
recent: recent.map((r) => ({ ...r })),
|
|
299
|
+
phase: phase ? { ...phase } : null,
|
|
300
|
+
phases: [...phases],
|
|
301
|
+
note,
|
|
302
|
+
prior: prior.seen ? { turns: prior.turns, steps: prior.steps } : null,
|
|
303
|
+
startedAt,
|
|
304
|
+
updatedAt,
|
|
305
|
+
ended,
|
|
306
|
+
outcome,
|
|
307
|
+
// Only when a recovered run replayed something, so every other key keeps the field set INT-RUN-PROGRESS-KEY
|
|
308
|
+
// readers already accept (the admin's parser refuses a field it does not name).
|
|
309
|
+
...(replayed > 0 ? { replayed } : {}),
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
consume(parsed, ts, { prior: isPrior = false, replay = false } = {}) {
|
|
315
|
+
if (ended || parsed?.type !== "message") return;
|
|
316
|
+
const message = parsed.message;
|
|
317
|
+
if (message === null || typeof message !== "object") return;
|
|
318
|
+
const at = isCount(ts) ? ts : now();
|
|
319
|
+
if (isPrior) {
|
|
320
|
+
consumePrior(message);
|
|
321
|
+
touch();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (message.role === "assistant") {
|
|
325
|
+
if (replay) return;
|
|
326
|
+
if (isTurn(message)) turn += 1;
|
|
327
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
328
|
+
const dead = runsNoTools(message);
|
|
329
|
+
for (const part of content) {
|
|
330
|
+
if (part?.type !== "toolCall") continue;
|
|
331
|
+
// A call pi will never run opens no step: it would read as running until the end, then as interrupted.
|
|
332
|
+
if (dead || !isCallId(part.id) || open.has(part.id)) {
|
|
333
|
+
counts.anomalies += 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const report = phasesEnabled && part.name === REPORT_PROGRESS_TOOL ? phaseArgsOf(part.arguments) : null;
|
|
337
|
+
if (phasesEnabled && part.name === REPORT_PROGRESS_TOOL && report === null) counts.phaseRefused += 1;
|
|
338
|
+
open.set(part.id, { tool: displayTool(part.name), bucket: recordToolOf(part.name), since: at, phase: report });
|
|
339
|
+
if (open.size > PROGRESS_OPEN_MAX) {
|
|
340
|
+
open.delete(open.keys().next().value);
|
|
341
|
+
counts.evicted += 1;
|
|
342
|
+
counts.anomalies += 1;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
touch();
|
|
346
|
+
} else if (message.role === "toolResult") {
|
|
347
|
+
const id = message.toolCallId;
|
|
348
|
+
// Issue #645: a recovered run's runner answers every tool call the crashed attempt left without a result, and
|
|
349
|
+
// marks the result `details.piDispatchRecovery`. Those calls were made BEFORE the crash, so they are prior lines
|
|
350
|
+
// and opened no step here: without this branch each such result would read as an anomaly and count nothing.
|
|
351
|
+
// "interrupted" (the call may have run, its outcome is unknown) and "not-started" (it never ran) both count as
|
|
352
|
+
// interrupted, the word this reducer already uses for a step whose result never came; "replayed" (run again)
|
|
353
|
+
// is its own count. The mark is read from an agent-writable file like everything here: advisory, never a
|
|
354
|
+
// decision. A step still open under the same id is closed by it, without counting a step.
|
|
355
|
+
const mark = recoveryMarkOf(message);
|
|
356
|
+
if (mark !== null) {
|
|
357
|
+
const step = isCallId(id) ? open.get(id) : undefined;
|
|
358
|
+
if (replay && step === undefined) return;
|
|
359
|
+
if (step !== undefined) open.delete(id);
|
|
360
|
+
if (mark === "replayed") replayed += 1;
|
|
361
|
+
else {
|
|
362
|
+
interrupted += 1;
|
|
363
|
+
if (mark === "interrupted") recoveryInterrupted += 1;
|
|
364
|
+
else recoveryNotStarted += 1;
|
|
365
|
+
pushRecent({ tool: step?.tool ?? displayTool(message.toolName), status: "interrupted", durationMs: null, at });
|
|
366
|
+
}
|
|
367
|
+
touch();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (!isCallId(id) || !open.has(id)) {
|
|
371
|
+
// Unknown, or a duplicate of a result already closed (a closed id is no longer open).
|
|
372
|
+
if (!replay) counts.anomalies += 1;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
closeStep(id, message, at);
|
|
376
|
+
touch();
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
reset() {
|
|
380
|
+
counts.resets += 1;
|
|
381
|
+
},
|
|
382
|
+
snapshot,
|
|
383
|
+
finalSnapshot(result) {
|
|
384
|
+
if (!ended) {
|
|
385
|
+
// Every step still open is interrupted: the container is gone and its result will never come. A recovered run's
|
|
386
|
+
// synthetic result for such a call is counted when it arrives (`recoveryMarkOf` in `consume`).
|
|
387
|
+
const at = now();
|
|
388
|
+
for (const step of open.values()) {
|
|
389
|
+
interrupted += 1;
|
|
390
|
+
pushRecent({ tool: step.tool, status: "interrupted", durationMs: null, at });
|
|
391
|
+
// How long it had been running when the attempt ended: a hung tool killed by the job timeout is
|
|
392
|
+
// often the longest step of all, and `longestStepMs` (closed steps only) cannot see it.
|
|
393
|
+
const ran = Math.trunc(at - step.since);
|
|
394
|
+
if (Number.isSafeInteger(ran) && ran > longestInterruptedMs) longestInterruptedMs = ran;
|
|
395
|
+
}
|
|
396
|
+
open.clear();
|
|
397
|
+
ended = true;
|
|
398
|
+
outcome = LIVE_OUTCOMES.includes(result) ? result : "unknown";
|
|
399
|
+
touch();
|
|
400
|
+
}
|
|
401
|
+
return snapshot();
|
|
402
|
+
},
|
|
403
|
+
summary(_outcome) {
|
|
404
|
+
const out = {};
|
|
405
|
+
const put = (key, v) => {
|
|
406
|
+
if (isCount(v) && v > 0) out[key] = v;
|
|
407
|
+
};
|
|
408
|
+
put("turns", turn);
|
|
409
|
+
put("steps", steps);
|
|
410
|
+
put("failed", failed);
|
|
411
|
+
put("interrupted", interrupted);
|
|
412
|
+
put("replayed", replayed);
|
|
413
|
+
put("toolMs", toolMs);
|
|
414
|
+
put("longestStepMs", longestStepMs);
|
|
415
|
+
put("longestInterruptedMs", longestInterruptedMs);
|
|
416
|
+
put("phasesDeclared", phases.length);
|
|
417
|
+
put("recoveryInterrupted", recoveryInterrupted);
|
|
418
|
+
put("recoveryNotStarted", recoveryNotStarted);
|
|
419
|
+
if (phases.length > 0 && phase !== null) out.lastPhaseIndex = phase.index;
|
|
420
|
+
const counted = {};
|
|
421
|
+
for (const tool of [...PROGRESS_RECORD_TOOLS, "other"]) if (tools[tool] > 0) counted[tool] = tools[tool];
|
|
422
|
+
if (Object.keys(counted).length > 0) out.tools = counted;
|
|
423
|
+
return out;
|
|
424
|
+
},
|
|
425
|
+
version: () => version,
|
|
426
|
+
stats: () => ({ open: open.size, recent: recent.length, phases: phases.length, ...counts }),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The snapshot as the key holds it: serialized, and cut until it fits `maxBytes` by dropping `recent` oldest first, then
|
|
432
|
+
* `phases` from the tail, then the note. A `phase.index` past a cut list stays as it was: readers bound it themselves.
|
|
433
|
+
*/
|
|
434
|
+
export function serializeProgress(snapshot, maxBytes = PROGRESS_MAX_BYTES) {
|
|
435
|
+
const s = { ...snapshot, recent: [...(snapshot.recent ?? [])], phases: [...(snapshot.phases ?? [])] };
|
|
436
|
+
let json = JSON.stringify(s);
|
|
437
|
+
while (Buffer.byteLength(json) > maxBytes) {
|
|
438
|
+
if (s.recent.length > 0) s.recent.shift();
|
|
439
|
+
else if (s.phases.length > 0) s.phases.pop();
|
|
440
|
+
else if (s.note !== null && s.note !== undefined) s.note = null;
|
|
441
|
+
else break;
|
|
442
|
+
json = JSON.stringify(s);
|
|
443
|
+
}
|
|
444
|
+
return json;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The progress key as it stands, for a reader with no writer of its own: `{ commentId, snapshot }` (a `commentId` that is
|
|
449
|
+
* not a valid comment id reads as null), or null when the key is absent, unreadable or Valkey does not answer within
|
|
450
|
+
* `timeoutMs`. Never throws. start.mjs reads it to make the final status edit for an attempt that registered no
|
|
451
|
+
* commenter (one refused before its container), whose comment an earlier attempt posted.
|
|
452
|
+
*/
|
|
453
|
+
export async function readProgressKey({ redis, jobId, timeoutMs = OP_TIMEOUT_MS } = {}) {
|
|
454
|
+
if (!redis || typeof redis.get !== "function" || (typeof redis.status === "string" && redis.status !== "ready")) return null;
|
|
455
|
+
try {
|
|
456
|
+
const raw = await bounded(redis.get(progressKeyFor(jobId)), timeoutMs);
|
|
457
|
+
const old = typeof raw === "string" ? JSON.parse(raw) : null;
|
|
458
|
+
if (old === null || typeof old !== "object" || Array.isArray(old)) return null;
|
|
459
|
+
return { commentId: typeof old.commentId === "string" && COMMENT_ID_RE.test(old.commentId) ? old.commentId : null, snapshot: old };
|
|
460
|
+
} catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* `makeProgressWriter({ redis, jobId, now, timeoutMs, intervalMs, log, setTimer, clearTimer, onWrite })` returns
|
|
467
|
+
* `{ init(), update(snapshotOrFn), setCommentId(id), commentId(), final(snapshot), stats() }`. Writes the key
|
|
468
|
+
* (`SET key json PX ttl`), best effort, bounded and never throwing, at most once per `intervalMs` with a trailing write
|
|
469
|
+
* for the latest state, and one final write with the ended TTL. Once `final` has settled nothing is written again.
|
|
470
|
+
*
|
|
471
|
+
* - `init()` reads the key an earlier attempt of this job left and keeps its `commentId` (a retry, or a run #645
|
|
472
|
+
* recovers, edits the comment the first attempt posted rather than posting a second one). Every write waits for it,
|
|
473
|
+
* so the first write of an attempt replaces the old key only after its comment id was taken. Memoized.
|
|
474
|
+
* - The TTL is 7 days from this attempt's first successful write (each write sets what is left of it, so a write never
|
|
475
|
+
* extends it), and 24 hours from the final write. Seven days from the writer's start, every write but the final one
|
|
476
|
+
* stops for good, the heartbeat with them (`run_progress_expired`), so no write ever sets a TTL under a second.
|
|
477
|
+
* - `onWrite(snapshot)` is called with what each write before the final one carried (the commenter's feed), whether
|
|
478
|
+
* or not Valkey took it.
|
|
479
|
+
* - `updatedAt` is the time of the write that carried it, not of the last change: until `final`, the snapshot is
|
|
480
|
+
* written again at least every `heartbeatMs` even when nothing moved, so a key whose `updatedAt` is old and whose
|
|
481
|
+
* `ended` is false belongs to a worker that stopped writing (a crash, a kill), not to a quiet run.
|
|
482
|
+
*/
|
|
483
|
+
export function makeProgressWriter({ redis, jobId, now = () => Date.now(), timeoutMs = OP_TIMEOUT_MS, intervalMs = WRITE_INTERVAL_MS, heartbeatMs = PROGRESS_HEARTBEAT_MS, maxBytes = PROGRESS_MAX_BYTES, log = () => {}, setTimer = setTimeout, clearTimer = clearTimeout, onWrite = () => {} } = {}) {
|
|
484
|
+
const key = progressKeyFor(jobId);
|
|
485
|
+
const id = sanitizeJobId(jobId);
|
|
486
|
+
let commentId = null;
|
|
487
|
+
let initDone = null;
|
|
488
|
+
let source = null;
|
|
489
|
+
let dirty = false;
|
|
490
|
+
let timer = null;
|
|
491
|
+
let heartbeat = null;
|
|
492
|
+
let inFlight = null;
|
|
493
|
+
let lastWriteAt = null;
|
|
494
|
+
let firstWriteAt = null;
|
|
495
|
+
let closed = false;
|
|
496
|
+
let finalizing = null;
|
|
497
|
+
let warned = false;
|
|
498
|
+
const counts = { writes: 0, failed: 0 };
|
|
499
|
+
// The heartbeat's hard end: 7 days from this writer's start. The finish normally ends it long before (a job's own
|
|
500
|
+
// timeout is 30 minutes), but a finish that never comes (a path that loses the publisher) must not leave a timer
|
|
501
|
+
// rewriting the key for as long as the worker lives. Taken at the start, not at the first successful write, so a
|
|
502
|
+
// Valkey that refuses every write bounds the beating too; it is never later than the TTL's own start.
|
|
503
|
+
const deadline = now() + LIVE_TTL_LIVE_MS;
|
|
504
|
+
|
|
505
|
+
const ready = () => !redis || typeof redis.status !== "string" || redis.status === "ready";
|
|
506
|
+
|
|
507
|
+
const init = () => {
|
|
508
|
+
if (initDone) return initDone;
|
|
509
|
+
initDone = (async () => {
|
|
510
|
+
if (!redis || !ready()) return null;
|
|
511
|
+
// Unreadable or unreachable reads as null: this attempt posts its own comment.
|
|
512
|
+
const old = await readProgressKey({ redis, jobId, timeoutMs });
|
|
513
|
+
if (commentId === null && old?.commentId) commentId = old.commentId;
|
|
514
|
+
return commentId;
|
|
515
|
+
})();
|
|
516
|
+
return initDone;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const fail = () => {
|
|
520
|
+
counts.failed += 1;
|
|
521
|
+
if (!warned) {
|
|
522
|
+
warned = true;
|
|
523
|
+
log("run_progress_write_failed", { jobId: id });
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
/** Stops every write but the final one, for good: the heartbeat outlived the key's 7-day window. */
|
|
528
|
+
const expire = () => {
|
|
529
|
+
if (closed) return;
|
|
530
|
+
closed = true;
|
|
531
|
+
if (timer) clearTimer(timer);
|
|
532
|
+
timer = null;
|
|
533
|
+
if (heartbeat) clearTimer(heartbeat);
|
|
534
|
+
heartbeat = null;
|
|
535
|
+
log("run_progress_expired", { jobId: id });
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const write = async (snapshot, ended) => {
|
|
539
|
+
await init();
|
|
540
|
+
if (closed && !ended) return;
|
|
541
|
+
const at = now();
|
|
542
|
+
if (!ended && deadline - at < LIVE_TTL_FLOOR_MS) return expire();
|
|
543
|
+
const value = { ...snapshot, updatedAt: at, commentId };
|
|
544
|
+
// The final write is not fed on: the commenter gets the final snapshot from its own `finish`.
|
|
545
|
+
if (!ended) {
|
|
546
|
+
try {
|
|
547
|
+
onWrite(value);
|
|
548
|
+
} catch {}
|
|
549
|
+
}
|
|
550
|
+
if (!redis) return;
|
|
551
|
+
try {
|
|
552
|
+
if (!ready()) throw new Error("run progress client not ready");
|
|
553
|
+
// Never below the floor: `firstWriteAt` is no earlier than `deadline`'s start, so what is left of the TTL is
|
|
554
|
+
// at least what is left before the deadline, which the check above held at the floor or more.
|
|
555
|
+
const ttl = ended ? LIVE_TTL_ENDED_MS : Math.max(LIVE_TTL_FLOOR_MS, (firstWriteAt ?? at) + LIVE_TTL_LIVE_MS - at);
|
|
556
|
+
const reply = await bounded(redis.set(key, serializeProgress(value, maxBytes), "PX", ttl), timeoutMs);
|
|
557
|
+
if (reply !== "OK") throw new Error("run progress write refused");
|
|
558
|
+
if (firstWriteAt === null) firstWriteAt = at;
|
|
559
|
+
counts.writes += 1;
|
|
560
|
+
} catch {
|
|
561
|
+
fail();
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const run = () => {
|
|
566
|
+
timer = null;
|
|
567
|
+
if (closed || !dirty || inFlight) return;
|
|
568
|
+
dirty = false;
|
|
569
|
+
lastWriteAt = now();
|
|
570
|
+
const snapshot = source();
|
|
571
|
+
inFlight = write(snapshot, false).finally(() => {
|
|
572
|
+
inFlight = null;
|
|
573
|
+
if (dirty) schedule();
|
|
574
|
+
else beat();
|
|
575
|
+
});
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
/** The next heartbeat, re-armed after every write: the same snapshot again, with a fresh `updatedAt`. */
|
|
579
|
+
const beat = () => {
|
|
580
|
+
if (closed || source === null) return;
|
|
581
|
+
if (deadline - now() < LIVE_TTL_FLOOR_MS) return expire();
|
|
582
|
+
if (heartbeat) clearTimer(heartbeat);
|
|
583
|
+
heartbeat = setTimer(() => {
|
|
584
|
+
heartbeat = null;
|
|
585
|
+
if (closed) return;
|
|
586
|
+
dirty = true;
|
|
587
|
+
schedule();
|
|
588
|
+
}, heartbeatMs);
|
|
589
|
+
heartbeat?.unref?.();
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const schedule = () => {
|
|
593
|
+
if (closed || timer || inFlight) return;
|
|
594
|
+
const wait = lastWriteAt === null ? 0 : Math.max(0, lastWriteAt + intervalMs - now());
|
|
595
|
+
if (wait === 0) return run();
|
|
596
|
+
timer = setTimer(run, wait);
|
|
597
|
+
timer?.unref?.();
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
init,
|
|
602
|
+
update(snapshot) {
|
|
603
|
+
if (closed) return;
|
|
604
|
+
source = typeof snapshot === "function" ? snapshot : () => snapshot;
|
|
605
|
+
dirty = true;
|
|
606
|
+
schedule();
|
|
607
|
+
},
|
|
608
|
+
setCommentId(value) {
|
|
609
|
+
if (closed || typeof value !== "string" || !COMMENT_ID_RE.test(value) || value === commentId) return;
|
|
610
|
+
commentId = value;
|
|
611
|
+
if (source) {
|
|
612
|
+
dirty = true;
|
|
613
|
+
schedule();
|
|
614
|
+
}
|
|
615
|
+
},
|
|
616
|
+
commentId: () => commentId,
|
|
617
|
+
/** The final write, ended TTL. Bounded, never throwing; idempotent. */
|
|
618
|
+
final(snapshot) {
|
|
619
|
+
if (finalizing) return finalizing;
|
|
620
|
+
if (timer) clearTimer(timer);
|
|
621
|
+
timer = null;
|
|
622
|
+
if (heartbeat) clearTimer(heartbeat);
|
|
623
|
+
heartbeat = null;
|
|
624
|
+
closed = true;
|
|
625
|
+
finalizing = bounded(
|
|
626
|
+
(async () => {
|
|
627
|
+
try {
|
|
628
|
+
await inFlight;
|
|
629
|
+
} catch {}
|
|
630
|
+
await write(snapshot, true);
|
|
631
|
+
})(),
|
|
632
|
+
timeoutMs * 2,
|
|
633
|
+
).catch(() => fail());
|
|
634
|
+
return finalizing;
|
|
635
|
+
},
|
|
636
|
+
stats: () => ({ closed, dirty, ...counts }),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* The progress side of one attempt, as the live transcript's opener wires it: the reducer, the key writer and, when
|
|
642
|
+
* given, the forge status commenter. `makeProgressSink({ redis, jobId, phasesEnabled, redact, now, log, makeCommenter,
|
|
643
|
+
* writer })` returns `{ consume, reset, start(), finish(outcome), summary(), snapshot(), stats() }`.
|
|
644
|
+
*
|
|
645
|
+
* - `makeCommenter({ jobId, commentId, onId })` returns a status-comment.mjs commenter or null (start.mjs builds it
|
|
646
|
+
* only for a forge job whose trigger set `run.statusComment`). It is created at `start`, after the writer read the
|
|
647
|
+
* earlier attempt's comment id, and fed each snapshot the writer writes (at most once a second; it throttles its
|
|
648
|
+
* own edits). The first write, at `start`, is the empty snapshot (turn 0), so a retry's attempt replaces the key
|
|
649
|
+
* the previous attempt ended with at once rather than at its first transcript line.
|
|
650
|
+
* - `finish(outcome)` ends the reducer, hands the final snapshot and the exit's outcome to the commenter's
|
|
651
|
+
* `ended(snapshot, outcome)`, waits (at most `COMMENT_POST_WAIT_MS`) for a comment post still in flight so its id
|
|
652
|
+
* rides the final write, then makes the final write; all of it bounded by `SINK_FINISH_TIMEOUT_MS`. Never throws;
|
|
653
|
+
* idempotent.
|
|
654
|
+
*
|
|
655
|
+
* THE SINK DOES NOT WRITE THE COMMENT'S OUTCOME. The exit code is all it knows, and the record's classification can
|
|
656
|
+
* differ from it (an OOM kill is `unknown` here and `policy` in the record, an infrastructure exit is retried). So the
|
|
657
|
+
* commenter's owner (start.mjs) makes the final edit from the record and uses the outcome handed to `ended` only as a
|
|
658
|
+
* fallback. A commenter with no `ended` has no such owner, and the sink finishes it with the exit's outcome itself.
|
|
659
|
+
* The id wait exists because the key is closed after the final write: an id that arrived later was lost, and a retry
|
|
660
|
+
* that read no id posted a second comment.
|
|
661
|
+
*/
|
|
662
|
+
export function makeProgressSink({ redis, jobId, phasesEnabled = false, redact, now = () => Date.now(), log = () => {}, makeCommenter = null, writer: writerOpts = {}, finishTimeoutMs = SINK_FINISH_TIMEOUT_MS } = {}) {
|
|
663
|
+
const progress = makeProgress({ jobId, phasesEnabled, redact, now });
|
|
664
|
+
let commenter = null;
|
|
665
|
+
const writer = makeProgressWriter({
|
|
666
|
+
redis,
|
|
667
|
+
jobId,
|
|
668
|
+
now,
|
|
669
|
+
log,
|
|
670
|
+
...writerOpts,
|
|
671
|
+
onWrite: (snapshot) => {
|
|
672
|
+
try {
|
|
673
|
+
commenter?.update(snapshot);
|
|
674
|
+
} catch {}
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
let started = null;
|
|
678
|
+
let finishing = null;
|
|
679
|
+
let seen = progress.version();
|
|
680
|
+
const changed = () => {
|
|
681
|
+
const v = progress.version();
|
|
682
|
+
if (v === seen) return;
|
|
683
|
+
seen = v;
|
|
684
|
+
writer.update(() => progress.snapshot());
|
|
685
|
+
};
|
|
686
|
+
return {
|
|
687
|
+
consume(parsed, ts, flags) {
|
|
688
|
+
try {
|
|
689
|
+
progress.consume(parsed, ts, flags);
|
|
690
|
+
changed();
|
|
691
|
+
} catch {}
|
|
692
|
+
},
|
|
693
|
+
reset() {
|
|
694
|
+
progress.reset();
|
|
695
|
+
},
|
|
696
|
+
start() {
|
|
697
|
+
if (started || finishing) return started;
|
|
698
|
+
started = (async () => {
|
|
699
|
+
const commentId = await writer.init();
|
|
700
|
+
if (typeof makeCommenter === "function") {
|
|
701
|
+
try {
|
|
702
|
+
commenter = makeCommenter({ jobId, commentId, onId: (value) => writer.setCommentId(value), finalTimeoutMs: COMMENT_FINAL_TIMEOUT_MS }) ?? null;
|
|
703
|
+
commenter?.start({ ...progress.snapshot(), commentId });
|
|
704
|
+
} catch {
|
|
705
|
+
commenter = null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
writer.update(() => progress.snapshot());
|
|
709
|
+
})().catch(() => {});
|
|
710
|
+
return started;
|
|
711
|
+
},
|
|
712
|
+
finish(outcome) {
|
|
713
|
+
if (finishing) return finishing;
|
|
714
|
+
finishing = bounded(
|
|
715
|
+
(async () => {
|
|
716
|
+
try {
|
|
717
|
+
await started;
|
|
718
|
+
} catch {}
|
|
719
|
+
const snapshot = progress.finalSnapshot(outcome);
|
|
720
|
+
const enumOutcome = snapshot.outcome;
|
|
721
|
+
const owned = typeof commenter?.ended === "function";
|
|
722
|
+
if (owned) {
|
|
723
|
+
try {
|
|
724
|
+
commenter.ended(snapshot, enumOutcome);
|
|
725
|
+
} catch {}
|
|
726
|
+
}
|
|
727
|
+
if (commenter) {
|
|
728
|
+
let wait;
|
|
729
|
+
await Promise.race([
|
|
730
|
+
Promise.resolve()
|
|
731
|
+
.then(() => commenter.idle?.())
|
|
732
|
+
.catch(() => {}),
|
|
733
|
+
new Promise((resolve) => {
|
|
734
|
+
wait = setTimeout(resolve, COMMENT_POST_WAIT_MS);
|
|
735
|
+
}),
|
|
736
|
+
]).finally(() => clearTimeout(wait));
|
|
737
|
+
try {
|
|
738
|
+
writer.setCommentId(commenter.commentId?.());
|
|
739
|
+
} catch {}
|
|
740
|
+
}
|
|
741
|
+
await Promise.allSettled([writer.final(snapshot), owned ? null : Promise.resolve().then(() => commenter?.finish({ ...snapshot, commentId: writer.commentId() }, enumOutcome))]);
|
|
742
|
+
})(),
|
|
743
|
+
finishTimeoutMs,
|
|
744
|
+
).catch(() => {});
|
|
745
|
+
return finishing;
|
|
746
|
+
},
|
|
747
|
+
summary: () => progress.summary(),
|
|
748
|
+
snapshot: () => progress.snapshot(),
|
|
749
|
+
stats: () => ({ ...progress.stats(), writer: writer.stats() }),
|
|
750
|
+
};
|
|
751
|
+
}
|