@edgehero/pi-dispatch 4.3.1 → 5.1.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 +21 -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 +118 -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 +136 -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 +325 -0
- package/src/console-client.mjs +848 -0
- package/src/console-devices.mjs +372 -0
- package/src/console-install.mjs +239 -0
- package/src/console-open.mjs +99 -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 +1657 -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/envelope.mjs +5 -3
- 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 +4 -2
- 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 +12 -2
- 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 +36 -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 +402 -53
- 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,415 @@
|
|
|
1
|
+
import { LIVE_OUTCOMES } from "./live-transcript.mjs";
|
|
2
|
+
import { sanitizeJobId } from "./run-history.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The forge status comment (issue #644, opt-in `run.statusComment`): one comment on the triggering issue or pull
|
|
6
|
+
* request, posted when the container starts, edited as the run's progress snapshot moves, and edited a last time with
|
|
7
|
+
* the outcome.
|
|
8
|
+
*
|
|
9
|
+
* FIXED WORDS AND INTEGERS ONLY. The progress snapshot carries model text (phase names, the note) and tool names, all
|
|
10
|
+
* forgeable by the agent. A public comment must not carry any of it: a phase name could mention a user or hold a
|
|
11
|
+
* link. So `renderStatusText` reads the snapshot's counters and the LENGTH of `phases`, never a string out of it, and
|
|
12
|
+
* the job id goes through `sanitizeJobId` before it is put between backticks.
|
|
13
|
+
*
|
|
14
|
+
* BEST EFFORT, BOUNDED, NEVER THROWING. `post` and `edit` are the caller's never-throwing wrappers around a forge host
|
|
15
|
+
* (start.mjs). Nothing here throws, nothing blocks the caller (start and update only schedule), and `finish` is bounded
|
|
16
|
+
* by `finalTimeoutMs`: a stuck forge costs a stale comment, never a slower job.
|
|
17
|
+
*
|
|
18
|
+
* EDITS ARE RATIONED. At most one edit per `intervalMs`, only when the rendered text changed, a trailing edit so the
|
|
19
|
+
* latest snapshot lands, and at most `maxEdits` edits a job, the last of which is kept for `finish` so the outcome is
|
|
20
|
+
* always written when the run got that far. A `{ stop }` answer (the wrapper's 403, 404 or 429) ends editing for the
|
|
21
|
+
* rest of the run and leaves the last text in place, with one exception: the FINAL edit. A 429 is the forge asking for
|
|
22
|
+
* a pause, not refusing the edit, and a comment left saying "running" after the run ended is a lie told for good, so
|
|
23
|
+
* the final edit is made after a rate-limit stop, and tried once more after `finalRetryMs` when the forge answers a 429
|
|
24
|
+
* or any other failure (`{ ok: false }`), all inside `finalTimeoutMs`. A 403 or 404 stop still ends it: the comment is
|
|
25
|
+
* gone or the token may not edit it, and asking again changes neither. Logs carry the job id and a reason, never the
|
|
26
|
+
* text.
|
|
27
|
+
*
|
|
28
|
+
* THE OUTCOME COMES FROM THE RECORD. The container's exit code is a weaker reading than the run record's
|
|
29
|
+
* classification (an OOM kill exits 137 and is recorded `policy`; an infrastructure exit is retried under the same job
|
|
30
|
+
* id). `makeStatusCommentRegistry` lets start.mjs make the final edit from the record (`settle`), with the exit's
|
|
31
|
+
* reading only as a bounded fallback for an attempt that leaves no record.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** A forge comment id this module will hand to a host, and the form `postStatusComment` answers. */
|
|
35
|
+
export const STATUS_COMMENT_ID_RE = /^[A-Za-z0-9:_-]{1,200}$/;
|
|
36
|
+
|
|
37
|
+
/** Whether `id` is a status comment id a host may build a URL from. */
|
|
38
|
+
export const isStatusCommentId = (id) => typeof id === "string" && STATUS_COMMENT_ID_RE.test(id);
|
|
39
|
+
|
|
40
|
+
/** A forge's created-comment id as the string form, or null: a non-negative safe integer, or an already valid string. */
|
|
41
|
+
export function commentIdOf(value) {
|
|
42
|
+
if (Number.isSafeInteger(value) && value >= 0) return String(value);
|
|
43
|
+
return isStatusCommentId(value) ? value : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The most characters of the sanitized job id the text carries. */
|
|
47
|
+
const JOB_ID_MAX = 128;
|
|
48
|
+
/** The final state of an attempt that ended and will run again under the same job id (an infrastructure retry). */
|
|
49
|
+
export const STATUS_RETRYING = "retrying";
|
|
50
|
+
/** Every final state the text can name: the live outcomes (the record's three among them) and the retry. */
|
|
51
|
+
export const STATUS_OUTCOMES = Object.freeze([...LIVE_OUTCOMES, STATUS_RETRYING]);
|
|
52
|
+
/**
|
|
53
|
+
* How long after the container ended the registry makes the final edit itself when no record settled it. Five minutes,
|
|
54
|
+
* not seconds: the record is written after the processor's own teardown (the job directory, the egress network, the
|
|
55
|
+
* budget refund), and a slow one used to lose the race to a 30 s fallback, which then wrote the exit's weaker reading
|
|
56
|
+
* as if it were final. The timer is unref'd, so a long one costs nothing.
|
|
57
|
+
*/
|
|
58
|
+
export const STATUS_FALLBACK_MS = 5 * 60_000;
|
|
59
|
+
/**
|
|
60
|
+
* How long a commenter the fallback finished stays reachable for a record that settles after it, and how many such
|
|
61
|
+
* commenters are kept at once (oldest dropped first). A late settle makes one corrective edit when its text differs.
|
|
62
|
+
*/
|
|
63
|
+
export const STATUS_LATE_SETTLE_MS = 10 * 60_000;
|
|
64
|
+
export const STATUS_LATE_SETTLE_MAX = 256;
|
|
65
|
+
/** The pause before the final edit's one retry. */
|
|
66
|
+
const FINAL_RETRY_MS = 1_000;
|
|
67
|
+
/** A stop that asks for a pause rather than refusing the edit: the final edit is still made. */
|
|
68
|
+
const isRateLimitStop = (reason) => /^(http-)?429$/.test(String(reason));
|
|
69
|
+
|
|
70
|
+
/** A non-negative safe integer, or 0. Every number in the text goes through this. */
|
|
71
|
+
const count = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : 0);
|
|
72
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The comment body for `snapshot`. Running when `outcome` is undefined or null, "attempt ended, retrying" for
|
|
76
|
+
* `STATUS_RETRYING`, finished otherwise; an outcome outside `LIVE_OUTCOMES` is written as `unknown`. Example:
|
|
77
|
+
* "pi-dispatch job `abc`: running. Turn 7, 12 steps (1 failed). Phase 2 of 4 (3/10)."
|
|
78
|
+
*/
|
|
79
|
+
export function renderStatusText({ jobId, snapshot, outcome } = {}) {
|
|
80
|
+
const id = sanitizeJobId(jobId).slice(0, JOB_ID_MAX);
|
|
81
|
+
const s = snapshot !== null && typeof snapshot === "object" ? snapshot : {};
|
|
82
|
+
const finished = outcome !== undefined && outcome !== null;
|
|
83
|
+
const state = !finished ? "running" : outcome === STATUS_RETRYING ? "attempt ended, retrying" : `finished: ${LIVE_OUTCOMES.includes(outcome) ? outcome : "unknown"}`;
|
|
84
|
+
const turn = count(s.turn);
|
|
85
|
+
const steps = count(s.steps);
|
|
86
|
+
const failed = count(s.failed);
|
|
87
|
+
const interrupted = count(s.interrupted);
|
|
88
|
+
const extra = [failed > 0 ? `${failed} failed` : null, interrupted > 0 ? `${interrupted} interrupted` : null].filter(Boolean);
|
|
89
|
+
let text = `pi-dispatch job \`${id}\`: ${state}. Turn ${turn}, ${plural(steps, "step")}${extra.length ? ` (${extra.join(", ")})` : ""}.`;
|
|
90
|
+
const phase = s.phase !== null && typeof s.phase === "object" ? s.phase : null;
|
|
91
|
+
if (phase && Number.isSafeInteger(phase.index) && phase.index >= 0) {
|
|
92
|
+
// Only the array's length is read: a phase NAME is model text and never reaches the comment.
|
|
93
|
+
const total = Array.isArray(s.phases) ? s.phases.length : 0;
|
|
94
|
+
text += ` Phase ${phase.index + 1}${total > phase.index ? ` of ${total}` : ""}`;
|
|
95
|
+
const done = Number.isSafeInteger(phase.done) && phase.done >= 0 ? phase.done : null;
|
|
96
|
+
const of = Number.isSafeInteger(phase.total) && phase.total >= 1 ? phase.total : null;
|
|
97
|
+
if (done !== null && of !== null) text += ` (${done}/${of})`;
|
|
98
|
+
else if (done !== null) text += ` (${done} done)`;
|
|
99
|
+
text += ".";
|
|
100
|
+
}
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The commenter for one run. `post(text) -> Promise<{id}|null>` and `edit(id, text) -> Promise<{ok:true}|{stop}>` never
|
|
106
|
+
* throw (the caller's wrappers); a given `commentId` (a retry or a recovered run, from the progress key) is edited
|
|
107
|
+
* instead of posting a second comment. `onId(id)` hears a newly posted comment's id so the caller can store it.
|
|
108
|
+
*/
|
|
109
|
+
export function makeStatusCommenter({
|
|
110
|
+
jobId,
|
|
111
|
+
post,
|
|
112
|
+
edit,
|
|
113
|
+
commentId = null,
|
|
114
|
+
onId = () => {},
|
|
115
|
+
now = Date.now,
|
|
116
|
+
log = () => {},
|
|
117
|
+
intervalMs = 30_000,
|
|
118
|
+
maxEdits = 120,
|
|
119
|
+
finalTimeoutMs = 5_000,
|
|
120
|
+
finalRetryMs = FINAL_RETRY_MS,
|
|
121
|
+
setTimer = setTimeout,
|
|
122
|
+
clearTimer = clearTimeout,
|
|
123
|
+
} = {}) {
|
|
124
|
+
let id = isStatusCommentId(commentId) ? commentId : null;
|
|
125
|
+
let started = false;
|
|
126
|
+
let finished = false;
|
|
127
|
+
let stopped = false;
|
|
128
|
+
let stopReason = null;
|
|
129
|
+
let capped = false;
|
|
130
|
+
let edits = 0;
|
|
131
|
+
let lastText = null;
|
|
132
|
+
let lastAt = -Infinity;
|
|
133
|
+
let latest = null;
|
|
134
|
+
let timer = null;
|
|
135
|
+
// Every forge call runs on this chain, one at a time, so edits never overlap and `finish` can wait for the one in
|
|
136
|
+
// flight. Each link swallows its own failure, so the chain never rejects.
|
|
137
|
+
let chain = Promise.resolve();
|
|
138
|
+
|
|
139
|
+
const say = (event, fields) => {
|
|
140
|
+
try {
|
|
141
|
+
log(event, { jobId, ...fields });
|
|
142
|
+
} catch {
|
|
143
|
+
// A logger that throws must not reach the job.
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const enqueue = (op) => {
|
|
147
|
+
chain = chain.then(op).catch((err) => say("status_comment_error", { reason: err?.name ?? "error" }));
|
|
148
|
+
return chain;
|
|
149
|
+
};
|
|
150
|
+
const render = (snapshot, outcome) => renderStatusText({ jobId, snapshot, outcome });
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One edit, counted, unless stopped. `final` may spend the edit `update` keeps in reserve, and `extra` (the late
|
|
154
|
+
* correction) one past `maxEdits`, since the final edit it corrects may have spent the last one. Answers the result.
|
|
155
|
+
*/
|
|
156
|
+
async function doEdit(text, { final = false, extra = 0 } = {}) {
|
|
157
|
+
if ((stopped && !(final && isRateLimitStop(stopReason))) || id === null || text === lastText) return { ok: true };
|
|
158
|
+
const limit = (final ? maxEdits : maxEdits - 1) + extra;
|
|
159
|
+
if (edits >= limit) {
|
|
160
|
+
if (!capped) say("status_comment_capped", { edits });
|
|
161
|
+
capped = true;
|
|
162
|
+
return { ok: true };
|
|
163
|
+
}
|
|
164
|
+
edits += 1;
|
|
165
|
+
lastAt = now();
|
|
166
|
+
const result = await edit(id, text);
|
|
167
|
+
if (result?.stop) {
|
|
168
|
+
if (!stopped || String(result.stop) !== stopReason) say("status_comment_stopped", { reason: String(result.stop) });
|
|
169
|
+
stopped = true;
|
|
170
|
+
stopReason = String(result.stop);
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
if (result?.ok === true) lastText = text;
|
|
174
|
+
return result ?? { ok: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The final edit: once, and once more after a pause when the forge failed it or asked for one (a 429). */
|
|
178
|
+
async function doFinalEdit(text, extra = 0) {
|
|
179
|
+
const first = await doEdit(text, { final: true, extra });
|
|
180
|
+
const again = first?.ok === false || (first?.stop && isRateLimitStop(first.stop));
|
|
181
|
+
if (!again) return;
|
|
182
|
+
await new Promise((resolve) => {
|
|
183
|
+
const t = setTimer(resolve, finalRetryMs);
|
|
184
|
+
t?.unref?.();
|
|
185
|
+
});
|
|
186
|
+
// The retry is not counted against `maxEdits`: the edit it repeats never landed.
|
|
187
|
+
edits = Math.max(0, edits - 1);
|
|
188
|
+
await doEdit(text, { final: true, extra });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function doPost(text) {
|
|
192
|
+
lastAt = now();
|
|
193
|
+
const result = await post(text);
|
|
194
|
+
const posted = commentIdOf(result?.id);
|
|
195
|
+
if (posted === null) return;
|
|
196
|
+
id = posted;
|
|
197
|
+
lastText = text;
|
|
198
|
+
try {
|
|
199
|
+
onId(posted);
|
|
200
|
+
} catch {
|
|
201
|
+
// The id is still used for this run's edits; only the caller's record of it is lost.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function schedule() {
|
|
206
|
+
if (timer !== null || stopped || finished) return;
|
|
207
|
+
const delay = Math.max(0, lastAt + intervalMs - now());
|
|
208
|
+
timer = setTimer(() => {
|
|
209
|
+
timer = null;
|
|
210
|
+
if (finished || latest === null) return;
|
|
211
|
+
const snapshot = latest;
|
|
212
|
+
enqueue(() => doEdit(render(snapshot)));
|
|
213
|
+
}, delay);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function start(snapshot) {
|
|
217
|
+
try {
|
|
218
|
+
if (started || finished) return;
|
|
219
|
+
started = true;
|
|
220
|
+
latest = snapshot ?? null;
|
|
221
|
+
const text = render(snapshot);
|
|
222
|
+
if (id !== null) enqueue(() => doEdit(text));
|
|
223
|
+
else enqueue(() => doPost(text));
|
|
224
|
+
} catch (err) {
|
|
225
|
+
say("status_comment_error", { reason: err?.name ?? "error" });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function update(snapshot) {
|
|
230
|
+
try {
|
|
231
|
+
if (!started || finished || stopped || capped) return;
|
|
232
|
+
latest = snapshot ?? null;
|
|
233
|
+
if (render(latest) === lastText) return;
|
|
234
|
+
schedule();
|
|
235
|
+
} catch (err) {
|
|
236
|
+
say("status_comment_error", { reason: err?.name ?? "error" });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Waits for `work`, at most `finalTimeoutMs`. Never throws. */
|
|
241
|
+
async function within(work) {
|
|
242
|
+
let bound = null;
|
|
243
|
+
try {
|
|
244
|
+
await Promise.race([
|
|
245
|
+
work,
|
|
246
|
+
new Promise((resolve) => {
|
|
247
|
+
bound = setTimer(() => {
|
|
248
|
+
say("status_comment_final_timeout", {});
|
|
249
|
+
resolve();
|
|
250
|
+
}, finalTimeoutMs);
|
|
251
|
+
}),
|
|
252
|
+
]);
|
|
253
|
+
} finally {
|
|
254
|
+
if (bound !== null) {
|
|
255
|
+
try {
|
|
256
|
+
clearTimer(bound);
|
|
257
|
+
} catch {
|
|
258
|
+
// Nothing to undo.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function finish(snapshot, outcome) {
|
|
265
|
+
try {
|
|
266
|
+
if (finished) return;
|
|
267
|
+
finished = true;
|
|
268
|
+
if (timer !== null) {
|
|
269
|
+
clearTimer(timer);
|
|
270
|
+
timer = null;
|
|
271
|
+
}
|
|
272
|
+
const text = render(snapshot ?? latest, outcome ?? "unknown");
|
|
273
|
+
// A run that never started its comment posts one, with the outcome; one whose post found no id posts
|
|
274
|
+
// nothing more, since the first post may have landed and a second would be a duplicate.
|
|
275
|
+
const fresh = !started && id === null;
|
|
276
|
+
started = true;
|
|
277
|
+
await within(enqueue(() => (fresh ? doPost(text) : doFinalEdit(text))));
|
|
278
|
+
} catch (err) {
|
|
279
|
+
say("status_comment_error", { reason: err?.name ?? "error" });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* One corrective final edit after `finish`, for a record that settled after the fallback wrote the exit's outcome.
|
|
285
|
+
* Made only when its text differs from what landed (`doEdit` skips an equal text), bounded as `finish`, never throws.
|
|
286
|
+
*/
|
|
287
|
+
async function correct(snapshot, outcome) {
|
|
288
|
+
try {
|
|
289
|
+
if (!finished) return;
|
|
290
|
+
await within(enqueue(() => doFinalEdit(render(snapshot ?? latest, outcome ?? "unknown"), 1)));
|
|
291
|
+
} catch (err) {
|
|
292
|
+
say("status_comment_error", { reason: err?.name ?? "error" });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// `idle()` settles once every forge call queued so far has (it never rejects): the sink waits on it, bounded, so a post
|
|
297
|
+
// still in flight when the run ends lands its id in the progress key's final write.
|
|
298
|
+
return { start, update, finish, correct, commentId: () => id, idle: () => chain };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The status commenters of the running jobs, by job id, so the final edit is made from the run record rather than from
|
|
303
|
+
* the exit code (start.mjs). `register(jobId, commenter)` returns the commenter the progress sink drives, with an
|
|
304
|
+
* `ended(snapshot, outcome)` that keeps the final snapshot and arms a fallback; `settle(jobId, outcome)` makes the final
|
|
305
|
+
* edit with the record's outcome (or `STATUS_RETRYING`) and the kept snapshot.
|
|
306
|
+
*
|
|
307
|
+
* - The fallback: `fallbackMs` (5 minutes) after `ended`, a commenter no record settled (an attempt that ended in a
|
|
308
|
+
* hold, or a record path that threw) is finished with the exit's outcome, so the comment never says "running" after
|
|
309
|
+
* the run.
|
|
310
|
+
* - THE LATE SETTLE. A commenter the fallback finished is kept for `lateMs` (10 minutes, at most `lateMax` of them,
|
|
311
|
+
* the oldest dropped first), and a `settle` that arrives in that time makes one corrective edit through the
|
|
312
|
+
* commenter's `correct`, which writes only when the record's text differs from the fallback's. Without it a record
|
|
313
|
+
* slower than the fallback left the exit's weaker reading (an OOM kill's `unknown` for the record's `policy`) as the
|
|
314
|
+
* comment's last word.
|
|
315
|
+
* - Every entry is removed when it is finished, either way, and a new attempt's `register` under the same job id
|
|
316
|
+
* drops the previous entry (and any late one) WITHOUT a final edit: the new attempt's commenter edits the same
|
|
317
|
+
* comment from here on.
|
|
318
|
+
* - `has(jobId)` says whether a `settle` would reach a commenter, so start.mjs can tell a record with nothing
|
|
319
|
+
* registered (an attempt refused before its container) from one the registry will edit.
|
|
320
|
+
* - Never throws; `settle` answers the commenter's bounded `finish` (or undefined when there is nothing to settle).
|
|
321
|
+
*/
|
|
322
|
+
export function makeStatusCommentRegistry({ fallbackMs = STATUS_FALLBACK_MS, lateMs = STATUS_LATE_SETTLE_MS, lateMax = STATUS_LATE_SETTLE_MAX, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
323
|
+
const entries = new Map();
|
|
324
|
+
// key -> { commenter, snapshot, until }, insertion ordered, so the oldest is the first. Pruned on every use rather than
|
|
325
|
+
// by timers: what it holds is bounded by `lateMax` either way.
|
|
326
|
+
const late = new Map();
|
|
327
|
+
const prune = () => {
|
|
328
|
+
const t = now();
|
|
329
|
+
for (const [key, held] of late) if (!(held.until > t)) late.delete(key);
|
|
330
|
+
};
|
|
331
|
+
const drop = (key, entry) => {
|
|
332
|
+
entry.done = true;
|
|
333
|
+
if (entries.get(key) === entry) entries.delete(key);
|
|
334
|
+
if (entry.timer !== null) {
|
|
335
|
+
try {
|
|
336
|
+
clearTimer(entry.timer);
|
|
337
|
+
} catch {}
|
|
338
|
+
entry.timer = null;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const close = (key, entry, outcome) => {
|
|
342
|
+
if (entry.done) return undefined;
|
|
343
|
+
drop(key, entry);
|
|
344
|
+
try {
|
|
345
|
+
return Promise.resolve(entry.commenter.finish(entry.snapshot, outcome)).catch(() => {});
|
|
346
|
+
} catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
const keepLate = (key, entry) => {
|
|
351
|
+
if (typeof entry.commenter.correct !== "function") return;
|
|
352
|
+
prune();
|
|
353
|
+
late.delete(key);
|
|
354
|
+
late.set(key, { commenter: entry.commenter, snapshot: entry.snapshot, until: now() + lateMs });
|
|
355
|
+
while (late.size > lateMax) late.delete(late.keys().next().value);
|
|
356
|
+
};
|
|
357
|
+
return {
|
|
358
|
+
register(jobId, commenter) {
|
|
359
|
+
const key = String(jobId);
|
|
360
|
+
const previous = entries.get(key);
|
|
361
|
+
if (previous) drop(key, previous);
|
|
362
|
+
late.delete(key);
|
|
363
|
+
const entry = { commenter, snapshot: null, timer: null, done: false };
|
|
364
|
+
entries.set(key, entry);
|
|
365
|
+
return {
|
|
366
|
+
start: (snapshot) => commenter.start(snapshot),
|
|
367
|
+
update: (snapshot) => commenter.update(snapshot),
|
|
368
|
+
commentId: () => commenter.commentId(),
|
|
369
|
+
idle: () => commenter.idle?.(),
|
|
370
|
+
ended(snapshot, outcome) {
|
|
371
|
+
if (entry.done) return;
|
|
372
|
+
entry.snapshot = snapshot ?? null;
|
|
373
|
+
if (entry.timer !== null) return;
|
|
374
|
+
entry.timer = setTimer(() => {
|
|
375
|
+
entry.timer = null;
|
|
376
|
+
if (entry.done) return;
|
|
377
|
+
void close(key, entry, outcome);
|
|
378
|
+
keepLate(key, entry);
|
|
379
|
+
}, fallbackMs);
|
|
380
|
+
entry.timer?.unref?.();
|
|
381
|
+
},
|
|
382
|
+
// The sink calls `ended`, never this; it is here so the wrapper is still a whole commenter.
|
|
383
|
+
finish: (snapshot, outcome) => {
|
|
384
|
+
if (snapshot) entry.snapshot = snapshot;
|
|
385
|
+
return close(key, entry, outcome);
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
},
|
|
389
|
+
settle(jobId, outcome) {
|
|
390
|
+
const key = String(jobId);
|
|
391
|
+
const entry = entries.get(key);
|
|
392
|
+
if (entry) return close(key, entry, outcome);
|
|
393
|
+
prune();
|
|
394
|
+
const held = late.get(key);
|
|
395
|
+
if (!held) return undefined;
|
|
396
|
+
late.delete(key);
|
|
397
|
+
try {
|
|
398
|
+
return Promise.resolve(held.commenter.correct(held.snapshot, outcome)).catch(() => {});
|
|
399
|
+
} catch {
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
has(jobId) {
|
|
404
|
+
const key = String(jobId);
|
|
405
|
+
if (entries.has(key)) return true;
|
|
406
|
+
prune();
|
|
407
|
+
return late.has(key);
|
|
408
|
+
},
|
|
409
|
+
size: () => entries.size,
|
|
410
|
+
lateSize: () => {
|
|
411
|
+
prune();
|
|
412
|
+
return late.size;
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|