@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,266 @@
|
|
|
1
|
+
import { isFinalOutcome } from "./inbox-settle.mjs";
|
|
2
|
+
import { enqueueForgeJob, QUEUE } from "./queue.mjs";
|
|
3
|
+
import { runIdOf } from "./recovery-queue.mjs";
|
|
4
|
+
import { sanitizeJobId } from "./run-history.mjs";
|
|
5
|
+
import * as heldModule from "./held-replies.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The worker's side of held replies (issue #638, DES-AWAIT-INPUT-ENDS-THE-JOB; the keys and the scripts are
|
|
9
|
+
* held-replies.mjs's): a keeper that keeps an awaitInput run reachable while it runs, the take inside the park that
|
|
10
|
+
* turns what it held into at most one resumed job, and the final settle that drops what a run that did not park held.
|
|
11
|
+
*
|
|
12
|
+
* WHY THE WORKER AND NOT THE RECEIVER DECIDES. The receiver only knows that a reply met a run that is still going; it
|
|
13
|
+
* cannot know whether that run will park (the agent may finish, fail, or be cancelled after asking). The worker learns
|
|
14
|
+
* it in the park sequence, or at the run's final outcome, so that is where a held reply becomes a job or is dropped. Dropping is today's
|
|
15
|
+
* coalescing exactly: a reply that met a run which then did NOT park was always deduplicated against it, and still is.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The keeper's refresh period, the steer relay's: 2 s against a 30 s open key. */
|
|
19
|
+
export const AWAIT_KEEPER_POLL_MS = 2_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How far back before the park a held reply may have been written and still be taken as the answer (issue #638 review,
|
|
23
|
+
* M1). The keeper registers the run for its whole pickup, so without a bound a phrase-free comment made ten minutes
|
|
24
|
+
* into a long run, before the agent had asked anything, would be taken as "the answer" to a question posted later.
|
|
25
|
+
* Five minutes covers the seconds between the agent's question and the park with a wide margin; an older entry is
|
|
26
|
+
* dropped silently, which is the coalescing it always got. Residual, named: a comment written in the last five minutes
|
|
27
|
+
* before the question and not meant as an answer is taken as one (the resumed agent reads it as a reply and may ask
|
|
28
|
+
* again).
|
|
29
|
+
*/
|
|
30
|
+
export const HELD_LOOKBACK_MS = 5 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The one comment the replies after the first get, however many there are (fixed words, no mention, no quote of any
|
|
34
|
+
* reply). The first reply is the answer the parked run resumes with; a run asks for one answer, and starting a second
|
|
35
|
+
* resumed job from the same parked session would race the first for it.
|
|
36
|
+
*/
|
|
37
|
+
export const HELD_EXTRA_REPLY = "This run was waiting for one answer and took the first reply it was given. If this comment adds to it, comment again once the run has replied.";
|
|
38
|
+
|
|
39
|
+
/** The comment when the held answer could not be started (an enqueue that failed): the reply is answered, never lost silently. */
|
|
40
|
+
export const HELD_RESUME_FAILED_REPLY = "This run was waiting for an answer and could not start from the reply it was given. Comment again to answer it.";
|
|
41
|
+
|
|
42
|
+
const codeOf = (e) => (typeof e?.code === "string" && e.code !== "" ? e.code : typeof e?.error === "string" ? e.error : "error");
|
|
43
|
+
const targetNumber = (t) => (typeof t?.number === "number" ? t.number : typeof t?.number === "string" && /^[1-9][0-9]{0,15}$/.test(t.number) ? Number(t.number) : null);
|
|
44
|
+
|
|
45
|
+
/** Whether a processor result is a run that PARKED: the completed branch's `reason: "awaiting-input"` (processor.mjs). */
|
|
46
|
+
export function isParkedOutcome(result) {
|
|
47
|
+
return result !== null && typeof result === "object" && result.outcome === "completed" && result.reason === "awaiting-input";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `makeAwaitKeeper({ redis, job, host, now, log, pollMs, held })` -> `{ start(), stop() }`: keeps an awaitInput run
|
|
52
|
+
* live on its await target for the whole pickup (registered at start, refreshed every `pollMs`), so a reply in the
|
|
53
|
+
* seconds between the agent's question and the park can be held. Started by the processor only for a job whose
|
|
54
|
+
* `data.awaitInput === true` and whose target a forge reply can reach; stopped by the park's take, else at the settle
|
|
55
|
+
* (index.mjs).
|
|
56
|
+
*
|
|
57
|
+
* STOP LEAVES THE KEYS TO EXPIRE rather than deleting them: the settle's close removes them at a final outcome, and an
|
|
58
|
+
* attempt that will be retried (an InfraRetry with attempts left) keeps taking a held reply for the open key's 30 s,
|
|
59
|
+
* after which the receiver answers as it always did. Never throws; faults are logged once per step, by code.
|
|
60
|
+
*/
|
|
61
|
+
export function makeAwaitKeeper({ redis, job, host, now = Date.now, log = () => {}, pollMs = AWAIT_KEEPER_POLL_MS, held = heldModule } = {}) {
|
|
62
|
+
const runId = runIdOf(job);
|
|
63
|
+
const targetKey = held.awaitTargetKeyOf(job?.data ?? null);
|
|
64
|
+
const jobId = sanitizeJobId(job?.id);
|
|
65
|
+
let timer = null;
|
|
66
|
+
let running = null;
|
|
67
|
+
let stopped = false;
|
|
68
|
+
const said = new Set();
|
|
69
|
+
const fault = (step, code) => {
|
|
70
|
+
if (said.has(step)) return;
|
|
71
|
+
said.add(step);
|
|
72
|
+
log("held_keeper_fault", { jobId, step, code });
|
|
73
|
+
};
|
|
74
|
+
const refresh = async (step) => {
|
|
75
|
+
try {
|
|
76
|
+
const r = await held.refreshAwaitLive({ redis, runId, targetKey, host, now });
|
|
77
|
+
if (r?.error) fault(step, codeOf(r));
|
|
78
|
+
} catch (e) {
|
|
79
|
+
fault(step, codeOf(e));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const tick = () => {
|
|
83
|
+
if (running || stopped) return running ?? Promise.resolve();
|
|
84
|
+
running = refresh("refresh").finally(() => {
|
|
85
|
+
running = null;
|
|
86
|
+
});
|
|
87
|
+
return running;
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
targetKey,
|
|
91
|
+
async start() {
|
|
92
|
+
if (timer !== null || stopped || targetKey === null) return;
|
|
93
|
+
// A closed list under this run id belongs to a run that already settled (a console retry keeps the job id).
|
|
94
|
+
const reset = await held.resetClosedHeld({ redis, runId });
|
|
95
|
+
if (reset?.error) fault("reset", codeOf(reset));
|
|
96
|
+
await refresh("register");
|
|
97
|
+
if (stopped) return;
|
|
98
|
+
timer = setInterval(() => void tick(), pollMs);
|
|
99
|
+
timer.unref?.();
|
|
100
|
+
},
|
|
101
|
+
async stop() {
|
|
102
|
+
stopped = true;
|
|
103
|
+
if (timer !== null) clearInterval(timer);
|
|
104
|
+
timer = null;
|
|
105
|
+
try {
|
|
106
|
+
await running;
|
|
107
|
+
} catch {}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `makeHeldSettle({ redis, queues, comment, log, now, enqueue, held })` -> `settleHeld({ job, runId, result, error,
|
|
114
|
+
* queueName })`, with `settleHeld.take({ job, runId, queueName })`: what an awaitInput run's held replies become.
|
|
115
|
+
*
|
|
116
|
+
* THE TAKE, INSIDE THE PARK (issue #638 review, H1). The processor's parked branch calls `take` after it has set the
|
|
117
|
+
* marker and released the window and before its comment (processor.mjs, the park sequence). `closeHeld` first (one
|
|
118
|
+
* script: no reply can be held after it, and every reply held before it is in its reply); only the FIRST close acts:
|
|
119
|
+
* - entries older than HELD_LOOKBACK_MS before now are dropped silently (`held_replies_dropped`, `stale`);
|
|
120
|
+
* - the first recent entry whose fallback names this run's forge, repo, target type and number (it was held under
|
|
121
|
+
* them; one that does not is skipped with `held_reply_mismatch` and the next is tried) is enqueued as the resumed job
|
|
122
|
+
* WITH the semantic window (`enqueueForgeJob`), on the queue that ran the run (a host queue stays on its host). The
|
|
123
|
+
* window is the point: the park released this run's window a moment before, so the taken reply now holds it, and
|
|
124
|
+
* every later answer (a phrase-free reply the marker routes, a phrase reply) is coalesced against that one job
|
|
125
|
+
* instead of starting a second resumed run from the same parked transcript. An add the window swallowed against
|
|
126
|
+
* ANOTHER job (an answer that came in between the release and the take, which is then the run) is not taken;
|
|
127
|
+
* against THIS run's own job (the release failed) it is not taken either, and is answered as not started;
|
|
128
|
+
* - every other recent entry gets ONE fixed comment, HELD_EXTRA_REPLY; when nothing could be started at all, one
|
|
129
|
+
* HELD_RESUME_FAILED_REPLY instead (also when the close itself failed: an answer may have been held and lost).
|
|
130
|
+
* The take's close also raises the target's parked marker. Returns `{ resumed, answered, dropped, taken }`; the
|
|
131
|
+
* processor posts HELD_TAKEN_COMMENT instead of the parked comment when `taken` is set ("here" or "other").
|
|
132
|
+
*
|
|
133
|
+
* THE SETTLE, AT THE FINAL OUTCOME (the inbox settle's three sites and `isFinalOutcome`), is the backstop. After a take
|
|
134
|
+
* the list is closed and it acts on nothing (`first: false`), and when the take's own close failed it only drops
|
|
135
|
+
* (`afterTake`). Otherwise: a parked outcome whose take never ran (an unwired seam) gets the take's logic; ANY other outcome drops every held reply, which is exactly the coalescing a reply
|
|
136
|
+
* into a still-running run always got. That includes an operator cancel acknowledged before the result: the record says
|
|
137
|
+
* the run was cancelled, not parked, so its held answers are dropped (review, L2).
|
|
138
|
+
*
|
|
139
|
+
* Logs, ids and counts only: `held_reply_resumed { jobId, runId, n, queue }`, `held_replies_dropped { runId, n,
|
|
140
|
+
* stale? }`, `held_reply_mismatch { runId }`, `held_reply_not_taken { runId, survivingJobId }`, `held_reply_resume_failed
|
|
141
|
+
* { runId, queue, code }`, `held_settle_failed { jobId, code }`. Never throws; the counts or null.
|
|
142
|
+
*/
|
|
143
|
+
export function makeHeldSettle({ redis, queues, comment = async () => {}, log = () => {}, now = Date.now, enqueue = enqueueForgeJob, held = heldModule } = {}) {
|
|
144
|
+
const queueFor = (name) => {
|
|
145
|
+
if (typeof name === "string" && name.startsWith(`${QUEUE}@`) && typeof queues?.host === "function") return { queue: queues.host(name), name };
|
|
146
|
+
return { queue: queues?.shared?.(), name: QUEUE };
|
|
147
|
+
};
|
|
148
|
+
const answer = async (job, text) => {
|
|
149
|
+
try {
|
|
150
|
+
await comment({ ...job.data, id: job.id }, text);
|
|
151
|
+
} catch {}
|
|
152
|
+
};
|
|
153
|
+
// The same forge, repo, target type and number, and (review round 2) the same flow and command: the held fallback was
|
|
154
|
+
// held under this run's target, and one that would run different work is no answer to this run's question.
|
|
155
|
+
const sameTarget = (fields, d) => !((fields?.kind !== undefined && fields.kind !== d.kind) || fields?.repo !== d.repo || typeof fields?.target?.type !== "string" || fields.target.type !== d.target?.type || targetNumber(fields.target) === null || targetNumber(fields.target) !== targetNumber(d.target) || fields.flow !== d.flow || fields.command !== d.command);
|
|
156
|
+
const close = async (job, runId, { parked = false } = {}) => {
|
|
157
|
+
const closed = await held.closeHeld({ redis, runId, targetKey: held.awaitTargetKeyOf(job.data), parked });
|
|
158
|
+
if (!closed || closed.error) {
|
|
159
|
+
log("held_settle_failed", { jobId: sanitizeJobId(job.id), code: codeOf(closed) });
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
return { entries: Array.isArray(closed.entries) ? closed.entries : [], first: closed.first === true };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// The take's body, on entries a first close returned.
|
|
166
|
+
const start = async ({ job, runId, entries, queueName }) => {
|
|
167
|
+
// `taken`: "here" when this take started the resumed job, "other" when the window showed another job already is
|
|
168
|
+
// the answer (one that got in between the release and the take), null otherwise. Either word means the run has
|
|
169
|
+
// its resumed job, so the processor says so and a late steer message must not start a second one (review round 2).
|
|
170
|
+
const counts = { resumed: 0, answered: 0, dropped: 0, taken: null };
|
|
171
|
+
if (entries.length === 0) return counts;
|
|
172
|
+
const rid = sanitizeJobId(runId);
|
|
173
|
+
const t = typeof now === "function" ? now() : now;
|
|
174
|
+
const recent = entries.filter((e) => Number.isSafeInteger(e?.at) && e.at >= t - HELD_LOOKBACK_MS);
|
|
175
|
+
const stale = entries.length - recent.length;
|
|
176
|
+
if (stale > 0) {
|
|
177
|
+
counts.dropped = stale;
|
|
178
|
+
log("held_replies_dropped", { runId: rid, n: stale, stale });
|
|
179
|
+
}
|
|
180
|
+
if (recent.length === 0) return counts;
|
|
181
|
+
const route = queueFor(queueName ?? job.queueName);
|
|
182
|
+
let takenAt = -1;
|
|
183
|
+
let failed = false;
|
|
184
|
+
let tried = false;
|
|
185
|
+
for (let i = 0; i < recent.length; i++) {
|
|
186
|
+
const fields = recent[i].fallback;
|
|
187
|
+
if (!sameTarget(fields, job.data)) {
|
|
188
|
+
log("held_reply_mismatch", { runId: rid });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
tried = true;
|
|
192
|
+
try {
|
|
193
|
+
if (!route.queue) throw Object.assign(new Error("no queue"), { code: "no-queue" });
|
|
194
|
+
// The queue's enqueue timestamp is later than the receiver's trusted receipt. Keep that receipt
|
|
195
|
+
// outside the payload so a held answer does not acquire artificial human waiting time.
|
|
196
|
+
const r = await enqueue(route.queue, job.data.kind, { ...fields, replyReceivedAt: recent[i].at });
|
|
197
|
+
if (r?.deduplicated === true) {
|
|
198
|
+
// Swallowed by the window: another job holds it (an answer between the release and the take, which is
|
|
199
|
+
// then the resumed run), or this run's own (its release failed, so nothing was started).
|
|
200
|
+
log("held_reply_not_taken", { runId: rid, survivingJobId: sanitizeJobId(r.survivingJobId) });
|
|
201
|
+
// The run's own job id, or its run id for a #645 recovery whose original holds the window.
|
|
202
|
+
failed = r.survivingJobId === job.id || r.survivingJobId === runId;
|
|
203
|
+
if (!failed) counts.taken = "other";
|
|
204
|
+
} else {
|
|
205
|
+
takenAt = i;
|
|
206
|
+
counts.resumed = 1;
|
|
207
|
+
counts.taken = "here";
|
|
208
|
+
log("held_reply_resumed", { jobId: sanitizeJobId(r?.jobId), runId: rid, n: recent.length, queue: route.name });
|
|
209
|
+
}
|
|
210
|
+
} catch (e) {
|
|
211
|
+
log("held_reply_resume_failed", { runId: rid, queue: route.name, code: codeOf(e) });
|
|
212
|
+
failed = true;
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
const rest = recent.length - (takenAt >= 0 ? 1 : 0);
|
|
217
|
+
if (rest > 0) {
|
|
218
|
+
// "Took the first reply" is true when one was taken here or by the job that holds the window; otherwise the
|
|
219
|
+
// replies were not started at all and say so.
|
|
220
|
+
await answer(job, takenAt >= 0 || (tried && !failed) ? HELD_EXTRA_REPLY : HELD_RESUME_FAILED_REPLY);
|
|
221
|
+
counts.answered = rest;
|
|
222
|
+
}
|
|
223
|
+
return counts;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// `afterTake`: the park's take ran for this outcome (the processor says so). A take whose close FAILED already told the
|
|
227
|
+
// person nothing was started (HELD_RESUME_FAILED_REPLY), so a backstop close that succeeds afterwards must not start a
|
|
228
|
+
// job anyway and contradict it (review round 2): after a take, the backstop only ever drops.
|
|
229
|
+
async function settleHeld({ job, runId = runIdOf(job), result = null, error = null, queueName = null, afterTake = false } = {}) {
|
|
230
|
+
try {
|
|
231
|
+
if (job?.data?.awaitInput !== true) return null;
|
|
232
|
+
if (!isFinalOutcome({ job, result, error })) return null;
|
|
233
|
+
if (runId === null || runId === undefined || runId === "") return null;
|
|
234
|
+
const closed = await close(job, runId);
|
|
235
|
+
if (closed === null) return null;
|
|
236
|
+
if (!closed.first || closed.entries.length === 0) return { resumed: 0, answered: 0, dropped: 0, taken: null };
|
|
237
|
+
if (isParkedOutcome(result) && afterTake !== true) return await start({ job, runId, entries: closed.entries, queueName });
|
|
238
|
+
log("held_replies_dropped", { runId: sanitizeJobId(runId), n: closed.entries.length });
|
|
239
|
+
return { resumed: 0, answered: 0, dropped: closed.entries.length, taken: null };
|
|
240
|
+
} catch (e) {
|
|
241
|
+
log("held_settle_failed", { jobId: sanitizeJobId(job?.id ?? ""), code: codeOf(e) });
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
settleHeld.take = async function takeHeld({ job, runId = runIdOf(job), queueName = null } = {}) {
|
|
247
|
+
try {
|
|
248
|
+
if (job?.data?.awaitInput !== true) return null;
|
|
249
|
+
if (runId === null || runId === undefined || runId === "") return null;
|
|
250
|
+
// `parked`: the close also raises the target's parked marker (held-replies.mjs), so a reply delivered after the run
|
|
251
|
+
// left its target is routed as a parked reply rather than dropped for the label it raced.
|
|
252
|
+
const closed = await close(job, runId, { parked: true });
|
|
253
|
+
if (closed === null) {
|
|
254
|
+
// L1: the close failed, so a reply may have been held and will never be seen: say so once.
|
|
255
|
+
await answer(job, HELD_RESUME_FAILED_REPLY);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (!closed.first) return { resumed: 0, answered: 0, dropped: 0, taken: null };
|
|
259
|
+
return await start({ job, runId, entries: closed.entries, queueName });
|
|
260
|
+
} catch (e) {
|
|
261
|
+
log("held_settle_failed", { jobId: sanitizeJobId(job?.id ?? ""), code: codeOf(e) });
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
return settleHeld;
|
|
266
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display history, never a notification queue (INT-HOOK-RUNS-KEY, #775). Deleting hook:* changes no hook or job.
|
|
3
|
+
* Each completion appends one bounded, versioned record; the key expires seven days after the last append.
|
|
4
|
+
* The small memory drain absorbs simultaneous completions without making a hook wait for Valkey. It never retries:
|
|
5
|
+
* a failed append is lost and named. An operation that timed out still owns the drain until it ACTUALLY settles,
|
|
6
|
+
* because ioredis queues commands while offline and a timeout alone cannot bound that queue.
|
|
7
|
+
*/
|
|
8
|
+
import { publishedJobId, LIVE_JOB_ID_RE } from "./live-jobs.mjs";
|
|
9
|
+
import { WORKER_NAME_RE } from "./worker-name.mjs";
|
|
10
|
+
import { RECORD_REASON_RE } from "./record-reason.mjs";
|
|
11
|
+
|
|
12
|
+
export const HOOK_HOSTS = "hook:hosts";
|
|
13
|
+
export const hookRunsKey = (name) => `hook:h:${name}`;
|
|
14
|
+
export const HOOK_RUN_VERSION = 1;
|
|
15
|
+
export const HOOK_RUNS_MAX = 20;
|
|
16
|
+
export const HOOK_RUN_BYTES = 1024;
|
|
17
|
+
export const HOOK_HOSTS_MAX = 64;
|
|
18
|
+
export const HOOK_PENDING_MAX = 64;
|
|
19
|
+
export const HOOK_RUNS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
/** The portable union, not this reader's platform: a Linux worker may be read from macOS or Windows. */
|
|
21
|
+
export const HOOK_SIGNALS = Object.freeze(["SIGABRT", "SIGALRM", "SIGBUS", "SIGCHLD", "SIGCLD", "SIGCONT", "SIGEMT", "SIGFPE", "SIGHUP", "SIGILL", "SIGINFO", "SIGINT", "SIGIO", "SIGIOT", "SIGKILL", "SIGLOST", "SIGPIPE", "SIGPOLL", "SIGPROF", "SIGPWR", "SIGQUIT", "SIGSEGV", "SIGSTKFLT", "SIGSTOP", "SIGSYS", "SIGTERM", "SIGTRAP", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGUNUSED", "SIGURG", "SIGUSR1", "SIGUSR2", "SIGVTALRM", "SIGWINCH", "SIGXCPU", "SIGXFSZ", "SIGBREAK"]);
|
|
22
|
+
export const HOOK_DETAILS = Object.freeze(["exit", "timeout", "spawn", "unresolvable", "argv-unusable", "signal-unknown", ...HOOK_SIGNALS.map((signal) => `signal-${signal}`)]);
|
|
23
|
+
const instant = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
24
|
+
// Windows reports the full unsigned process status (including NTSTATUS), not POSIX's low eight bits.
|
|
25
|
+
const codeOf = (value) => Number.isInteger(value) && value >= 0 && value <= 0xffffffff ? value : null;
|
|
26
|
+
const outcomeOf = (value) => value === "failed" || value === "policy" ? value : null;
|
|
27
|
+
const reasonOf = (value) => typeof value === "string" && RECORD_REASON_RE.test(value) ? value : "infra";
|
|
28
|
+
|
|
29
|
+
export function buildHookRun(fields, at) {
|
|
30
|
+
return { v: HOOK_RUN_VERSION, at, jobId: publishedJobId(fields?.jobId), outcome: outcomeOf(fields?.outcome), reason: reasonOf(fields?.reason), detail: HOOK_DETAILS.includes(fields?.detail) ? fields.detail : "signal-unknown", code: codeOf(fields?.code) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Another host's text is projected again, including records passed directly to the model boundary. */
|
|
34
|
+
export function parseHookRun(raw) {
|
|
35
|
+
if (typeof raw !== "string" || Buffer.byteLength(raw) > HOOK_RUN_BYTES) return null;
|
|
36
|
+
let value;
|
|
37
|
+
try { value = JSON.parse(raw); } catch { return null; }
|
|
38
|
+
if (!value || value.v !== HOOK_RUN_VERSION || !instant(value.at) || !(value.jobId === null || typeof value.jobId === "string" && LIVE_JOB_ID_RE.test(value.jobId)) || !(value.outcome === null || outcomeOf(value.outcome)) || typeof value.reason !== "string" || !RECORD_REASON_RE.test(value.reason) || !HOOK_DETAILS.includes(value.detail) || !(value.code === null || codeOf(value.code) === value.code)) return null;
|
|
39
|
+
return { v: HOOK_RUN_VERSION, at: value.at, jobId: value.jobId, outcome: value.outcome, reason: value.reason, detail: value.detail, code: value.code };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function bounded(promise, timeoutMs = 2000) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const timer = setTimeout(() => reject(Error("timeout")), timeoutMs);
|
|
45
|
+
Promise.resolve(promise).then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const validName = (name) => typeof name === "string" && WORKER_NAME_RE.test(name);
|
|
49
|
+
|
|
50
|
+
/** No message from a client, record, executable or environment reaches this diagnostic. */
|
|
51
|
+
export function makeHookRunsPublisher({ redis, name, now = Date.now, log = () => {}, timeoutMs = 2000 }) {
|
|
52
|
+
const pending = [];
|
|
53
|
+
let draining = false;
|
|
54
|
+
let closed = false;
|
|
55
|
+
let pruneAt = null;
|
|
56
|
+
let pruneCursor = "0";
|
|
57
|
+
const note = (reason) => { try { log("hook_runs_publish_failed", { reason }); } catch {} };
|
|
58
|
+
const prune = async (at) => {
|
|
59
|
+
if (closed || pruneAt !== null && at - pruneAt < 15_000) return;
|
|
60
|
+
pruneAt = at;
|
|
61
|
+
const found = await redis.sscan(HOOK_HOSTS, pruneCursor, "COUNT", HOOK_HOSTS_MAX);
|
|
62
|
+
if (!Array.isArray(found) || typeof found[0] !== "string" || !Array.isArray(found[1])) return;
|
|
63
|
+
pruneCursor = found[0];
|
|
64
|
+
const names = found[1].slice(0, HOOK_HOSTS_MAX).filter((member) => validName(member) && member !== name);
|
|
65
|
+
if (closed || !names.length) return;
|
|
66
|
+
const pipe = redis.pipeline();
|
|
67
|
+
// Checking and removing together cannot erase an index entry another host has just republished.
|
|
68
|
+
for (const member of names) pipe.eval("if redis.call('EXISTS', KEYS[1]) == 0 then return redis.call('SREM', KEYS[2], ARGV[1]) end return 0", 2, hookRunsKey(member), HOOK_HOSTS, member);
|
|
69
|
+
const result = await pipe.exec();
|
|
70
|
+
if (!Array.isArray(result) || result.length !== names.length || result.some((row) => !Array.isArray(row) || row[0])) throw Error("unavailable");
|
|
71
|
+
};
|
|
72
|
+
const drain = async () => {
|
|
73
|
+
draining = true;
|
|
74
|
+
while (!closed && pending.length) {
|
|
75
|
+
const record = pending.shift();
|
|
76
|
+
let said = false;
|
|
77
|
+
const fail = () => { if (!said && !closed) { said = true; note("unavailable"); } };
|
|
78
|
+
const timer = setTimeout(fail, timeoutMs);
|
|
79
|
+
timer.unref?.();
|
|
80
|
+
try {
|
|
81
|
+
const key = hookRunsKey(name);
|
|
82
|
+
const tx = redis.multi().lpush(key, JSON.stringify(record)).ltrim(key, 0, HOOK_RUNS_MAX - 1).pexpire(key, HOOK_RUNS_TTL_MS).sadd(HOOK_HOSTS, name);
|
|
83
|
+
const answer = await tx.exec();
|
|
84
|
+
if (!Array.isArray(answer) || answer.length !== 4 || answer.some((row) => !Array.isArray(row) || row[0])) fail();
|
|
85
|
+
else await prune(record.at);
|
|
86
|
+
} catch { fail(); }
|
|
87
|
+
finally { clearTimeout(timer); }
|
|
88
|
+
}
|
|
89
|
+
draining = false;
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
publish(fields) {
|
|
93
|
+
if (closed) return;
|
|
94
|
+
if (!validName(name)) { note("invalid-host"); return; }
|
|
95
|
+
if (pending.length + (draining ? 1 : 0) >= HOOK_PENDING_MAX) { note("full"); return; }
|
|
96
|
+
let at;
|
|
97
|
+
try { at = now(); } catch { note("invalid-time"); return; }
|
|
98
|
+
if (!instant(at)) { note("invalid-time"); return; }
|
|
99
|
+
pending.push(buildHookRun(fields, at));
|
|
100
|
+
if (!draining) void drain();
|
|
101
|
+
},
|
|
102
|
+
close() { closed = true; pending.length = 0; },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Bounded discovery: live names first, then at most four SSCAN pages, no SMEMBERS. COUNT is a hint, so even a large
|
|
108
|
+
* response is cut before issuing row commands. SCARD reports an incomplete index walk without calling it empty.
|
|
109
|
+
* A lost index still reads known live hosts; an unreadable index makes that answer partial, never complete.
|
|
110
|
+
*/
|
|
111
|
+
async function hostNames(redis, names, timeoutMs, stopped) {
|
|
112
|
+
const live = [...new Set(names.filter(validName))].sort();
|
|
113
|
+
const wanted = new Set(live.slice(0, HOOK_HOSTS_MAX));
|
|
114
|
+
const indexed = new Set();
|
|
115
|
+
const members = new Set();
|
|
116
|
+
let total = 0;
|
|
117
|
+
let partial = live.length > HOOK_HOSTS_MAX;
|
|
118
|
+
let indexFailed = false;
|
|
119
|
+
let cursor = "0";
|
|
120
|
+
try {
|
|
121
|
+
total = await bounded(redis.scard(HOOK_HOSTS), timeoutMs);
|
|
122
|
+
if (!Number.isSafeInteger(total) || total < 0) throw Error("invalid");
|
|
123
|
+
if (total > 0) for (let page = 0; page < 4 && !stopped(); page++) {
|
|
124
|
+
const result = await bounded(redis.sscan(HOOK_HOSTS, cursor, "COUNT", HOOK_HOSTS_MAX), timeoutMs);
|
|
125
|
+
if (!Array.isArray(result) || typeof result[0] !== "string" || !Array.isArray(result[1])) throw Error("invalid");
|
|
126
|
+
cursor = result[0];
|
|
127
|
+
for (const name of result[1].slice(0, HOOK_HOSTS_MAX * 4)) {
|
|
128
|
+
members.add(name);
|
|
129
|
+
if (validName(name)) { indexed.add(name); if (wanted.size < HOOK_HOSTS_MAX) wanted.add(name); }
|
|
130
|
+
}
|
|
131
|
+
if (cursor === "0" || wanted.size === HOOK_HOSTS_MAX) break;
|
|
132
|
+
}
|
|
133
|
+
partial ||= cursor !== "0" || total !== members.size || indexed.size !== members.size;
|
|
134
|
+
} catch { partial = true; indexFailed = true; }
|
|
135
|
+
// With an incomplete walk the unseen members may overlap the omitted live names. Count only the union we SAW,
|
|
136
|
+
// explicitly a floor; adding the two remainders would count a large fleet's same hosts twice.
|
|
137
|
+
const more = Math.max(0, new Set([...live, ...indexed]).size - wanted.size);
|
|
138
|
+
const moreUnknown = indexFailed || cursor !== "0" || total !== members.size;
|
|
139
|
+
return { names: [...wanted], more, moreUnknown, partial: partial || more > 0 };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Readers write nothing. Expired index members are pruned by the next publisher, not by a console request. */
|
|
143
|
+
export async function readHookRuns(redis, { names = [], timeoutMs = 2000 } = {}) {
|
|
144
|
+
let stopped = false;
|
|
145
|
+
// Register the WHOLE-read timer before issuing any command. Per-command bounds alone can turn four slow
|
|
146
|
+
// discovery pages and one row read into ten seconds; late settlement must never start follow-up commands.
|
|
147
|
+
const work = Promise.resolve().then(() => readHookRunsInner(redis, names, timeoutMs, () => stopped));
|
|
148
|
+
try { return await bounded(work, timeoutMs); }
|
|
149
|
+
catch { return { hosts: [], more: 0, moreUnknown: true, partial: true }; }
|
|
150
|
+
finally { stopped = true; }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function readHookRunsInner(redis, names, timeoutMs, stopped) {
|
|
154
|
+
const selected = await hostNames(redis, names, timeoutMs, stopped);
|
|
155
|
+
if (stopped()) return { hosts: [], more: selected.more, moreUnknown: true, partial: true };
|
|
156
|
+
const hosts = await Promise.all(selected.names.map(async (name) => {
|
|
157
|
+
try {
|
|
158
|
+
const key = hookRunsKey(name);
|
|
159
|
+
const [raw, ttl] = await Promise.all([bounded(redis.lrange(key, 0, HOOK_RUNS_MAX), timeoutMs), bounded(redis.pttl(key), timeoutMs)]);
|
|
160
|
+
if (!Array.isArray(raw) || raw.length > HOOK_RUNS_MAX || !Number.isSafeInteger(ttl)) throw Error("invalid");
|
|
161
|
+
if (raw.length === 0 && ttl === -2) return { name, runs: [], absent: true };
|
|
162
|
+
if (ttl <= 0 || ttl > HOOK_RUNS_TTL_MS || raw.length === 0) throw Error("invalid");
|
|
163
|
+
const runs = raw.map(parseHookRun);
|
|
164
|
+
if (runs.some((record) => record === null)) throw Error("invalid");
|
|
165
|
+
return { name, runs };
|
|
166
|
+
} catch { return { name, runs: [], unreadable: true }; }
|
|
167
|
+
}));
|
|
168
|
+
return { hosts: hosts.sort((a, b) => a.name.localeCompare(b.name)), more: selected.more, moreUnknown: selected.moreUnknown, partial: selected.partial || hosts.some((host) => host.unreadable) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Public display/model DTO. Recomputing counts prevents a polluted DTO inventing a successful hook history. */
|
|
172
|
+
export function hookRunsView(value) {
|
|
173
|
+
const hosts = [];
|
|
174
|
+
const input = Array.isArray(value?.hosts) ? value.hosts : [];
|
|
175
|
+
const omitted = Math.max(0, input.length - HOOK_HOSTS_MAX);
|
|
176
|
+
let partial = value?.partial !== false || omitted > 0;
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
for (const host of input.slice(0, HOOK_HOSTS_MAX)) {
|
|
179
|
+
if (!validName(host?.name) || seen.has(host.name)) { partial = true; continue; }
|
|
180
|
+
seen.add(host.name);
|
|
181
|
+
const raw = Array.isArray(host.runs) ? host.runs : [];
|
|
182
|
+
const runs = raw.slice(0, HOOK_RUNS_MAX).map((run) => { try { return parseHookRun(JSON.stringify(run)); } catch { return null; } });
|
|
183
|
+
const unreadable = host.unreadable === true || !Array.isArray(host.runs) || raw.length > HOOK_RUNS_MAX || runs.some((run) => !run);
|
|
184
|
+
const counts = unreadable ? null : Object.fromEntries([...new Set(runs.map((run) => run.detail))].map((detail) => [detail, runs.filter((run) => run.detail === detail).length]));
|
|
185
|
+
hosts.push({ name: host.name, unreadable, absent: !unreadable && (host.absent === true || runs.length === 0), runs: unreadable ? [] : runs, counts });
|
|
186
|
+
}
|
|
187
|
+
return { hosts, more: (Number.isSafeInteger(value?.more) && value.more >= 0 ? value.more : 0) + omitted, moreUnknown: value?.moreUnknown === true, partial: partial || hosts.some((host) => host.unreadable) };
|
|
188
|
+
}
|
package/src/host-budget.mjs
CHANGED
|
@@ -45,8 +45,8 @@ export const HOST_BUDGET_KEYS = Object.freeze({
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* How often a job the budget deferred asks again. Its own value, like every re-check cadence,
|
|
49
|
-
*
|
|
48
|
+
* How often a job the budget deferred asks again. Its own value, like every re-check cadence, so the wake instants of the
|
|
49
|
+
* different deferrals stay apart (the reason itself is recorded, `defer-reasons.mjs`): distinct from the scope re-check
|
|
50
50
|
* (5 s), the endpoint re-check (7 s), the wait throttle floor (11 s) and the supersede re-ask (15 s). A test keeps them
|
|
51
51
|
* apart. Short, because a hold keeps the room meanwhile: the wait is the latency of a freed slot, not a queue position.
|
|
52
52
|
*/
|
|
@@ -306,7 +306,7 @@ export function rankHolds(waiters, ledger, minJobsOf = () => 0) {
|
|
|
306
306
|
|
|
307
307
|
/**
|
|
308
308
|
* THE ADMISSION RULE (PURE): may `ask` (`{ id, project, memMiB, cpuCenti }`) start now? `{ ok: true }`, or `{ ok: false,
|
|
309
|
-
* why }` with `why` `share` (its project would hold more than its `hostShare` of the budget) or `
|
|
309
|
+
* why }` with `why` `share` (its project would hold more than its `hostShare` of the budget) or `memory`, `cpu`, `count` (what runs, plus
|
|
310
310
|
* `ask`, plus every hold ranked above it, does not fit in memory, in CPU or in the job COUNT). `holds` is `rankHolds`'
|
|
311
311
|
* order; a job that holds no hold has every hold above it. The share is judged first, so a job its own project's share
|
|
312
312
|
* stops is not reported as waiting for the budget (and so does not hold room it could not use).
|
|
@@ -328,7 +328,10 @@ export function admit({ budget, ledger, holds, ask, shareOf = () => null }) {
|
|
|
328
328
|
const counted = [...ledger, ask, ...(rank === -1 ? holds : holds.slice(0, rank))];
|
|
329
329
|
const need = sumOf(counted);
|
|
330
330
|
const count = Number.isSafeInteger(budget.count) ? budget.count : null;
|
|
331
|
-
|
|
331
|
+
// A stable order explains the first blocked dimension without changing admission or ranked holds.
|
|
332
|
+
if (!fits(need.memMiB, budget.memMiB)) return { ok: false, why: "memory" };
|
|
333
|
+
if (!fits(need.cpuCenti, budget.cpuCenti)) return { ok: false, why: "cpu" };
|
|
334
|
+
if (!fits(counted.length, count)) return { ok: false, why: "count" };
|
|
332
335
|
return { ok: true };
|
|
333
336
|
}
|
|
334
337
|
|
|
@@ -643,7 +646,7 @@ export function makeHostBudget({ settings, jobDefault = { memMiB: 4096, cpuCenti
|
|
|
643
646
|
/**
|
|
644
647
|
* THE GATE, synchronous: `{ admitted: true }` with the hold taken under `ticket`, or `{ admitted: false, why }`:
|
|
645
648
|
* `unseeded` (the boot listing of the job's `venue` is not read yet) and `running-here` (the ledger already holds
|
|
646
|
-
* this id, or an orphan carries the container `name` this pickup will use) make no waiter;
|
|
649
|
+
* this id, or an orphan carries the container `name` this pickup will use) make no waiter; dimension refusals and `share`
|
|
647
650
|
* keep (or make) the job a waiter. `getState` is the job's own, kept for `verify`.
|
|
648
651
|
*/
|
|
649
652
|
gate({ id, ticket = null, project = null, size, venue = null, name = null, getState = null, limits = scopedLimits() }) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Shared host-clone primitives. The credential helper lives outside every agent-reachable tree (#771). */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, win32 } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { GIT_SAFE_CONFIG, storeGitEnv } from "./git-hardening.mjs";
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
export const HOST_CLONE_FLAGS = Object.freeze([...GIT_SAFE_CONFIG, "-c", "protocol.ext.allow=never", "-c", "credential.helper=", "--no-pager"]);
|
|
11
|
+
export const hardenedCloneArgs = (args) => [...HOST_CLONE_FLAGS, ...args];
|
|
12
|
+
|
|
13
|
+
export function createAskpassHelper() {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-askpass-"));
|
|
15
|
+
const path = join(dir, "askpass.sh");
|
|
16
|
+
try {
|
|
17
|
+
writeFileSync(path, "#!/bin/sh\nprintf '%s' \"$GIT_ASKPASS_TOKEN\"\n", { mode: 0o700 });
|
|
18
|
+
chmodSync(path, 0o700);
|
|
19
|
+
return { dir, path };
|
|
20
|
+
} catch (error) {
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function defaultHostGit(cwd, args, { env, signal, timeout } = {}) {
|
|
27
|
+
const { stdout } = await exec("git", args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, env, ...(timeout ? { timeout } : {}), ...(signal ? { signal } : {}) });
|
|
28
|
+
return stdout;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The determinate missing-ref class, shared with the job preparer's existing refusal. */
|
|
32
|
+
export function isCloneRefGone(error) {
|
|
33
|
+
const text = `${error?.stderr ?? ""}\n${error?.message ?? ""}`.toLowerCase();
|
|
34
|
+
return ["couldn't find remote ref", "not our ref", "unadvertised object", "did not send all necessary objects", "reference is not a tree"].some((part) => text.includes(part));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** storeGitEnv's trust switches are shared; Windows keeps absolute drive/UNC tool paths with its own separator. */
|
|
38
|
+
export function hostCloneEnvironment({ home, path, platform = process.platform }) {
|
|
39
|
+
const clean = storeGitEnv({ home, path });
|
|
40
|
+
delete clean.GIT_DIR;
|
|
41
|
+
if (platform === "win32") clean.PATH = String(path ?? "").split(";").filter((entry) => win32.isAbsolute(entry)).join(";");
|
|
42
|
+
return clean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A credential-free checkout after the one authenticated fetch. No materialisation, prompt, session or agent inputs.
|
|
47
|
+
* The empty helper home and allowlisted environment also stop a repository's attributes selecting a host filter.
|
|
48
|
+
*/
|
|
49
|
+
export async function cloneHostWorkspace({ workspace, remote, ref, token, env = process.env, git = defaultHostGit, askpass = createAskpassHelper, platform = process.platform, signal } = {}) {
|
|
50
|
+
const url = new URL(remote);
|
|
51
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error("sandbox clone requires a credential-free configured http(s) remote");
|
|
52
|
+
const helper = askpass();
|
|
53
|
+
try {
|
|
54
|
+
const home = join(helper.dir, "home");
|
|
55
|
+
mkdirSync(home, { mode: 0o700 });
|
|
56
|
+
mkdirSync(workspace, { recursive: true, mode: 0o700 });
|
|
57
|
+
// env-internal PATH: use only absolute host tool directories, as the checkpoint-store Git does.
|
|
58
|
+
const clean = hostCloneEnvironment({ home, path: env.PATH, platform });
|
|
59
|
+
// env-internal GIT_SSL_CAINFO: an operator's private CA trust input, never repository content or a container env.
|
|
60
|
+
if (typeof env.GIT_SSL_CAINFO === "string") clean.GIT_SSL_CAINFO = env.GIT_SSL_CAINFO;
|
|
61
|
+
const run = (args, net = false) => git(workspace, hardenedCloneArgs(args), { timeout: 120_000, env: net ? { ...clean, GIT_ASKPASS: helper.path, GIT_ASKPASS_TOKEN: token } : clean, ...(signal ? { signal } : {}) });
|
|
62
|
+
await run(["init"]);
|
|
63
|
+
await run(["remote", "add", "origin", remote]);
|
|
64
|
+
await run(["fetch", "--depth=1", "--no-tags", "--no-recurse-submodules", "origin", ref], true);
|
|
65
|
+
await run(["checkout", "--detach", "FETCH_HEAD"]);
|
|
66
|
+
const sha = String(await run(["rev-parse", "HEAD"])).trim();
|
|
67
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(sha)) throw new Error("sandbox clone returned no commit id");
|
|
68
|
+
return { workspace, sha };
|
|
69
|
+
} finally {
|
|
70
|
+
rmSync(helper.dir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|