@edgehero/pi-dispatch 4.3.1 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +84 -3
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1639 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
package/src/run-container.mjs
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import { readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
4
5
|
import { DOCKER_NEVER_STARTED_EXITS } from "./backends.mjs";
|
|
5
|
-
import { CONTAINER_HOME, memoryBytesOfArgs } from "./container-spec.mjs";
|
|
6
|
+
import { CONTAINER_GLOBAL_PI_DIR, CONTAINER_HOME, memoryBytesOfArgs } from "./container-spec.mjs";
|
|
7
|
+
import { layoutFor } from "./job-layout.mjs";
|
|
6
8
|
import { cpuCeilingCenti, cpuRangeRefusal, DEFAULT_JOB_SIZE } from "./job-size.mjs";
|
|
7
9
|
import { buildDockerRunArgs, CONTAINER_SESSION_FILE, insideDir } from "./docker-run.mjs";
|
|
8
|
-
import {
|
|
10
|
+
import { configError } from "./config.mjs";
|
|
11
|
+
import { createJobNetwork, egressEnv, networkNameFor, removeJobNetwork } from "./egress.mjs";
|
|
9
12
|
import { buildContainerEnv } from "./env-allowlist.mjs";
|
|
13
|
+
import { authenticExitLines } from "./run-history.mjs";
|
|
14
|
+
import { extensionsNotHere, leavesOut, leftOutOf, rebuildLoaded } from "./loaded-record.mjs";
|
|
15
|
+
import { withCanaryPath } from "./package-canary.mjs";
|
|
16
|
+
import { parsePackageCanary } from "./package-canary-record.mjs";
|
|
17
|
+
import { EXCLUDABLE_TOOL_NAMES } from "./triggers.mjs";
|
|
10
18
|
import { resolveJobImage } from "./image-preflight.mjs";
|
|
19
|
+
import { LIVE_FILE_NAME, stageLiveFile } from "./live-transcript.mjs";
|
|
20
|
+
import { steerTargetOf } from "./inbox-relay.mjs";
|
|
11
21
|
import { InfraRetry } from "./processor.mjs";
|
|
22
|
+
import { makeRedactor, secretValuesOf } from "./redact-secrets.mjs";
|
|
23
|
+
import { makeSteerAck } from "./steer-ack.mjs";
|
|
24
|
+
import { keyFrame } from "./steer-framing.mjs";
|
|
25
|
+
import { makeStderrTranscript } from "./stderr-transcript.mjs";
|
|
12
26
|
|
|
13
27
|
/**
|
|
14
28
|
* The real `runContainer` the processor injects. Launches one job container and returns
|
|
15
|
-
* `{ code, aborted, turns, tokens, session, usage, context, exitReason, resources?, exitOomKilled?, memoryLimit? }`, where `aborted` records whether the WORKER initiated the stop (docker stop on
|
|
29
|
+
* `{ code, aborted, turns, tokens, session, usage, context, exitReason, resources?, exitOomKilled?, memoryLimit?, progress? }`, where `aborted` records whether the WORKER initiated the stop (docker stop on
|
|
16
30
|
* the 30-min timeout or graceful shutdown), which the processor classifies as POLICY (no retry) per
|
|
17
31
|
* INT-RUNNER-EXIT-CODE-PROTOCOL. The numeric `code` alone cannot say this: a worker SIGKILL and a
|
|
18
32
|
* kernel OOM both surface as 137, so the abort FLAG -- not the code -- is the discriminator.
|
|
@@ -42,7 +56,14 @@ export function makeRunContainer({
|
|
|
42
56
|
// REQ-GLOBAL-PI-OVERLAY: container paths of the operator-staged packages. An array, or a RESOLVER called
|
|
43
57
|
// once per job (issue #102): the wired worker passes a resolver so a re-stage lands on the next job with
|
|
44
58
|
// no restart, while the array form stays valid for every caller that has a fixed set.
|
|
59
|
+
// Issue #800: a resolver may carry `rowOf(path)`, the `{ name, version }` its last read holds for a path, which names
|
|
60
|
+
// the runner's positional rows, and `stageRead()`, false when this read could not say what is staged. On the resolver,
|
|
61
|
+
// not a second option, because every venue passes this one function through untouched, and a second option could reach a
|
|
62
|
+
// venue without it and record a job's packages with no names.
|
|
45
63
|
packagePaths = [],
|
|
64
|
+
// Issue #800: the names in `<overlay>/extensions`, which an overlay name on the exit line has to be one of. A seam for
|
|
65
|
+
// the tests. A folder that cannot be read holds nothing the worker can vouch for, so a name then reads as invalid.
|
|
66
|
+
readOverlayExtensions = (dir) => readdirSync(dir),
|
|
46
67
|
forwardEnv = [],
|
|
47
68
|
authFromPi = false, // fall back to ~/.pi/agent/auth.json for the provider key when the env has none
|
|
48
69
|
forgeHosts = {}, // per-forge self-hosted instance URLs, so a forge CLI in the container talks to the right one
|
|
@@ -69,7 +90,72 @@ export function makeRunContainer({
|
|
|
69
90
|
// the wiring drops the cached runtime facts the ceiling came from and the next pickup reads them again. The local
|
|
70
91
|
// venue's is the job-user resolver's `invalidate`; a no-op elsewhere (Podman accepts any `--cpus`, measured).
|
|
71
92
|
onCpuCeilingStale = () => {},
|
|
93
|
+
// Issue #643: `({ file, jobId, redact, job }) => { start, finish, progressSummary? } | null`, the live transcript
|
|
94
|
+
// publisher's opener (live-transcript.mjs `makeLiveTranscriptOpener`), which since issue #644 also reads run progress.
|
|
95
|
+
// Null publishes nothing: a deployment with PI_LIVE_TRANSCRIPTS=0 and PI_RUN_PROGRESS=0, one without a Valkey client,
|
|
96
|
+
// and every caller that predates the feature.
|
|
97
|
+
openLiveTranscript = null,
|
|
98
|
+
// Issue #645: stages the live transcript file for a `run.recover` job the preparer staged none for (both live switches
|
|
99
|
+
// off). A seam so a test can refuse it.
|
|
100
|
+
stageLive = stageLiveFile,
|
|
101
|
+
// Issue #645: the bound on each step of stopping a container whose unpause never took.
|
|
102
|
+
checkpointStopTimeoutMs = 15_000,
|
|
103
|
+
// Issue #646: `({ runId, jobId, inboxDir, targetKey, ref, forge }) => relay`, the inbox relay's opener (inbox-relay.mjs
|
|
104
|
+
// `makeInboxRelay`, bound in start.mjs to the worker's Valkey client and host name). Null arms no job: a steer job is
|
|
105
|
+
// then run without steering and its record says `steerSkipped: "no-transcript"`, never a silent no-op. Issue #686: a
|
|
106
|
+
// stdin-steered attempt opens it with `inboxDir: null` and `frames: { exitKey, macRunId }`, then `attach`es the pipe.
|
|
107
|
+
openInboxRelay = null,
|
|
108
|
+
// Issue #646: the host fs the inbox directory is made and removed through. A seam for the tests.
|
|
109
|
+
inboxFs = { mkdirSync, rmSync },
|
|
110
|
+
// Issue #683: the venue's file layout (job-layout.mjs). `mounts` for every binding venue, byte-identical to before;
|
|
111
|
+
// `sandbox` for a copying one (`openshell`), which moves the session file and the staged packages' container paths
|
|
112
|
+
// under /sandbox and writes PI_JOB_LAYOUT.
|
|
113
|
+
layout = "mounts",
|
|
114
|
+
// Issue #683: the container's memory bound in bytes, given the argv and the options it was built from. Both binding
|
|
115
|
+
// builders spell it `--memory=`, so the default reads the argv; a venue whose argv carries no such flag (an `exec`
|
|
116
|
+
// into a sandbox sized at create) says it from the job's size instead.
|
|
117
|
+
memoryLimitOf = (args) => memoryBytesOfArgs(args),
|
|
118
|
+
// Issue #683: a copying venue's own steps around the CLI child, each awaited. `stageRun({ args, opts })` runs after the
|
|
119
|
+
// argv is built and before the spawn (the venue's create, policy read-back and upload); a throw there is
|
|
120
|
+
// `container-never-started`, refunded and retried, since nothing ran. `collectRun(result)` runs once the child has
|
|
121
|
+
// closed (the download), and returns the result to resolve with. `teardownRun()` runs in the `finally`, on every
|
|
122
|
+
// path a `stageRun` was begun on (the delete). Each is handed THIS attempt's build options (`opts`, the name among
|
|
123
|
+
// them), so a venue running several jobs at once acts on its own sandbox only. Absent, a binding venue's run is
|
|
124
|
+
// exactly what it was.
|
|
125
|
+
stageRun = null,
|
|
126
|
+
collectRun = null,
|
|
127
|
+
teardownRun = null,
|
|
128
|
+
// Issue #686: how this venue's session file reaches the host while the job runs. `file` for every binding venue (the
|
|
129
|
+
// live tail reads the bind-mounted file, byte-identical to before); `stdio` for a copying one (`openshell`), whose
|
|
130
|
+
// runner mirrors the file's lines over the CLI's stderr as records signed with the exit key, which this side
|
|
131
|
+
// verifies and pushes into the same publisher (stderr-transcript.mjs). A CONSTRUCTION option, the venue's own
|
|
132
|
+
// statement beside `layout`, rather than read off the call bag's `binds`: `binds` reaches a steer job only (the
|
|
133
|
+
// registry adds it for that one question), while every job of a copying venue needs this answer, and a venue that
|
|
134
|
+
// copies but cannot mirror (none today) is then named for what it is (`venue-copies`) instead of guessed from a flag.
|
|
135
|
+
transcript = "file",
|
|
136
|
+
// Issue #686: the stderr demux's factory (stderr-transcript.mjs). A seam so a test can make it fail.
|
|
137
|
+
makeDemux = makeStderrTranscript,
|
|
138
|
+
// Review round 1, M1: how long a stdin-steered attempt's pipe stays open after an exit-shaped line that does not verify.
|
|
139
|
+
stdinExitGraceMs = 5_000,
|
|
140
|
+
// Issue #686 part 3, review round 2 of #714: `({ opts, aborted }) => boolean`, a binding venue whose box OUTLIVES its CLI
|
|
141
|
+
// child (`openshell-bind`: the sandbox stays after the exec, and a process the agent left behind can still write the
|
|
142
|
+
// bound host tree). Awaited once the child has closed and BEFORE the transcript's final read and the sink's close, so
|
|
143
|
+
// the last lines and the processor's reads both see a tree nothing writes any more. `false` (not confirmed gone) puts
|
|
144
|
+
// `lingering: true` on the result, which the processor reads (no exit checkpoint; the named result where a retry
|
|
145
|
+
// would be wrong). Absent, every venue's run is exactly what it was.
|
|
146
|
+
settleRun = null,
|
|
147
|
+
// Issue #704 part 3: this venue hands a project secret bundle's plain variables to the runner by FILE (the job
|
|
148
|
+
// directory's bundle-env.json, which its `stageRun` writes), so an attempt with plain bundle variables gets
|
|
149
|
+
// PI_BUNDLE_ENV=1. A construction option, the venue's own statement like `transcript`: every binding venue passes
|
|
150
|
+
// them as env and leaves it off, so its env is byte-identical.
|
|
151
|
+
bundleEnvFile = false,
|
|
152
|
+
// Issue #730: this venue's connections are mediated by a broker that must read the caller's memory (OpenShell), so
|
|
153
|
+
// every attempt gets PI_RUNNER_OBSERVABLE=1 and its key holders start from the readable node. Every other venue
|
|
154
|
+
// leaves it off and its env is byte-identical.
|
|
155
|
+
observableRunner = false,
|
|
72
156
|
}) {
|
|
157
|
+
// Refused at construction rather than written into a job's env: a guessed layout reads nothing.
|
|
158
|
+
const paths = layoutFor(layout);
|
|
73
159
|
// async so a synchronous throw (e.g. buildContainerEnv on an unconfigured provider) surfaces as
|
|
74
160
|
// a rejection, uniformly awaitable by the processor and by tests.
|
|
75
161
|
// `relabel` (issue #355) is the processor's, off the same job-user answer as `user`: true where the daemon confines
|
|
@@ -88,7 +174,27 @@ export function makeRunContainer({
|
|
|
88
174
|
// `unenforced` (issue #596) is the size flags this runtime said it drops (`unenforcedSizeFlags`: `--memory-swap` where
|
|
89
175
|
// Docker reports SwapLimit false, `--cpu-shares` where it reports CPUShares false), logged per job as
|
|
90
176
|
// `size_bound_unenforced` so a run past its size's bound is named where it happens. The flags stay on the argv.
|
|
91
|
-
|
|
177
|
+
// `jobId` (issue #643) is the queue's job id, which names the live transcript stream as it names the run record
|
|
178
|
+
// (`run:live:` and `runs:rec:`, both through `sanitizeJobId`); absent, no stream is published.
|
|
179
|
+
// `onLive(finish)` (issue #644) hears this attempt's bounded, idempotent publisher finish once the publisher is open, so
|
|
180
|
+
// the processor can end it when it stops waiting for a container whose stop did not take (index.mjs
|
|
181
|
+
// `boundAfterAbort`). Without it the publisher ends only on the CLI child's `close` or `error`, which never comes
|
|
182
|
+
// for that container: the progress key's heartbeat rewrote it as running for its whole 7-day window.
|
|
183
|
+
// `checkpoint` (issue #645) is the processor's open args for the run checkpointer (`{ runId, attempt, baseSha, branch,
|
|
184
|
+
// secretValues, meta }`), honoured only beside `recover: true`; `pause`/`unpause` are this job's venue's freezer verbs,
|
|
185
|
+
// which the backend registry puts on the call bag (absent for a venue that cannot freeze: no live checkpoints then).
|
|
186
|
+
// `binds` (issue #646) is the venue's own statement that it bind-mounts (the backend bundle's `binds`, which the registry
|
|
187
|
+
// puts on the call bag of a steer job only): a copying venue would upload `/inbox` once, at the start, so no message
|
|
188
|
+
// written later would ever arrive. Absent is false: a job is steer-armed only on a venue that SAYS it binds.
|
|
189
|
+
// `transcriptStderr` (issue #686) is the processor's, off the image preflight like `exitAuth`: true when the job image
|
|
190
|
+
// declares `transcriptStderr`, so its runner can mirror the session file over stderr. Read only on a `stdio` venue.
|
|
191
|
+
// `steerStdin` (issue #686, part 2) is the processor's too: true when the job image declares `steerStdin`, so its
|
|
192
|
+
// runner reads steer messages as MAC'd frames on stdin. Read only for a steer job on a venue that copies (`binds` not
|
|
193
|
+
// true); a binding venue keeps the read-only mount whatever the image declares.
|
|
194
|
+
// `bundleKeys` (issue #704 part 3) is the processor's `{ endpoints, plain }` split of a project secret bundle's received
|
|
195
|
+
// variables: handed on in the build options for a venue whose own layer holds credentials (openshell), which binds the
|
|
196
|
+
// first kind to their hosts and passes the second as values. Every other venue ignores it: the values are in `secrets`.
|
|
197
|
+
return async function runContainer({ job, token, prepared, secrets = {}, bundleKeys = null, name, signal, user = null, home = null, relabel = false, modelEndpoints = null, exitAuth = false, transcriptStderr = false, steerStdin = false, size = DEFAULT_JOB_SIZE, hostCpus = null, cpuBudgetCenti = null, unenforced = [], jobId = null, onLive = null, recover = false, recovering = false, checkpoint = null, pause = null, unpause = null, binds = false, subscriptionAuth = [] }) {
|
|
92
198
|
if (signal?.aborted) return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null, exitReason: null }; // killed before it could start
|
|
93
199
|
// Minted per attempt, held in this closure and the sink's, and handed to the container on its stdin only: never in
|
|
94
200
|
// the argv (a host `ps` shows it), never in the env (the container's /proc/1/environ shows it), never logged.
|
|
@@ -100,15 +206,193 @@ export function makeRunContainer({
|
|
|
100
206
|
throw new Error(`runContainer: a job user (${user}) must be paired with HOME=${CONTAINER_HOME}`);
|
|
101
207
|
}
|
|
102
208
|
|
|
209
|
+
// Issue #643: the directory holding this job's transcript file, mounted at /session. A resume job's is its staged
|
|
210
|
+
// transcript, exactly as before; every other job's is the empty live file the preparer staged beside it
|
|
211
|
+
// (`prepared.live`, live-transcript.mjs `stageLiveFile`). Resume itself stays keyed on `prepared.session` alone:
|
|
212
|
+
// `awaitInput` below, the promotion and the record's `session` read nothing else.
|
|
213
|
+
let transcriptDir = prepared.session?.hostDir ?? prepared.live?.hostDir ?? null;
|
|
214
|
+
// Issue #645: an armed job keeps its transcript on the host whatever the live switches say. The checkpoints copy it,
|
|
215
|
+
// a salvage reads it from a crashed attempt's job dir, and the live checkpoints are driven by its lines, so with
|
|
216
|
+
// PI_LIVE_TRANSCRIPTS=0 and PI_RUN_PROGRESS=0 (no file staged by the preparer) it is staged here. Not for a resume
|
|
217
|
+
// job, whose staged transcript already is the file.
|
|
218
|
+
const armed = recover === true && checkpoint !== null && typeof checkpoint === "object";
|
|
219
|
+
|
|
220
|
+
// Issue #686 (DES-OPENSHELL-VENUE): whether this attempt's transcript comes over the CLI's stderr. Decided BEFORE
|
|
221
|
+
// steering, because a stdin-steered attempt's acks come back through it. Asked only where
|
|
222
|
+
// something would read it (an opener and a job id) on a venue whose session file is not on the host while it runs,
|
|
223
|
+
// and armed only when every part exists: the venue mirrors (`transcript: "stdio"`), the image's runner can
|
|
224
|
+
// (`transcriptStderr`), and there is an exit key to verify the records with. A missing part is a NAMED skip
|
|
225
|
+
// (`live_transcript_skipped`); the job still runs, with no live transcript and no progress, as before this issue.
|
|
226
|
+
const liveReadable = typeof openLiveTranscript === "function" && jobId !== null && jobId !== undefined;
|
|
227
|
+
let liveSkipped = null;
|
|
228
|
+
if (liveReadable && (transcript === "stdio" || layout !== "mounts")) {
|
|
229
|
+
if (transcript !== "stdio") liveSkipped = "venue-copies";
|
|
230
|
+
else if (transcriptStderr !== true) liveSkipped = "no-capability";
|
|
231
|
+
else if (exitKey === null) liveSkipped = "no-exit-key";
|
|
232
|
+
}
|
|
233
|
+
const stdioArmed = liveReadable && transcript === "stdio" && liveSkipped === null;
|
|
234
|
+
if (liveSkipped !== null) log("live_transcript_skipped", { container: name, reason: liveSkipped });
|
|
235
|
+
|
|
236
|
+
// Issue #646 (DES-STEER-VIA-READONLY-INBOX-MOUNT): whether this attempt is steer-ARMED. Asked by the job's data
|
|
237
|
+
// (`steer: true`, which the trigger's `run.steer` puts there and the image preflight already checked the image for),
|
|
238
|
+
// and armed only when every part of the channel exists: a venue that binds mounts, an exit key to verify the
|
|
239
|
+
// delivery acks with, and something to read those acks (a transcript tail) and to write the files (the relay).
|
|
240
|
+
// Each missing part is a NAMED skip on the record (`steerSkipped`); the job still runs, as it would without
|
|
241
|
+
// `run.steer`, and a message for it is refused at push because nothing ever registers it as open.
|
|
242
|
+
//
|
|
243
|
+
// Issue #686 (INT-STEER-INBOX-CONTRACT's second transport): on a venue that COPIES (`binds` not true) the inbox part
|
|
244
|
+
// is the exec's stdin instead of the mount, when the image's runner reads frames (`steerStdin`). Its parts are the
|
|
245
|
+
// same key, a relay, and the stdio transcript (`stdioArmed`, above), which is where the acks come back; without that
|
|
246
|
+
// the attempt is `no-transcript`, as a binding venue without a tail is. An image without `steerStdin` there is
|
|
247
|
+
// skipped exactly as before, `venue-copies`. A binding venue never takes this path, whatever the image declares.
|
|
248
|
+
const steerAsked = job?.steer === true;
|
|
249
|
+
const viaStdin = binds !== true && steerStdin === true;
|
|
250
|
+
let steerSkipped = null;
|
|
251
|
+
if (steerAsked) {
|
|
252
|
+
if (binds !== true && steerStdin !== true) steerSkipped = "venue-copies";
|
|
253
|
+
else if (exitKey === null) steerSkipped = "no-exit-key";
|
|
254
|
+
else if (typeof openInboxRelay !== "function" || typeof openLiveTranscript !== "function" || jobId === null || jobId === undefined) steerSkipped = "no-transcript";
|
|
255
|
+
else if (viaStdin && !stdioArmed) steerSkipped = "no-transcript";
|
|
256
|
+
}
|
|
257
|
+
if (transcriptDir === null && (recover === true || (steerAsked && steerSkipped === null)) && typeof stageLive === "function") {
|
|
258
|
+
try {
|
|
259
|
+
transcriptDir = stageLive(prepared.jobDir)?.hostDir ?? null;
|
|
260
|
+
} catch {
|
|
261
|
+
transcriptDir = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (steerAsked && steerSkipped === null && transcriptDir === null) steerSkipped = "no-transcript";
|
|
265
|
+
// The inbox: `<jobDir>.inbox`, BESIDE the job directory (`/job` is merge-gated input only, and the processor removes
|
|
266
|
+
// the job directory itself), mode 0700, made fresh per attempt (a retry's messages are written again into its own),
|
|
267
|
+
// removed in this function's `finally`. Then the relay, built now (it starts with the container) because the env
|
|
268
|
+
// and the argv below depend on whether the attempt is armed.
|
|
269
|
+
const inboxDir = `${prepared.jobDir}.inbox`;
|
|
270
|
+
let inboxMade = false;
|
|
271
|
+
let relay = null;
|
|
272
|
+
// The stdin transport has no directory: its messages go down the pipe.
|
|
273
|
+
if (steerAsked && steerSkipped === null && !viaStdin) {
|
|
274
|
+
try {
|
|
275
|
+
inboxFs.rmSync(inboxDir, { recursive: true, force: true });
|
|
276
|
+
inboxFs.mkdirSync(inboxDir, { mode: 0o700 });
|
|
277
|
+
inboxMade = true;
|
|
278
|
+
} catch {
|
|
279
|
+
steerSkipped = "no-inbox";
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (steerAsked && steerSkipped === null) {
|
|
283
|
+
try {
|
|
284
|
+
// `runId` is the RUN's id (`jobId` here is `runIdOf(job)`, index.mjs), so a #645 recovery writes the same inbox.
|
|
285
|
+
// Issue #686: the stdin transport's relay MACs each frame under this attempt's key and the container's PI_JOB_ID
|
|
286
|
+
// (`name`, as the ack's `ackRunId`); a binding venue's relay is opened with exactly the arguments it always was.
|
|
287
|
+
relay = openInboxRelay({ runId: jobId, jobId, inboxDir: viaStdin ? null : inboxDir, ...steerTargetOf(job), ...(viaStdin ? { frames: { exitKey, macRunId: name } } : {}) }) ?? null;
|
|
288
|
+
} catch {
|
|
289
|
+
relay = null;
|
|
290
|
+
}
|
|
291
|
+
if (relay === null) steerSkipped = "no-transcript";
|
|
292
|
+
}
|
|
293
|
+
const steerArmed = steerAsked && steerSkipped === null;
|
|
294
|
+
// Issue #686: this attempt's key goes down stdin as frame 1 and the pipe stays OPEN for the relay's frames.
|
|
295
|
+
const stdinSteered = steerArmed && viaStdin;
|
|
296
|
+
if (steerSkipped !== null) log("steer_skipped", { container: name, reason: steerSkipped });
|
|
297
|
+
// The ack consumer, handed to the live tail below. It verifies under THIS attempt's exit key, against the very string
|
|
298
|
+
// put in the container's PI_JOB_ID (`jobId: name` in the env below), which is what the runner MACs: one value on both
|
|
299
|
+
// sides by construction. The per-attempt key is the domain separation between attempts; the string only has to match.
|
|
300
|
+
const steerAck = steerArmed ? makeSteerAck({ exitKey, ackRunId: name, relay, jobId, log }) : null;
|
|
301
|
+
|
|
302
|
+
const removeInbox = () => {
|
|
303
|
+
if (!inboxMade) return;
|
|
304
|
+
try {
|
|
305
|
+
inboxFs.rmSync(inboxDir, { recursive: true, force: true });
|
|
306
|
+
} catch {
|
|
307
|
+
// best effort: a leftover directory holds framed text the worker wrote, beside the job dir the sweep removes
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
103
311
|
// Closed env allowlist: only the provider key + the declared PI_* vars. Throws (config) if
|
|
104
312
|
// the provider is unconfigured, which the processor turns into a policy refusal that refunds the
|
|
105
313
|
// reserve. That sentence was aspirational until issue #310: the processor did not read the
|
|
106
314
|
// `piDispatchConfig` tag at all, so the throw fell through to a bare rethrow with the budget kept.
|
|
107
315
|
// The common case no longer reaches here either, because the processor probes the same resolution
|
|
108
316
|
// among its free gates, ahead of the mint and the clone.
|
|
109
|
-
|
|
317
|
+
// Issue #646: a throw from here to the spawn (an unconfigured provider, a refused argv, a network that would not build)
|
|
318
|
+
// happens before the `finally` below exists, so the inbox directory made above is removed on the way out.
|
|
319
|
+
const beforeSpawn = (fn) => {
|
|
320
|
+
try {
|
|
321
|
+
return fn();
|
|
322
|
+
} catch (error) {
|
|
323
|
+
removeInbox();
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
// Issue #796: a canary's candidate in place of the staged copy, by this layout's own `/job` path, so that it means the same on every venue.
|
|
328
|
+
const canaryPlan = prepared.packageCanary ?? null;
|
|
329
|
+
const withCanary = (list) => (canaryPlan ? withCanaryPath(list, canaryPlan, paths.job).paths : list);
|
|
330
|
+
const canaryPathOf = () => (canaryPlan ? withCanaryPath([], canaryPlan, paths.job).candidate : undefined);
|
|
331
|
+
// The staged set, read once for this job (issue #102), so the rows that name a package below are the read that chose
|
|
332
|
+
// its path. `!== false`, because staged packages LOAD unless a trigger explicitly opts out
|
|
333
|
+
// (INT-TRIGGERS-FILE-CONTRACT). The strictness that used to live in this `=== true` did not disappear, it moved:
|
|
334
|
+
// parseTriggers refuses any non-boolean run.packages fail-loud at load, so a hand-edited string "false" never becomes
|
|
335
|
+
// job data this comparison could misread as an opt-out. The opt-out short-circuits BEFORE the resolver runs: a trigger
|
|
336
|
+
// that withheld the staged set has no reason to make the worker read the manifest on its behalf.
|
|
337
|
+
const everyStaged = beforeSpawn(() => (job.packages === false ? [] : typeof packagePaths === "function" ? packagePaths() : packagePaths));
|
|
338
|
+
// Issue #800: run.excludeExtensions, by the manifest name of the read that chose these paths. Here and not in the resolver, which has
|
|
339
|
+
// no job. A path this cannot name is refused rather than loaded: a resolver with no `rowOf` is no wired worker, and loading what the
|
|
340
|
+
// file takes away is the one answer this must never give. The processor reads the tag as a free refusal.
|
|
341
|
+
const excludedNames = Array.isArray(job.excludeExtensions) ? job.excludeExtensions : [];
|
|
342
|
+
const stagedRowOf = (p) => (typeof packagePaths === "function" ? (packagePaths.rowOf?.(p) ?? null) : null);
|
|
343
|
+
const stagedPaths = excludedNames.length === 0 ? everyStaged : beforeSpawn(() => everyStaged.filter((p) => {
|
|
344
|
+
const row = stagedRowOf(p);
|
|
345
|
+
if (row === null) throw configError("run.excludeExtensions names extensions, and this worker cannot tell which staged package a path is");
|
|
346
|
+
return !excludedNames.includes(row.name);
|
|
347
|
+
}));
|
|
348
|
+
// Issue #683: the staged packages' container paths are the overlay's, so on the sandbox layout their prefix moves with
|
|
349
|
+
// it (`/opt/pi-global` to `/sandbox/pi-global`). A path not under the overlay is passed as it was.
|
|
350
|
+
const movedPath = (p) => (layout !== "mounts" && typeof p === "string" && p.startsWith(`${CONTAINER_GLOBAL_PI_DIR}/`) ? `${paths.global}${p.slice(CONTAINER_GLOBAL_PI_DIR.length)}` : p);
|
|
351
|
+
const jobPackagePaths = withCanary(stagedPaths.map(movedPath));
|
|
352
|
+
// Issue #800: what the runner's `loaded` is checked against (loaded-record.mjs), from what this attempt hands the job.
|
|
353
|
+
const stagedRows = new Map(stagedPaths.map((p) => [movedPath(p), stagedRowOf(p)]));
|
|
354
|
+
let overlayExtensions = [];
|
|
355
|
+
let overlayListed = true;
|
|
356
|
+
try {
|
|
357
|
+
overlayExtensions = globalPiDir ? readOverlayExtensions(join(globalPiDir, "extensions")) : [];
|
|
358
|
+
} catch (error) {
|
|
359
|
+
overlayExtensions = [];
|
|
360
|
+
// No folder is no extensions, as `readOverlayExtensionRows` reads it: an overlay of packages alone holds none, so a typo there is a name this host does
|
|
361
|
+
// not hold, and reading it as could-not-say would record it as left out. Only a read that failed some other way holds nothing to vouch for.
|
|
362
|
+
overlayListed = error?.code === "ENOENT";
|
|
363
|
+
}
|
|
364
|
+
// Issue #800: which of those names this host holds neither way, for the record. Only where both sides were read: a job with its packages
|
|
365
|
+
// withheld never reads the stage, a manifest that did not read says nothing is staged only by accident, and a folder that could not be listed
|
|
366
|
+
// holds nothing this can vouch for, so none of them says a name is absent. `=== false`, so an array of paths or a resolver with no such word stands as read.
|
|
367
|
+
const stageRead = typeof packagePaths !== "function" || packagePaths.stageRead?.() !== false;
|
|
368
|
+
const excludedNotHere = job.packages !== false && overlayListed && stageRead ? extensionsNotHere(excludedNames, { packages: everyStaged.map(stagedRowOf), overlay: overlayExtensions }) : [];
|
|
369
|
+
const excludedTools = Array.isArray(job.excludeExtensionTools) ? job.excludeExtensionTools : [];
|
|
370
|
+
// Whether a name in run.excludeExtensionTools that no staged package or overlay extension brought stops this job (`exclude-tool-not-loaded`): only one that should have
|
|
371
|
+
// held every extension it could. A trigger withholding packages or leaving one out, a host with the overlay shut, a chained child (its
|
|
372
|
+
// parent's list, for a job that may load less) and a restricted run may each lack the tool with the name right, so there it is only said.
|
|
373
|
+
const excludeToolsStrict = excludedTools.length > 0 && job.packages !== false && excludedNames.length === 0 && allowGlobalExtensions !== false && job.parentJobId === undefined && !(job.watch || job.answerDraft || job.questionGrouping);
|
|
374
|
+
const loadedKnown = {
|
|
375
|
+
packages: jobPackagePaths.map((p) => (p === canaryPathOf() ? parsePackageCanary(job.packageCanary) : (stagedRows.get(p) ?? null))),
|
|
376
|
+
overlayExtensions,
|
|
377
|
+
servers: Array.isArray(job.mcp) ? job.mcp : [],
|
|
378
|
+
ownTools: EXCLUDABLE_TOOL_NAMES,
|
|
379
|
+
// The closure's value, not the job's below: this worker's switch (the host's, or a canary replay's) and a restricted
|
|
380
|
+
// job's forced one are different statements.
|
|
381
|
+
leftOut: leftOutOf(job, { allowGlobalExtensions, excludedNotHere }),
|
|
382
|
+
// What the line's `unmatched` positions index: PI_EXCLUDE_EXTENSION_TOOLS, as this attempt hands it over.
|
|
383
|
+
excludeExtensionTools: excludedTools,
|
|
384
|
+
// A word from a closed list, never line text: the line is what is not trusted here.
|
|
385
|
+
onViolation: (why) => log("loaded_invalid", { container: name, why }),
|
|
386
|
+
// How many and no name: the record says only that a name was left out, and this says why.
|
|
387
|
+
onWithheld: (count) => log("loaded_names_withheld", { container: name, count }),
|
|
388
|
+
};
|
|
389
|
+
const env = beforeSpawn(() => buildContainerEnv({
|
|
390
|
+
size,
|
|
110
391
|
provider: job.provider,
|
|
392
|
+
...(job.answerDraft && job.answerDecisionModel ? { secondaryProvider: job.answerDecisionModel.provider } : job.questionGrouping && job.questionGroupModel ? { secondaryProvider: job.questionGroupModel.provider } : job.watch && job.watchDecisionModel ? { secondaryProvider: job.watchDecisionModel.provider } : {}),
|
|
111
393
|
model: job.model,
|
|
394
|
+
fork: job.fork ?? null,
|
|
395
|
+
thinkingLevel: job.thinkingLevel ?? null,
|
|
112
396
|
maxTurns: job.maxTurns,
|
|
113
397
|
maxTokens: job.maxTokens, // optional per-job token budget (issue #25); undefined => runner meter only
|
|
114
398
|
maxCostMicros: job.maxCostMicros ?? null, // issue #501: effectiveJobOf's min of trigger and deployment; null => no cap
|
|
@@ -120,21 +404,20 @@ export function makeRunContainer({
|
|
|
120
404
|
hostEnv,
|
|
121
405
|
egress, // REQ-EGRESS-ALLOWLIST: emits HTTPS_PROXY/HTTP_PROXY/NO_PROXY/NODE_USE_ENV_PROXY, or nothing
|
|
122
406
|
egressProxy,
|
|
123
|
-
allowGlobalExtensions
|
|
407
|
+
allowGlobalExtensions: job.answerDraft || job.watch || job.questionGrouping ? false : allowGlobalExtensions,
|
|
124
408
|
// REQ-GLOBAL-PI-OVERLAY: the per-job value comes off `job` (like maxTurns), the staged set off
|
|
125
409
|
// the closure (like allowGlobalExtensions) -- so a trigger can withhold what the operator staged.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
sessionFile: prepared.session ? CONTAINER_SESSION_FILE : undefined,
|
|
410
|
+
packagePaths: jobPackagePaths,
|
|
411
|
+
packageCanaryPath: canaryPathOf(),
|
|
412
|
+
forwardEnv: job.answerDraft || job.watch || job.questionGrouping ? [] : forwardEnv,
|
|
413
|
+
// REQ-RESUMABLE-SESSION: the fixed container path, emitted only when this job HAS a transcript file, which
|
|
414
|
+
// since issue #643 is every prepared job (the live file), resume or not. The constant is imported rather than
|
|
415
|
+
// re-typed so the mount below and this variable name one path -- two literals is how they drift with both
|
|
416
|
+
// suites green. On an empty file the runner opens a fresh session there (image/runner/src/session.mjs), the
|
|
417
|
+
// same path a cold-starting resume job takes, so the image needs no change.
|
|
418
|
+
// Issue #683: under the layout's session directory, which on `mounts` is CONTAINER_SESSION_FILE exactly.
|
|
419
|
+
sessionFile: transcriptDir ? (layout === "mounts" ? CONTAINER_SESSION_FILE : `${paths.session}/current.jsonl`) : undefined,
|
|
420
|
+
layout,
|
|
138
421
|
// Issue #189: the flow name, structurally, so the runner can verify it against the loaded
|
|
139
422
|
// skill set. Off `job` like maxTurns; absent (a bare run.task cron job) emits no variable.
|
|
140
423
|
flow: typeof job.flow === "string" && job.flow.trim() !== "" ? job.flow : undefined,
|
|
@@ -146,15 +429,33 @@ export function makeRunContainer({
|
|
|
146
429
|
// non-empty validated array; the guard is the same defensive shape `flow` above wears, so a
|
|
147
430
|
// hand-built job with junk in the field emits no variable rather than an empty one.
|
|
148
431
|
excludeTools: Array.isArray(job.excludeTools) && job.excludeTools.length > 0 ? job.excludeTools : undefined,
|
|
432
|
+
excludeExtensions: excludedNames.length > 0 ? excludedNames : undefined,
|
|
433
|
+
excludeExtensionTools: excludedTools.length > 0 ? excludedTools : undefined,
|
|
434
|
+
excludeToolsStrict,
|
|
149
435
|
// REQ-MCP-OPERATOR-SERVERS: the trigger's MCP server names, off `job` like excludeTools.
|
|
150
436
|
mcpServers: Array.isArray(job.mcp) && job.mcp.length > 0 ? job.mcp : undefined,
|
|
151
437
|
// Issue #625: whether the runner offers the tool a parked run ends on. Only beside a staged transcript: the
|
|
152
438
|
// runner refuses the flag without one (await-input-without-session), so a job whose resume degraded to no
|
|
153
439
|
// mount is not armed into that refusal.
|
|
154
440
|
awaitInput: job.awaitInput === true && prepared.session ? true : undefined,
|
|
441
|
+
approvalGates: job.gates ?? null,
|
|
442
|
+
approvalDecision: job.approvalDecision ?? null,
|
|
443
|
+
// Issue #644: whether the runner offers `report_progress`, off `job` like awaitInput (env-allowlist.mjs maps it to
|
|
444
|
+
// PI_REPORT_PROGRESS=1). The worker reads the phases it reports only for the same job (`job.progress` below).
|
|
445
|
+
reportProgress: job.progress === true ? true : undefined,
|
|
446
|
+
// Issue #645, durable runs: off the call bag, NOT off `job`. Whether this attempt keeps checkpoints and whether it
|
|
447
|
+
// is the continuation of a lost one are the processor's decisions, made against the checkpoint store and the
|
|
448
|
+
// image's `recover` capability; a `job.recover` read here would arm a container the processor never cleared.
|
|
449
|
+
// Strict `=== true` both ways, so a truthy non-boolean arms nothing.
|
|
450
|
+
recover: recover === true,
|
|
451
|
+
recovering: recovering === true,
|
|
452
|
+
// Issue #646: PI_STEER=1, only for an ARMED attempt (above), never off `job.steer` alone: the runner refuses the
|
|
453
|
+
// flag without a readable /inbox and a key, and a job the worker could not arm must not be refused for it.
|
|
454
|
+
steer: steerArmed,
|
|
155
455
|
// Issue #502: the effective allowed-model list, off `job` like excludeTools. index.mjs `effectiveJobOf` folds
|
|
156
456
|
// the deployment's PI_ALLOWED_MODELS in under the trigger's own, so `job.models` is already the one list.
|
|
157
457
|
allowedModels: Array.isArray(job.models) && job.models.length > 0 ? job.models : null,
|
|
458
|
+
subscriptionAuth,
|
|
158
459
|
authFromPi, // source the provider key from pi's auth.json when the env has none
|
|
159
460
|
// REQ-TRIGGER-SECRETS: this trigger's resolved secrets, fetched by the processor BEFORE anything
|
|
160
461
|
// spent. Off the call bag rather than off `job` or the closure: it is neither a per-job fact the
|
|
@@ -163,7 +464,22 @@ export function makeRunContainer({
|
|
|
163
464
|
home: user !== null ? home : null, // issue #341: HOME only beside --user, assigned after the forward loops
|
|
164
465
|
modelEndpoints, // issue #503: the keyless branch of the credential, from the gate's own snapshot
|
|
165
466
|
exitAuth: exitKey !== null, // issue #545: PI_EXIT_AUTH=stdin, only when a key is actually handed over below
|
|
166
|
-
|
|
467
|
+
// Issue #686: PI_TRANSCRIPT=stderr, only for an attempt armed above, never off the image's label alone: the
|
|
468
|
+
// worker must be reading the records, or they would reach the job log as noise.
|
|
469
|
+
transcriptStderr: stdioArmed,
|
|
470
|
+
// Issue #686: PI_EXIT_AUTH=stdin-frames, only for an attempt armed above for stdin steering (beside PI_STEER=1).
|
|
471
|
+
steerStdin: stdinSteered,
|
|
472
|
+
bundleEnvFile: bundleEnvFile === true && Array.isArray(bundleKeys?.plain) && bundleKeys.plain.length > 0,
|
|
473
|
+
observableRunner: observableRunner === true,
|
|
474
|
+
}));
|
|
475
|
+
// Issue #800: a tool name is the one string on the line nothing here can compare with anything, so at least it must
|
|
476
|
+
// not hold this job's own secrets. Built off THIS job's env, as the transcript's is below. If it cannot be built, nothing
|
|
477
|
+
// can be judged: every name reads as one it would change and is withheld, and a `pi` reads as invalid, rather than as fine.
|
|
478
|
+
try {
|
|
479
|
+
loadedKnown.redact = makeRedactor({ values: secretValuesOf(env, [token, exitKey], { workerSet: egressEnv({ proxy: egressProxy, armed: egress }) }) });
|
|
480
|
+
} catch {
|
|
481
|
+
loadedKnown.redact = () => "";
|
|
482
|
+
}
|
|
167
483
|
|
|
168
484
|
// `-net` on this container's own name (egress.mjs). null when no policy is armed, and docker-run's
|
|
169
485
|
// guard then omits the flag entirely, so the argv is byte-identical to one built before this feature.
|
|
@@ -172,6 +488,9 @@ export function makeRunContainer({
|
|
|
172
488
|
// Issue #345: BESIDE the job directory, never inside the `/job:ro` mount. Removed first because the docker CLI refuses
|
|
173
489
|
// to start with an existing cidfile; every attempt's job dir is a fresh mkdtemp, so a leftover is not expected, and
|
|
174
490
|
// this keeps one from turning into a start failure if a caller ever reuses a directory.
|
|
491
|
+
// env-internal PI_MODEL_ROLES_FILE: closed read-only job input, set by the worker.
|
|
492
|
+
// env-internal PI_AGENTS_FILE: base-commit agent snapshot beside the role map.
|
|
493
|
+
if (job.modelRoles) { env.PI_MODEL_ROLES_FILE = `${paths.job}/model-roles.json`; env.PI_AGENTS_FILE = `${paths.job}/agents.json`; }
|
|
175
494
|
const cidFile = `${prepared.jobDir}.cid`;
|
|
176
495
|
try {
|
|
177
496
|
fs.rmSync(cidFile, { force: true });
|
|
@@ -179,7 +498,7 @@ export function makeRunContainer({
|
|
|
179
498
|
// an unremovable stale file makes the run itself fail to start, which is reported as that
|
|
180
499
|
}
|
|
181
500
|
|
|
182
|
-
const
|
|
501
|
+
const buildOpts = {
|
|
183
502
|
// Same split as packagePaths above: the per-job value off `job`, the deployment value off the closure,
|
|
184
503
|
// so a trigger can name its own toolchain (INT-TRIGGERS-FILE-CONTRACT). Resolved through the SAME
|
|
185
504
|
// function the pre-spend preflight uses (image-preflight.mjs), so the tag that was checked is the tag
|
|
@@ -189,9 +508,11 @@ export function makeRunContainer({
|
|
|
189
508
|
jobDir: prepared.jobDir,
|
|
190
509
|
workspace: prepared.workspace,
|
|
191
510
|
outboxDir: prepared.outboxDir, // undefined for github jobs -> docker-run's guard skips the /outbox mount
|
|
192
|
-
// The job's OWN copy, under jobDir -- never the shared store. Undefined when
|
|
193
|
-
//
|
|
194
|
-
sessionDir:
|
|
511
|
+
// The job's OWN copy, under jobDir -- never the shared store. Undefined only when no transcript file was staged
|
|
512
|
+
// (a caller that predates issue #643, or a staging that failed), and docker-run's guard then skips the mount.
|
|
513
|
+
sessionDir: transcriptDir ?? undefined,
|
|
514
|
+
// Issue #646: the inbox, read-only at /inbox, for an armed attempt only; undefined leaves the argv byte-identical.
|
|
515
|
+
steerInboxDir: steerArmed && !stdinSteered ? inboxDir : undefined,
|
|
195
516
|
globalPiDir, // undefined/null -> docker-run's guard skips the /opt/pi-global mount
|
|
196
517
|
name,
|
|
197
518
|
size, // issue #596: memory, swap equal to it, the CPU weight and /dev/shm, through containerSpec's one function
|
|
@@ -207,15 +528,19 @@ export function makeRunContainer({
|
|
|
207
528
|
// Both facts, the kind the preparers branch on AND containment in this job's own directory, so a kind added later
|
|
208
529
|
// that works on a folder in place fails closed rather than relabelling it.
|
|
209
530
|
workspaceOwned: job?.kind !== "local" && insideDir(prepared.jobDir, prepared.workspace),
|
|
531
|
+
workspaceReadOnly: Boolean(job.answerDraft || job.watch || job.questionGrouping),
|
|
210
532
|
// Issue #545: `-i`, so the container's stdin is the pipe the key is written to. Absent otherwise, and the argv is
|
|
211
533
|
// byte-identical to one built before the signed exit line.
|
|
212
534
|
...(exitKey !== null ? { extraFlags: ["-i"] } : {}),
|
|
213
|
-
|
|
535
|
+
// Issue #704 part 3: absent without a bundle, so every other build is byte-identical.
|
|
536
|
+
...(bundleKeys !== null && typeof bundleKeys === "object" ? { bundleKeys } : {}),
|
|
537
|
+
};
|
|
538
|
+
const args = beforeSpawn(() => buildArgs(buildOpts));
|
|
214
539
|
|
|
215
540
|
// Issue #596: the memory bound this container actually got, in bytes, read off the argv the runtime is handed (both
|
|
216
541
|
// builders spell it `--memory=`, from the job's size), never re-derived from the environment or the size. The
|
|
217
542
|
// processor confirms an OOM only when the run's peak reached 90% of it. null when the argv names none.
|
|
218
|
-
const memoryLimit =
|
|
543
|
+
const memoryLimit = memoryLimitOf(args, buildOpts);
|
|
219
544
|
if (!args.some((a) => typeof a === "string" && a.startsWith("--cpus="))) log("cpu_ceiling_unknown", { container: name });
|
|
220
545
|
if (Array.isArray(unenforced) && unenforced.length > 0) log("size_bound_unenforced", { container: name, flags: unenforced.filter((f) => typeof f === "string" && /^--[a-z-]{1,32}$/.test(f)) });
|
|
221
546
|
|
|
@@ -228,20 +553,228 @@ export function makeRunContainer({
|
|
|
228
553
|
// A failure to build it is INFRA, not policy: nothing has been spent, a retry may well succeed, and
|
|
229
554
|
// `container-never-started` is literally true, so the reservation is given back (processor.mjs).
|
|
230
555
|
if (network && !(await createJobNetwork(spawnFn, { network, proxy: egressProxy, bin }))) {
|
|
556
|
+
removeInbox();
|
|
231
557
|
throw new InfraRetry("container-never-started", { reason: "container-never-started" });
|
|
232
558
|
}
|
|
233
559
|
|
|
560
|
+
// Issue #683: a copying venue's create and upload, before anything spawns the job. Its teardown is owed from the
|
|
561
|
+
// moment it begins, so a stage that fails half way (a sandbox made, an upload refused) is still deleted.
|
|
562
|
+
let staged = false;
|
|
563
|
+
const teardown = async () => {
|
|
564
|
+
if (!staged || typeof teardownRun !== "function") return;
|
|
565
|
+
try {
|
|
566
|
+
await teardownRun({ opts: buildOpts });
|
|
567
|
+
} catch {
|
|
568
|
+
// best effort: the reaper deletes what this left, by the job's label
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
if (typeof stageRun === "function") {
|
|
572
|
+
staged = true;
|
|
573
|
+
try {
|
|
574
|
+
// Issue #684: the job and the pickup's endpoint snapshot ride with it, for the venue's per-job network policy.
|
|
575
|
+
await stageRun({ args, opts: buildOpts, signal, job, modelEndpoints });
|
|
576
|
+
} catch (cause) {
|
|
577
|
+
await teardown();
|
|
578
|
+
removeInbox();
|
|
579
|
+
if (network) await removeJobNetwork(spawnFn, { network, proxy: egressProxy, bin });
|
|
580
|
+
if (cause instanceof InfraRetry) throw cause;
|
|
581
|
+
throw new InfraRetry("container-never-started", { cause, reason: "container-never-started" });
|
|
582
|
+
}
|
|
583
|
+
// A stop that landed while the venue was still creating or uploading (the job's timeout, a shutdown): its own
|
|
584
|
+
// stop found nothing to delete yet, so nothing would stop the exec. The run ends here, as stopped, and the
|
|
585
|
+
// teardown deletes what the stage made.
|
|
586
|
+
if (signal?.aborted) {
|
|
587
|
+
await teardown();
|
|
588
|
+
removeInbox();
|
|
589
|
+
if (network) await removeJobNetwork(spawnFn, { network, proxy: egressProxy, bin });
|
|
590
|
+
return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null, exitReason: null };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Issue #643: the live transcript publisher, opened only beside a transcript file, an opener and a job id. Its
|
|
595
|
+
// redactor is built from THIS job's env (the secret-bearing keys, redact-secrets.mjs) plus the two credentials that
|
|
596
|
+
// are not in it by name or at all: the minted token and the exit-line key. An opener that throws costs the stream,
|
|
597
|
+
// never the run.
|
|
598
|
+
let live = null;
|
|
599
|
+
// Question grouping reads the canonical (raw) transcript after promotion. Keep the exact job-env
|
|
600
|
+
// redactor available even when live streaming is disabled: token shapes alone miss forwarded values.
|
|
601
|
+
try { Object.defineProperty(prepared, "questionRedact", { value: makeRedactor({ values: secretValuesOf(env, [token, exitKey], { workerSet: egressEnv({ proxy: egressProxy, armed: egress }) }) }), configurable: true }); }
|
|
602
|
+
catch { log("question_redactor_unavailable", {}); }
|
|
603
|
+
if (job.watch) Object.defineProperty(prepared, "watchRedact", { value: makeRedactor({ values: secretValuesOf(env, [token, exitKey], { workerSet: egressEnv({ proxy: egressProxy, armed: egress }) }) }), configurable: true });
|
|
604
|
+
// Issue #645: a container whose unpause failed UNPAUSE_TRIES times never runs again, and the worker would wait on it
|
|
605
|
+
// for the whole job timeout. It is killed (SIGKILL reaches a frozen container: the runtime thaws it to deliver it),
|
|
606
|
+
// then removed if the kill was refused, and the run ends as an infrastructure exit (`checkpointStopped`).
|
|
607
|
+
let checkpointStopped = false;
|
|
608
|
+
const stopFrozen = async () => {
|
|
609
|
+
checkpointStopped = true;
|
|
610
|
+
log("checkpoint_container_stopped", { container: name });
|
|
611
|
+
const killed = await dockerStep(spawnFn, ["kill", name], checkpointStopTimeoutMs, bin);
|
|
612
|
+
if (killed.code !== 0) await dockerStep(spawnFn, ["rm", "-f", name], checkpointStopTimeoutMs, bin);
|
|
613
|
+
};
|
|
614
|
+
// Issue #686: on a copying venue the host file is the upload's copy, never written while the job runs, so it is not
|
|
615
|
+
// tailed: the transcript is the stderr records' (`stdioArmed`) or there is none (`liveSkipped`).
|
|
616
|
+
if (liveReadable && liveSkipped === null && (stdioArmed || transcriptDir)) {
|
|
617
|
+
try {
|
|
618
|
+
// `workerSet`: the proxy variables count as structural only with the values this worker wrote for this job's
|
|
619
|
+
// egress; a proxy URL an operator forwarded with PI_EGRESS=0 is redacted like any other forwarded value.
|
|
620
|
+
const workerSet = egressEnv({ proxy: egressProxy, armed: egress });
|
|
621
|
+
// `job` (issue #644) for its two switches, `progress` (read phases) and `statusComment` (the forge comment).
|
|
622
|
+
// `checkpoint` (issue #645): the open args this side adds to the processor's, the container's name and venue
|
|
623
|
+
// verbs, the workspace and job dir, and `onStop` for an unpause that never took. Only for an armed job.
|
|
624
|
+
const checkpointArgs = armed
|
|
625
|
+
? {
|
|
626
|
+
...checkpoint,
|
|
627
|
+
containerName: name,
|
|
628
|
+
pause: typeof pause === "function" ? pause : null,
|
|
629
|
+
unpause: typeof unpause === "function" ? unpause : null,
|
|
630
|
+
workspace: prepared.workspace ?? null,
|
|
631
|
+
jobDir: prepared.jobDir ?? null,
|
|
632
|
+
onStop: () => stopFrozen(),
|
|
633
|
+
}
|
|
634
|
+
: null;
|
|
635
|
+
// The stdio source has no file; a checkpoint copies a host file, so none is opened on it (a copying venue refuses
|
|
636
|
+
// `run.recover` before spend). The steer acks ride it as they ride a tailed file (issue #686): the records are
|
|
637
|
+
// the session's lines, so a delivered message's `custom_message` reaches the ack consumer the same way.
|
|
638
|
+
const source = stdioArmed ? { source: "stdio", file: null } : { file: join(transcriptDir, LIVE_FILE_NAME) };
|
|
639
|
+
live = openLiveTranscript({ ...source, jobId, redact: makeRedactor({ values: secretValuesOf(env, [token, exitKey], { workerSet }) }), job, ...(checkpointArgs && !stdioArmed ? { checkpoint: checkpointArgs } : {}), ...(steerAck ? { steer: steerAck } : {}) }) ?? null;
|
|
640
|
+
} catch {
|
|
641
|
+
live = null;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// Issue #644: no publisher means no progress, so a trigger's `run.statusComment` cannot be honoured. Said once, by
|
|
645
|
+
// reason: both switches off (no opener), or no transcript file or job id to read progress from. Issue #686: a
|
|
646
|
+
// copying venue's skipped transcript is `no-transcript` too (its own reason is on `live_transcript_skipped`); before
|
|
647
|
+
// the openshell bundle was handed the opener it read `progress-off` there, which named the wrong switch.
|
|
648
|
+
if (job?.statusComment === true && live === null) log("status_comment_skipped", { jobId: jobId ?? null, reason: typeof openLiveTranscript === "function" ? "no-transcript" : "progress-off" });
|
|
649
|
+
// Bounded and never throwing (live-transcript.mjs); called at most once, and always BEFORE this function resolves,
|
|
650
|
+
// so before the processor's `finally` removes the job directory the file lives in.
|
|
651
|
+
const finishLive = async (outcome) => {
|
|
652
|
+
try {
|
|
653
|
+
await live?.finish(outcome);
|
|
654
|
+
} catch {}
|
|
655
|
+
};
|
|
656
|
+
// Issue #646: an armed attempt whose tail could not be opened can deliver messages that no ack will ever count; they
|
|
657
|
+
// settle as late. Said, never silent.
|
|
658
|
+
if (steerArmed && live === null) log("steer_acks_unread", { container: name });
|
|
659
|
+
// Issue #646: the relay's end, AFTER the tail's final read (`finishLive`), so a last-moment ack is handed to the relay
|
|
660
|
+
// and marked before the liveness keys go; the settle step's close (index.mjs) then sees it as delivered. Once.
|
|
661
|
+
let relayStopping = null;
|
|
662
|
+
const stopRelay = () => {
|
|
663
|
+
relayStopping ??= (async () => {
|
|
664
|
+
try {
|
|
665
|
+
await steerAck?.drain();
|
|
666
|
+
} catch {}
|
|
667
|
+
try {
|
|
668
|
+
await relay?.stop();
|
|
669
|
+
} catch {}
|
|
670
|
+
})();
|
|
671
|
+
return relayStopping;
|
|
672
|
+
};
|
|
673
|
+
// Issue #645: the checkpoint handle the opener built for an armed job, or null. Never throws (run-checkpoint.mjs).
|
|
674
|
+
const ckpt = live?.checkpoint && typeof live.checkpoint.close === "function" ? live.checkpoint : null;
|
|
675
|
+
const closeCheckpoint = async () => {
|
|
676
|
+
try {
|
|
677
|
+
return await ckpt?.close();
|
|
678
|
+
} catch {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
// What the processor is handed: `exit()` once the container is dead (an infra exit of an armed job), `close()`
|
|
683
|
+
// before its cleanup, `stats()` for the record's counts. Spread only when there is a handle, so every other run
|
|
684
|
+
// resolves the very object it always did.
|
|
685
|
+
const checkpointResult = ckpt
|
|
686
|
+
? {
|
|
687
|
+
checkpoint: {
|
|
688
|
+
exit: (opts) => ckpt.exit(opts),
|
|
689
|
+
close: () => closeCheckpoint(),
|
|
690
|
+
stats: () => ckpt.stats(),
|
|
691
|
+
},
|
|
692
|
+
}
|
|
693
|
+
: {};
|
|
694
|
+
if (live !== null && typeof onLive === "function") {
|
|
695
|
+
try {
|
|
696
|
+
onLive(finishLive);
|
|
697
|
+
} catch {}
|
|
698
|
+
}
|
|
699
|
+
|
|
234
700
|
// Host-side per-job log sink, teed off `onOutput`. `name` is `pi-job-<jobId>`; the sink
|
|
235
701
|
// sanitizes internally. No container mount, no env var -- the sink lives on this side only.
|
|
236
|
-
|
|
702
|
+
// Issue #683: a staged venue's teardown is owed from here, so a synchronous throw before the `try` below still
|
|
703
|
+
// deletes what the stage made.
|
|
704
|
+
let sink;
|
|
705
|
+
try {
|
|
706
|
+
sink = openJobLog(name, { exitKey, ...(job.watch || job.answerDraft ? { suppressCapture: true } : {}) });
|
|
707
|
+
} catch (error) {
|
|
708
|
+
await teardown();
|
|
709
|
+
removeInbox();
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
237
712
|
// Issue #596: the head of the CLI's stderr, kept only to recognise the runtime's refusal of a stale `--cpus`
|
|
238
713
|
// (`cpuRangeRefusal`). Bounded, and never logged: only the count it names leaves this function.
|
|
239
714
|
let stderrHead = "";
|
|
240
715
|
const STDERR_HEAD_MAX = 4096;
|
|
716
|
+
// Issue #686: an armed attempt's stderr goes through the record demux first. Records are verified under THIS
|
|
717
|
+
// attempt's exit key against the string in the container's PI_JOB_ID (`name`, as the steer ack's `ackRunId`), and
|
|
718
|
+
// their lines pushed into the publisher; every other byte is passed on unchanged to the tee and the head below.
|
|
719
|
+
// Demuxed even when the opener returned no publisher, so the records never reach the job log or the terminal.
|
|
720
|
+
const demux = stdioArmed
|
|
721
|
+
? makeDemux({
|
|
722
|
+
key: exitKey,
|
|
723
|
+
runId: name,
|
|
724
|
+
onBaseline: (size) => live?.setBaseline?.(size),
|
|
725
|
+
onLine: (line, end) => live?.push?.(line, end),
|
|
726
|
+
onGap: (missing) => live?.skip?.(missing),
|
|
727
|
+
onReset: (size) => live?.reset?.(size),
|
|
728
|
+
})
|
|
729
|
+
: null;
|
|
730
|
+
let demuxEnded = false;
|
|
731
|
+
// A demux that threw is not asked again for this attempt (none is known to throw). Its error carries what that chunk
|
|
732
|
+
// had already passed on and the part of it not yet read, so the output is exactly: those passed bytes, then what it
|
|
733
|
+
// held (`end()`), then the unread rest, then every later chunk as it comes. No byte twice, and no completed
|
|
734
|
+
// record's bytes. A throw that carries neither (not the demux's own) passes the chunk on whole.
|
|
735
|
+
let demuxFailed = false;
|
|
736
|
+
const endDemux = () => {
|
|
737
|
+
if (demux === null || demuxEnded) return Buffer.alloc(0);
|
|
738
|
+
demuxEnded = true;
|
|
739
|
+
const counts = demux.stats();
|
|
740
|
+
// Counts only, never a payload: a forged record is the agent's text.
|
|
741
|
+
if (counts.unverified > 0) log("transcript_record_unverified", { container: name, count: counts.unverified });
|
|
742
|
+
if (counts.replayed > 0 || counts.gaps > 0 || counts.oversize > 0) log("transcript_record_dropped", { container: name, replayed: counts.replayed, gaps: counts.gaps, oversize: counts.oversize });
|
|
743
|
+
return demux.end();
|
|
744
|
+
};
|
|
241
745
|
|
|
242
746
|
const run = new Promise((resolve, reject) => {
|
|
243
747
|
const child = spawnFn(bin, args, { stdio: [exitKey !== null ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
244
|
-
|
|
748
|
+
// Issue #686: a stdin-steered attempt's key is frame 1, written at the spawn, and the pipe is NOT closed: it is
|
|
749
|
+
// handed to the relay, which writes each message as a frame after it and closes the pipe at its stop. The relay
|
|
750
|
+
// writes nothing before the publisher's baseline (the runner's signed baseline record and the lines up to it, so a
|
|
751
|
+
// resumed transcript's acks are known first). The runner reads exactly frame 1 before pi loads, so the key is out
|
|
752
|
+
// of the pipe before any tool exists, as on every other attempt.
|
|
753
|
+
if (stdinSteered) {
|
|
754
|
+
child.stdin?.on("error", () => {});
|
|
755
|
+
const first = keyFrame(exitKey);
|
|
756
|
+
try {
|
|
757
|
+
child.stdin?.write(first);
|
|
758
|
+
} catch {}
|
|
759
|
+
relay?.attach?.(child.stdin, { written: first.length });
|
|
760
|
+
// The baseline, when this side already knows it: a job with no staged session (not a resume, not a recovery)
|
|
761
|
+
// uploads the empty live file the preparer made, so its transcript has no prior line and nothing to
|
|
762
|
+
// re-baseline against. Released here, at the spawn, rather than when the runner's baseline record comes back
|
|
763
|
+
// over stderr (about 600 ms in, measured on Docker): a message queued before the start then reaches the runner
|
|
764
|
+
// before its first agent_start, where pi polls its steering queue once before the first model call, instead
|
|
765
|
+
// of racing the run. A staged transcript waits for the record as before (the ack's `onBaseline` is once only).
|
|
766
|
+
if (!prepared.session && recover !== true) {
|
|
767
|
+
try {
|
|
768
|
+
steerAck?.onBaseline();
|
|
769
|
+
} catch {}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
live?.start();
|
|
774
|
+
} catch {}
|
|
775
|
+
// Issue #646: registered live with the container, so a push is accepted only while there is a run to read it.
|
|
776
|
+
void relay?.start();
|
|
777
|
+
if (exitKey !== null && !stdinSteered) {
|
|
245
778
|
// Written and closed at once: the runner drains stdin to EOF before anything else runs, so the key is out of
|
|
246
779
|
// the pipe before any tool exists. A CLI that died first makes this EPIPE, which is the run failing, reported
|
|
247
780
|
// by `error` or `close` below; the listener only keeps the write's own error from crashing the worker.
|
|
@@ -250,25 +783,122 @@ export function makeRunContainer({
|
|
|
250
783
|
}
|
|
251
784
|
// A throwing sink.write is swallowed so a misbehaving sink cannot break the tee or hang the run.
|
|
252
785
|
const tee = (chunk) => {
|
|
253
|
-
onOutput(chunk);
|
|
786
|
+
if (!job.watch && !job.answerDraft) onOutput(chunk);
|
|
254
787
|
try {
|
|
255
788
|
sink.write(chunk);
|
|
256
789
|
} catch {}
|
|
257
790
|
};
|
|
258
791
|
child.stdout?.on("data", tee);
|
|
259
|
-
|
|
792
|
+
// Review round 1, M1: a stdin-steered attempt's pipe is ended as soon as the runner's exit line is out, so a CLI
|
|
793
|
+
// that waited for EOF before it exits could never wait on the relay (OpenShell 0.1.4 does not wait, measured on
|
|
794
|
+
// the real gateway; this is the defence, and the close at the relay's stop stays). A line that VERIFIES under the
|
|
795
|
+
// attempt's key ends it at once; an exit-shaped line that does not (the supervisor's unkeyed one, a forgery) ends
|
|
796
|
+
// it after `stdinExitGraceMs`, so a forged line can only cut steering short, never hold the pipe open. Lines are
|
|
797
|
+
// cut on newlines from a bounded buffer; a line past the bound is skipped.
|
|
798
|
+
if (stdinSteered) {
|
|
799
|
+
let held = "";
|
|
800
|
+
let graceTimer = null;
|
|
801
|
+
const STDIN_LINE_MAX = 256 * 1024;
|
|
802
|
+
let stdinClosed = false;
|
|
803
|
+
// Said once, by which line ended it: a pipe ended early is steering cut short for the rest of the attempt, and
|
|
804
|
+
// every message accepted after it settles `late` (the relay writes nothing more).
|
|
805
|
+
const closeNow = (reason) => {
|
|
806
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
807
|
+
graceTimer = null;
|
|
808
|
+
if (stdinClosed) return;
|
|
809
|
+
stdinClosed = true;
|
|
810
|
+
log("steer_stdin_closed", { container: name, reason });
|
|
811
|
+
try {
|
|
812
|
+
relay?.closeStdin?.();
|
|
813
|
+
} catch {}
|
|
814
|
+
};
|
|
815
|
+
child.stdout?.on("data", (chunk) => {
|
|
816
|
+
held += String(chunk);
|
|
817
|
+
let nl = held.indexOf("\n");
|
|
818
|
+
while (nl !== -1) {
|
|
819
|
+
const line = held.slice(0, nl);
|
|
820
|
+
held = held.slice(nl + 1);
|
|
821
|
+
if (line.includes('{"event":"exit"')) {
|
|
822
|
+
if (authenticExitLines(line, exitKey) !== "") closeNow("exit-verified");
|
|
823
|
+
else if (graceTimer === null && !stdinClosed) {
|
|
824
|
+
graceTimer = setTimeout(() => closeNow("exit-unverified"), stdinExitGraceMs);
|
|
825
|
+
graceTimer.unref?.();
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
nl = held.indexOf("\n");
|
|
829
|
+
}
|
|
830
|
+
if (held.length > STDIN_LINE_MAX) held = "";
|
|
831
|
+
});
|
|
832
|
+
child.on("close", () => {
|
|
833
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
const stderrOut = (chunk) => {
|
|
837
|
+
if (chunk.length === 0) return;
|
|
260
838
|
if (stderrHead.length < STDERR_HEAD_MAX) stderrHead = (stderrHead + String(chunk)).slice(0, STDERR_HEAD_MAX);
|
|
261
839
|
tee(chunk);
|
|
840
|
+
};
|
|
841
|
+
child.stderr?.on("data", (chunk) => {
|
|
842
|
+
if (demux === null || demuxFailed) {
|
|
843
|
+
if (stderrHead.length < STDERR_HEAD_MAX) stderrHead = (stderrHead + String(chunk)).slice(0, STDERR_HEAD_MAX);
|
|
844
|
+
tee(chunk);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
let out;
|
|
848
|
+
try {
|
|
849
|
+
out = demux.push(chunk);
|
|
850
|
+
} catch (error) {
|
|
851
|
+
demuxFailed = true;
|
|
852
|
+
log("transcript_demux_failed", { container: name });
|
|
853
|
+
const carried = Buffer.isBuffer(error?.passed) && Buffer.isBuffer(error?.rest);
|
|
854
|
+
out = carried ? Buffer.concat([error.passed, endDemux(), error.rest]) : Buffer.concat([endDemux(), Buffer.from(chunk)]);
|
|
855
|
+
}
|
|
856
|
+
stderrOut(out);
|
|
262
857
|
});
|
|
263
858
|
// docker not found / daemon down -- a transient infra fault, so tag it retryable
|
|
264
859
|
// (CONST-RETRY-INFRA-ONLY). `reason` also cues the processor to release the budget slot,
|
|
265
860
|
// since a container that never started spent nothing.
|
|
266
861
|
child.on("error", (err) => {
|
|
862
|
+
stderrOut(endDemux());
|
|
863
|
+
void closeCheckpoint();
|
|
864
|
+
void finishLive("failed").then(stopRelay);
|
|
267
865
|
sink.close().catch(() => {}); // best-effort teardown; a rejecting close cannot leak an unhandled rejection
|
|
268
866
|
reject(new InfraRetry("container-never-started", { cause: err, reason: "container-never-started" }));
|
|
269
867
|
});
|
|
270
868
|
child.on("close", async (code) => {
|
|
271
869
|
const aborted = signal?.aborted === true; // capture BEFORE the await
|
|
870
|
+
// Issue #686: the demux's held tail (a last line with no newline) goes out before anything closes, and every
|
|
871
|
+
// record it completed is in the publisher before the final flush below.
|
|
872
|
+
stderrOut(endDemux());
|
|
873
|
+
// Issue #686 part 3, review round 2: the venue's box ended and confirmed gone first (see `settleRun`).
|
|
874
|
+
let settled = null;
|
|
875
|
+
if (typeof settleRun === "function") {
|
|
876
|
+
try {
|
|
877
|
+
settled = (await settleRun({ opts: buildOpts, aborted })) === true;
|
|
878
|
+
} catch {
|
|
879
|
+
settled = false;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const lingered = settled === false ? { lingering: true } : {};
|
|
883
|
+
// Issue #645: no live checkpoint starts once the container is gone (a quiet point in the final read would
|
|
884
|
+
// pause a dead container). Closing first, and awaiting it after the final read, lets a live one in flight
|
|
885
|
+
// finish, its pointer included, before this promise resolves and the processor's cleanup can run.
|
|
886
|
+
const ckptClosed = closeCheckpoint();
|
|
887
|
+
// Issue #643, ORDER LOAD-BEARING: the transcript's last lines are read and published HERE, before the sink
|
|
888
|
+
// closes and before this promise resolves, because the processor removes the job directory (and the file in
|
|
889
|
+
// it) as soon as it has the result. The outcome is the exit's own reading, the most this side knows.
|
|
890
|
+
await finishLive(liveOutcomeOf(code, aborted, neverStartedExits));
|
|
891
|
+
// Issue #644: the run record's `progress`, read once the final snapshot was taken. Only when the opener ran the
|
|
892
|
+
// progress reducer for this job; absent otherwise, so every other run resolves the object it always did.
|
|
893
|
+
let progress = null;
|
|
894
|
+
try {
|
|
895
|
+
progress = live?.progressSummary?.() ?? null;
|
|
896
|
+
} catch {
|
|
897
|
+
progress = null;
|
|
898
|
+
}
|
|
899
|
+
const ranProgress = progress !== null && typeof progress === "object" ? { progress } : {};
|
|
900
|
+
await ckptClosed;
|
|
901
|
+
const stoppedForCheckpoint = checkpointStopped ? { checkpointStopped: true } : {};
|
|
272
902
|
// A rejecting sink.close is swallowed so a misbehaving sink cannot hang the run; turns/tokens/session/usage/context/exitReason fall back to null.
|
|
273
903
|
let turns = null;
|
|
274
904
|
let tokens = null;
|
|
@@ -279,23 +909,39 @@ export function makeRunContainer({
|
|
|
279
909
|
// RUNNER_POLICY_REASONS set and to a line that itself said code 2. The processor still decides
|
|
280
910
|
// the retry class from `code` alone; this only picks the label inside exit 2.
|
|
281
911
|
let exitReason = null;
|
|
912
|
+
let approval = null;
|
|
913
|
+
let answerDraftDigest = null;
|
|
914
|
+
let questionRefDigest = null;
|
|
282
915
|
// Issue #501 (PR #542's review, round 3): the decisive exit line's own `code` (decisiveExitLine), which the
|
|
283
916
|
// dollar settlement compares with the container's real exit code before it trusts that line's cost.
|
|
284
917
|
let exitLineCode = null;
|
|
285
918
|
// Issue #507: the cost guard's rule on a `cost-cap` line, already filtered by parseExitWhy to COST_CAP_WHYS.
|
|
286
919
|
let exitWhy = null;
|
|
920
|
+
let providerFailure = null;
|
|
921
|
+
let modelRoles = null;
|
|
922
|
+
let toolsStarted = null;
|
|
923
|
+
let replaySafe = null;
|
|
287
924
|
// Issue #545: null (no key issued), "verified" or "unverified" (a key, and no exit line carried it).
|
|
288
925
|
let exitAuthResult = null;
|
|
289
926
|
// Issue #596: what the container used, off its exit line (parseExitResources), or null.
|
|
290
927
|
let resources = null;
|
|
291
928
|
// Issue #596: the image's supervisor reported the runner killed for memory (parseExitOomKilled).
|
|
292
929
|
let exitOomKilled = false;
|
|
930
|
+
// Issue #645: a recovered run's runner counts (parseExitRecovery), `{ orphans, replayed, interrupted, notStarted }`
|
|
931
|
+
// each an integer or null, or null when the line carried none.
|
|
932
|
+
let recovery = null;
|
|
933
|
+
// Issue #646: a steer-armed runner's inbox counts (parseExitSteer), `{ delivered, pending }`, or null.
|
|
934
|
+
let steerCounts = null;
|
|
935
|
+
// Issue #796: what the runner said of a canary's candidate (parseExitCanaryLoad), or null.
|
|
936
|
+
let canaryLoad = null;
|
|
937
|
+
// Issue #800: the runner's `loaded` as it wrote it, off a verified line only, for `rebuildLoaded` below.
|
|
938
|
+
let lineLoaded = null;
|
|
293
939
|
try {
|
|
294
940
|
// `context = null` is a DEFAULT rather than a plain destructure: an injected sink that
|
|
295
941
|
// predates the field returns no such key, and `undefined` would then reach the record's
|
|
296
942
|
// shape where every other absence is spelled `null`.
|
|
297
943
|
// `exitReason` defaults the same way, for the same reason.
|
|
298
|
-
({ turns, tokens, session, usage, context = null, exitReason = null, exitLineCode = null, exitWhy = null, exitAuth: exitAuthResult = null, resources = null, exitOomKilled = false } = await sink.close());
|
|
944
|
+
({ canaryLoad = null, loaded: lineLoaded = null, turns, tokens, session, usage, context = null, exitReason = null, exitLineCode = null, exitWhy = null, modelRoles = null, providerFailure = null, toolsStarted = null, replaySafe = null, exitAuth: exitAuthResult = null, resources = null, exitOomKilled = false, recovery = null, steer: steerCounts = null, approval = null, answerDraftDigest = null, questionRefDigest = null } = await sink.close());
|
|
299
945
|
} catch {
|
|
300
946
|
turns = null;
|
|
301
947
|
tokens = null;
|
|
@@ -303,10 +949,19 @@ export function makeRunContainer({
|
|
|
303
949
|
usage = null;
|
|
304
950
|
context = null;
|
|
305
951
|
exitReason = null;
|
|
952
|
+
approval = null;
|
|
306
953
|
exitLineCode = null;
|
|
307
954
|
exitWhy = null;
|
|
955
|
+
providerFailure = null;
|
|
956
|
+
modelRoles = null;
|
|
957
|
+
toolsStarted = null;
|
|
958
|
+
replaySafe = null;
|
|
308
959
|
resources = null;
|
|
309
960
|
exitOomKilled = false;
|
|
961
|
+
recovery = null;
|
|
962
|
+
steerCounts = null;
|
|
963
|
+
canaryLoad = null;
|
|
964
|
+
lineLoaded = null;
|
|
310
965
|
// The sink could not say, and a key was issued: nothing it returned was verified.
|
|
311
966
|
exitAuthResult = exitKey !== null ? "unverified" : null;
|
|
312
967
|
}
|
|
@@ -319,19 +974,57 @@ export function makeRunContainer({
|
|
|
319
974
|
usage = null;
|
|
320
975
|
context = null;
|
|
321
976
|
exitReason = null;
|
|
977
|
+
approval = null;
|
|
322
978
|
exitLineCode = null;
|
|
323
979
|
exitWhy = null;
|
|
980
|
+
providerFailure = null;
|
|
981
|
+
modelRoles = null;
|
|
982
|
+
toolsStarted = null;
|
|
983
|
+
replaySafe = null;
|
|
324
984
|
resources = null;
|
|
325
985
|
exitOomKilled = false;
|
|
986
|
+
recovery = null;
|
|
987
|
+
steerCounts = null;
|
|
988
|
+
canaryLoad = null;
|
|
989
|
+
lineLoaded = null;
|
|
326
990
|
}
|
|
327
991
|
// Spread only when a key was issued, so a run without one resolves the very object it always did.
|
|
328
992
|
const auth = exitKey !== null ? { exitAuth: exitAuthResult } : {};
|
|
993
|
+
if (job.answerDraft && code === 0 && exitKey !== null && exitAuthResult === "verified" && /^[a-f0-9]{64}$/.test(answerDraftDigest ?? "")) Object.defineProperty(prepared, "answerDraftDigest", { value: answerDraftDigest, configurable: true });
|
|
994
|
+
if (job.awaitInput && code === 0 && exitReason === "awaiting-input" && exitKey !== null && exitAuthResult === "verified" && /^[a-f0-9]{64}$/.test(questionRefDigest ?? "")) Object.defineProperty(prepared, "questionRefDigest", { value: questionRefDigest, configurable: true });
|
|
329
995
|
// `exitWhy` only when the line named one, so every other run resolves the object it always did.
|
|
330
|
-
const why = exitWhy !== null ? { exitWhy } : {};
|
|
996
|
+
const why = { ...(exitKey !== null && exitAuthResult === "verified" && modelRoles ? { modelRoles } : {}), ...(exitWhy !== null ? { exitWhy } : {}), ...(exitKey !== null && exitAuthResult === "verified" && providerFailure ? { providerFailure } : {}), ...(exitKey !== null && exitAuthResult === "verified" && typeof toolsStarted === "boolean" ? { toolsStarted } : {}), ...(exitKey !== null && exitAuthResult === "verified" && typeof replaySafe === "boolean" ? { replaySafe } : {}) };
|
|
331
997
|
// `resources` (issue #596) only when the exit line carried a block, so every other run resolves the object it always did.
|
|
332
998
|
// `memoryLimit` only beside the OOM report, the one place it is read, for the same reason.
|
|
333
999
|
const used = { ...(resources !== null && resources !== undefined ? { resources } : {}), ...(exitOomKilled === true ? { exitOomKilled: true, memoryLimit } : {}) };
|
|
334
|
-
|
|
1000
|
+
// `recovery` (issue #645) only when the exit line carried the block, so every other run resolves the object it always did.
|
|
1001
|
+
const recovered = recovery !== null && typeof recovery === "object" ? { recovery } : {};
|
|
1002
|
+
// Issue #646, review finding F3: the deliveries the runner's SIGNED exit line names are marked before the relay
|
|
1003
|
+
// stops. The transcript is the agent's to break (a symlink swapped in makes the tail refuse it), so the acks
|
|
1004
|
+
// it carries are not the only witness: the exit line is signed with the key the agent never sees, and with a
|
|
1005
|
+
// key issued every field above is nulled unless it verified. The tail's acks are drained first, so these marks
|
|
1006
|
+
// only add what the transcript could not say. Then the relay stops, after which the settle step closes.
|
|
1007
|
+
if (steerArmed && exitAuthResult === "verified" && Array.isArray(steerCounts?.deliveredIds)) {
|
|
1008
|
+
try {
|
|
1009
|
+
await steerAck?.drain();
|
|
1010
|
+
} catch {}
|
|
1011
|
+
for (const id of steerCounts.deliveredIds) {
|
|
1012
|
+
try {
|
|
1013
|
+
await relay?.delivered(id);
|
|
1014
|
+
} catch {}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
await stopRelay();
|
|
1018
|
+
// Issue #646: only for a job that asked for steering, so every other run resolves the object it always did.
|
|
1019
|
+
// `steer` is the runner's own count (advisory, like `recovery`), without the ids; `steerSkipped` the verdict.
|
|
1020
|
+
const steerShown = steerCounts !== null && typeof steerCounts === "object" ? { delivered: steerCounts.delivered ?? null, pending: steerCounts.pending ?? null } : null;
|
|
1021
|
+
const steered = steerAsked ? { ...(steerArmed && steerShown !== null ? { steer: steerShown } : {}), ...(steerSkipped !== null ? { steerSkipped } : {}) } : {};
|
|
1022
|
+
// Issue #712: the named live-transcript skip, for the record's `liveSkipped`, only when there was one.
|
|
1023
|
+
// Issue #800: what the job loaded, when the line said or the worker has something of its own to say; otherwise the run
|
|
1024
|
+
// resolves the object it always did, and the processor's image-only value stands.
|
|
1025
|
+
const loadedShown = lineLoaded !== null || leavesOut(loadedKnown.leftOut) ? { loaded: rebuildLoaded(lineLoaded, loadedKnown) } : {};
|
|
1026
|
+
const liveShown = { ...(liveSkipped !== null ? { liveSkipped } : {}), ...(canaryLoad !== null ? { canaryLoad } : {}), ...loadedShown };
|
|
1027
|
+
resolve(aborted ? { code: code ?? 137, aborted: true, turns, tokens, session, usage, context, exitReason, exitLineCode, ...(approval ? { approval } : {}), ...why, ...used, ...auth, ...ranProgress, ...recovered, ...steered, ...liveShown, ...checkpointResult, ...stoppedForCheckpoint, ...lingered } : { code: code ?? 1, aborted: false, turns, tokens, session, usage, context, exitReason, exitLineCode, ...(approval ? { approval } : {}), ...why, ...used, ...auth, ...ranProgress, ...recovered, ...steered, ...liveShown, ...checkpointResult, ...stoppedForCheckpoint, ...lingered });
|
|
335
1028
|
});
|
|
336
1029
|
});
|
|
337
1030
|
|
|
@@ -358,8 +1051,11 @@ export function makeRunContainer({
|
|
|
358
1051
|
if (!result.aborted && (neverStartedExits ?? []).includes(result.code) && (await stopDetached({ spawnFn, cidFile, fs, bin, ...detachedCheck }))) {
|
|
359
1052
|
return { ...result, detached: true };
|
|
360
1053
|
}
|
|
1054
|
+
// Issue #683: a copying venue brings its outputs back before the processor reads them. Its own throw stands.
|
|
1055
|
+
if (typeof collectRun === "function") return await collectRun(result, { opts: buildOpts });
|
|
361
1056
|
return result;
|
|
362
1057
|
} finally {
|
|
1058
|
+
await teardown();
|
|
363
1059
|
if (network) {
|
|
364
1060
|
await removeJobNetwork(spawnFn, {
|
|
365
1061
|
network,
|
|
@@ -374,10 +1070,30 @@ export function makeRunContainer({
|
|
|
374
1070
|
} catch {
|
|
375
1071
|
// best effort: a leftover file is removed before this job's next attempt starts
|
|
376
1072
|
}
|
|
1073
|
+
// Issue #646: the relay is stopped on every path (a throw before the spawn included), then its directory goes.
|
|
1074
|
+
await stopRelay();
|
|
1075
|
+
removeInbox();
|
|
377
1076
|
}
|
|
378
1077
|
};
|
|
379
1078
|
}
|
|
380
1079
|
|
|
1080
|
+
/**
|
|
1081
|
+
* The `end` entry's outcome from what `runContainer` knows when the container exits (issue #643): a worker stop and
|
|
1082
|
+
* the runner's exit 2 are policy, exit 0 completed, exit 1 failed, and anything else (an OOM kill, a runtime's own
|
|
1083
|
+
* code) `unknown`, since only the processor's classification can say more. One exception to `unknown`: an exit this
|
|
1084
|
+
* runtime spells "never started" (`neverStartedExits`, 125 to 127 on Docker) is `failed`, the same word the spawn
|
|
1085
|
+
* error path uses, because the container never ran: the attempt is over and did nothing, and the processor retries it
|
|
1086
|
+
* as infrastructure under the same job id, whose first write then replaces this stream.
|
|
1087
|
+
*/
|
|
1088
|
+
export function liveOutcomeOf(code, aborted, neverStartedExits = []) {
|
|
1089
|
+
if (aborted) return "policy";
|
|
1090
|
+
if (code === 0) return "completed";
|
|
1091
|
+
if (code === 2) return "policy";
|
|
1092
|
+
if (code === 1) return "failed";
|
|
1093
|
+
if (Array.isArray(neverStartedExits) && neverStartedExits.includes(code)) return "failed";
|
|
1094
|
+
return "unknown";
|
|
1095
|
+
}
|
|
1096
|
+
|
|
381
1097
|
/** The detached check's whole bound, retries included, so a daemon that stopped answering cannot hold the slot for long. */
|
|
382
1098
|
export const DETACHED_CHECK_TIMEOUT_MS = 10_000;
|
|
383
1099
|
/** The least time any single step of it is given, so the last one tried at the deadline is not killed before it is sent. */
|