@edgehero/pi-dispatch 1.10.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +303 -150
- package/README.md +52 -0
- package/deploy/com.pi-dispatch.worker.plist +10 -4
- package/deploy/docker-compose.yml +49 -16
- package/deploy/egress-proxy.conf +32 -2
- package/deploy/nssm-install.cmd +12 -6
- package/deploy/pi-dispatch-egress-out.network +10 -0
- package/deploy/pi-dispatch-egress-proxy.container +50 -0
- package/deploy/pi-dispatch-netns-keeper.container +80 -0
- package/deploy/pi-dispatch-netns-keeper.network +18 -0
- package/deploy/pi-dispatch-valkey.container +51 -0
- package/deploy/pi-dispatch-valkey.network +16 -0
- package/deploy/receiver.service +6 -0
- package/deploy/worker-env-wrapper.cmd +12 -1
- package/deploy/worker-env-wrapper.sh +63 -37
- package/deploy/worker.service +18 -8
- package/package.json +15 -5
- package/src/azure-host.mjs +19 -0
- package/src/azure-identity.mjs +18 -2
- package/src/backend-conformance.mjs +71 -18
- package/src/backend-local.mjs +637 -21
- package/src/backend-podman.mjs +1168 -0
- package/src/backend-registry.mjs +86 -3
- package/src/backends.mjs +489 -37
- package/src/branch.mjs +7 -2
- package/src/cancel-cli.mjs +174 -0
- package/src/cancel-state.mjs +125 -0
- package/src/cli.mjs +188 -90
- package/src/config.mjs +503 -43
- package/src/connection.mjs +374 -8
- package/src/container-spec.mjs +102 -7
- package/src/daemon-facts.mjs +167 -0
- package/src/deployment-venue.mjs +158 -0
- package/src/docker-run.mjs +146 -15
- package/src/doctor.mjs +4756 -394
- package/src/egress-conf-copy.mjs +166 -0
- package/src/egress-proxy-state.mjs +151 -0
- package/src/egress.mjs +456 -25
- package/src/entry.mjs +27 -0
- package/src/env-allowlist.mjs +245 -40
- package/src/env-file.mjs +1869 -33
- package/src/exit-code.mjs +15 -0
- package/src/flow-gate.mjs +5 -3
- package/src/forgejo-host.mjs +19 -0
- package/src/forgejo-identity.mjs +21 -2
- package/src/get-token.mjs +67 -18
- package/src/git-dirty.mjs +9 -1
- package/src/git-hardening.mjs +33 -0
- package/src/github-app-setup.mjs +29 -12
- package/src/github-prompt.mjs +4 -1
- package/src/gitlab-host.mjs +19 -0
- package/src/gitlab-identity.mjs +19 -2
- package/src/host-pi.mjs +19 -3
- package/src/host-registry.mjs +29 -2
- package/src/identity.mjs +29 -4
- package/src/image-preflight.mjs +46 -11
- package/src/image-ref.mjs +21 -0
- package/src/index.mjs +363 -13
- package/src/init.mjs +197 -38
- package/src/job-user.mjs +252 -0
- package/src/json-duplicates.mjs +204 -0
- package/src/live-probes.mjs +1020 -0
- package/src/materialize.mjs +4 -11
- package/src/netns-keeper.mjs +264 -0
- package/src/on-failure.mjs +119 -0
- package/src/outbox.mjs +7 -0
- package/src/packages.mjs +2 -2
- package/src/podman-stack.mjs +1304 -0
- package/src/prepare-github.mjs +6 -6
- package/src/prepare-local.mjs +51 -17
- package/src/prepare.mjs +27 -6
- package/src/pricing.mjs +9 -5
- package/src/processor.mjs +506 -26
- package/src/provider-key.mjs +66 -0
- package/src/provider-steering.mjs +185 -0
- package/src/queue.mjs +35 -8
- package/src/redact.mjs +84 -0
- package/src/reserved-env.mjs +7 -3
- package/src/retention-sweep.mjs +178 -0
- package/src/run-container.mjs +181 -14
- package/src/run-history.mjs +105 -16
- package/src/runtime-observations.mjs +1152 -0
- package/src/runtime-settings.mjs +13 -8
- package/src/sandbox-cli.mjs +100 -95
- package/src/sandbox-store.mjs +612 -45
- package/src/sandbox.mjs +1459 -37
- package/src/schedules.mjs +16 -3
- package/src/secret-profiles.mjs +2 -1
- package/src/secrets.mjs +24 -6
- package/src/service-env.mjs +247 -0
- package/src/service.mjs +618 -28
- package/src/session-store.mjs +678 -53
- package/src/start.mjs +1348 -326
- package/src/subscriptions.mjs +7 -3
- package/src/transient.mjs +240 -0
- package/src/triggers-file.mjs +71 -15
- package/src/triggers.mjs +179 -19
- package/src/up.mjs +1399 -85
- package/src/valkey-auth.mjs +529 -0
- package/src/valkey-endpoint.mjs +367 -0
- package/src/watch-closer.mjs +158 -0
package/src/exit-code.mjs
CHANGED
|
@@ -86,3 +86,18 @@ export function decideWait(exitCode) {
|
|
|
86
86
|
return { verdict: "hold", fault: true }; // EXIT_INFRA and every unrecognised code
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A process-level printer for a promise nobody handled (PR #475's review, round 2): Node prints such a rejection's
|
|
92
|
+
* reason WHOLE, and a Valkey client's error may carry what it sent (connection.mjs scrubs that at the source; this is
|
|
93
|
+
* the second line of defence for anything else). It prints the message alone and exits 1, which is what Node's own
|
|
94
|
+
* default does with an unhandled rejection: infra, restarted by the service manager, never a silent carry-on. Installed
|
|
95
|
+
* by the worker's CLI (every `pi-dispatch` verb, the worker among them) and the receiver's two entry points.
|
|
96
|
+
*/
|
|
97
|
+
export function installRejectionPrinter({ proc = process, write = (line) => process.stderr.write(line) } = {}) {
|
|
98
|
+
proc.on("unhandledRejection", (reason) => {
|
|
99
|
+
const message = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "a non-Error value";
|
|
100
|
+
write(`error: an unhandled rejection: ${message}\n`);
|
|
101
|
+
proc.exit(EXIT_INFRA);
|
|
102
|
+
});
|
|
103
|
+
}
|
package/src/flow-gate.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { GIT_READ_FLAGS } from "./git-hardening.mjs";
|
|
1
2
|
import { execFile } from "node:child_process";
|
|
2
3
|
import { promisify } from "node:util";
|
|
3
4
|
|
|
@@ -74,9 +75,10 @@ export function aiTriggerAllows(buf) {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
async function defaultGit(gitDir, args, { raw = false } = {}) {
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
78
|
+
// No hooks, no fsmonitor, no pager, so a hostile repo config cannot run code or corrupt output during
|
|
79
|
+
// a read. This said "mirror materialize.mjs -- keep in sync" until the copy that had NOT stayed in
|
|
80
|
+
// sync was found (issue #286's sweep); it is an import now, so there is nothing left to remember.
|
|
81
|
+
const hardened = [...GIT_READ_FLAGS, "-C", gitDir, ...args];
|
|
80
82
|
const { stdout } = await exec("git", hardened, {
|
|
81
83
|
encoding: raw ? "buffer" : "utf8",
|
|
82
84
|
maxBuffer: 16 * 1024 * 1024,
|
package/src/forgejo-host.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { configError } from "./config.mjs";
|
|
|
24
24
|
import { InfraRetry } from "./processor.mjs";
|
|
25
25
|
import { fetchFailureReason } from "./gitlab-identity.mjs";
|
|
26
26
|
import { matchesBranch } from "./gitlab-host.mjs";
|
|
27
|
+
import { isDeterminateFetchFailure } from "./transient.mjs";
|
|
27
28
|
|
|
28
29
|
const API_PREFIX = "/api/v1";
|
|
29
30
|
|
|
@@ -37,6 +38,15 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
|
|
|
37
38
|
try {
|
|
38
39
|
res = await fetchFn(`${root}${path}`, { headers: { Authorization: `token ${token}` }, redirect: "error" });
|
|
39
40
|
} catch (err) {
|
|
41
|
+
// The one condition where the identity modules and this one now agree, because the issue's
|
|
42
|
+
// acceptance asks for exactly that (#316): a TLS trust failure, a protocol mismatch and a URL
|
|
43
|
+
// that always redirects are the operator's, and retrying them twice before failing tells
|
|
44
|
+
// nobody anything. Everything else here stays unconditionally retryable, which is right for a
|
|
45
|
+
// per-job call behind the queue: a wrong retry costs one more attempt, a wrong refusal costs
|
|
46
|
+
// the delivery and posts publicly that the deployment is misconfigured.
|
|
47
|
+
if (isDeterminateFetchFailure(err)) {
|
|
48
|
+
throw configError(`forgejo-host: GET ${path} failed (${fetchFailureReason(err)})`);
|
|
49
|
+
}
|
|
40
50
|
throw new InfraRetry(`forgejo-host: GET ${path} failed (${fetchFailureReason(err)})`);
|
|
41
51
|
}
|
|
42
52
|
if (res.status === 404 && notFound !== undefined) return notFound;
|
|
@@ -114,6 +124,15 @@ export function makeForgejoHost({ apiUrl, fetchFn = fetch } = {}) {
|
|
|
114
124
|
redirect: "error",
|
|
115
125
|
});
|
|
116
126
|
} catch (err) {
|
|
127
|
+
// The one condition where the identity modules and this one now agree, because the issue's
|
|
128
|
+
// acceptance asks for exactly that (#316): a TLS trust failure, a protocol mismatch and a URL
|
|
129
|
+
// that always redirects are the operator's, and retrying them twice before failing tells
|
|
130
|
+
// nobody anything. Everything else here stays unconditionally retryable, which is right for a
|
|
131
|
+
// per-job call behind the queue: a wrong retry costs one more attempt, a wrong refusal costs
|
|
132
|
+
// the delivery and posts publicly that the deployment is misconfigured.
|
|
133
|
+
if (isDeterminateFetchFailure(err)) {
|
|
134
|
+
throw configError(`forgejo-host: POST ${path} failed (${fetchFailureReason(err)})`);
|
|
135
|
+
}
|
|
117
136
|
throw new InfraRetry(`forgejo-host: POST ${path} failed (${fetchFailureReason(err)})`);
|
|
118
137
|
}
|
|
119
138
|
if (!res.ok) {
|
package/src/forgejo-identity.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { configError } from "./config.mjs";
|
|
25
|
+
import { isDeterminateFetchFailure, isJsonContentType, isTransientStatus, responseHeaderReader, transientError } from "./transient.mjs";
|
|
25
26
|
import { fetchFailureReason } from "./gitlab-identity.mjs";
|
|
26
27
|
|
|
27
28
|
const API_PREFIX = "/api/v1";
|
|
@@ -47,7 +48,11 @@ export async function resolveForgejoSelfId({ apiUrl, token, botId = null, fetchF
|
|
|
47
48
|
try {
|
|
48
49
|
res = await fetchFn(url, { headers: { Authorization: `token ${token}` }, redirect: "error" });
|
|
49
50
|
} catch (err) {
|
|
50
|
-
|
|
51
|
+
// Transient, EXCEPT for the trust and redirect faults below, which is where this deliberately
|
|
52
|
+
// diverges from forgejo-host.mjs: the host retries every rejection unconditionally, and being
|
|
53
|
+
// unconditional is correct there because a job retry is cheap. A boot refusal is not (issue #316).
|
|
54
|
+
if (isDeterminateFetchFailure(err)) throw configError(`could not resolve the forgejo bot identity from ${url}: ${fetchFailureReason(err)}`);
|
|
55
|
+
throw transientError(`could not resolve the forgejo bot identity from ${url}: ${fetchFailureReason(err)}`, err);
|
|
51
56
|
}
|
|
52
57
|
if (res.status === 403 || res.status === 401) {
|
|
53
58
|
// The likely cause, named. A repo-scoped Forgejo token cannot carry `read:user`, so this is the
|
|
@@ -58,13 +63,27 @@ export async function resolveForgejoSelfId({ apiUrl, token, botId = null, fetchF
|
|
|
58
63
|
}
|
|
59
64
|
if (!res.ok) {
|
|
60
65
|
// The status only. A Forgejo error body can echo the request, and the request carried the token.
|
|
66
|
+
// 401 and 403 were answered above, where they name the scope fix; what is left splits on the
|
|
67
|
+
// shared rule, so a self-hosted instance answering 502 mid-restart no longer reads as a
|
|
68
|
+
// misconfiguration the operator has to go and find.
|
|
69
|
+
if (isTransientStatus(res.status, responseHeaderReader(res))) {
|
|
70
|
+
throw transientError(`could not resolve the forgejo bot identity: GET /user returned ${res.status}`);
|
|
71
|
+
}
|
|
61
72
|
throw configError(`could not resolve the forgejo bot identity: GET /user returned ${res.status}`);
|
|
62
73
|
}
|
|
63
74
|
let body;
|
|
64
75
|
try {
|
|
65
76
|
body = await res.json();
|
|
66
77
|
} catch (err) {
|
|
67
|
-
|
|
78
|
+
// A body that will not parse is ambiguous in the expensive direction, so the CONTENT TYPE
|
|
79
|
+
// decides it. A truncated JSON body is transient. An HTML page is not: it is a Cloudflare
|
|
80
|
+
// Access or OIDC portal answering instead of the forge, and no amount of restarting gets past
|
|
81
|
+
// one (issue #316).
|
|
82
|
+
const contentType = responseHeaderReader(res);
|
|
83
|
+
if (contentType("content-type") !== undefined && !isJsonContentType(contentType)) {
|
|
84
|
+
throw configError(`could not resolve the forgejo bot identity: GET /user answered ${String(contentType("content-type"))} instead of JSON, so something other than the Forgejo API replied`);
|
|
85
|
+
}
|
|
86
|
+
throw transientError(`could not resolve the forgejo bot identity: unparseable JSON from GET /user (${err?.message ?? "unknown"})`, err);
|
|
68
87
|
}
|
|
69
88
|
const id = body?.id;
|
|
70
89
|
if (!Number.isInteger(id)) {
|
package/src/get-token.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { Octokit as RealOctokit } from "@octokit/rest";
|
|
|
28
28
|
import { configError } from "./config.mjs";
|
|
29
29
|
import { resolveSelfId } from "./identity.mjs";
|
|
30
30
|
import { InfraRetry } from "./processor.mjs";
|
|
31
|
+
import { isDeterminateFetchFailure, isDeterminateFsCode, isTransientStatus, octokitHeaderReader } from "./transient.mjs";
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Build the auth surface for `cfg = { source, patVar?, appId?, installationId?, privateKeyPath? }`.
|
|
@@ -138,14 +139,39 @@ function requireToken(raw, what) {
|
|
|
138
139
|
return token;
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Run `gh auth token` (array args, no shell) and return the trimmed token.
|
|
144
|
+
*
|
|
145
|
+
* THIS RUNS PER JOB, not only at boot: `makeGitHubAuth`'s gh arm mints once to resolve the identity and
|
|
146
|
+
* then again on every delivery, so a fault here is a fault on the job path.
|
|
147
|
+
*
|
|
148
|
+
* `promisify(execFile)` overloads `error.code`, and issue #316 is what that overloading cost. A SPAWN
|
|
149
|
+
* failure sets it to a string errno; a non-zero EXIT sets it to the integer status. The old comment
|
|
150
|
+
* called both deterministic and tagged both, so `EAGAIN` and `EMFILE` under fd pressure -- the host being
|
|
151
|
+
* momentarily busy -- were reported to the issue author as "is the gh CLI installed and logged in?" and
|
|
152
|
+
* never retried.
|
|
153
|
+
*
|
|
154
|
+
* So: a spawn errno splits on the same allow-list every other absence question uses, and `ENOENT` (no
|
|
155
|
+
* binary on PATH) stays determinate. A NON-ZERO EXIT stays determinate too, and that is a judgment call
|
|
156
|
+
* rather than a derivation. `gh auth token` exits 1 for a logged-out host far more often than for a
|
|
157
|
+
* locked keyring or a token refresh that could not reach the network, and the only thing that separates
|
|
158
|
+
* them is a table of gh's own stderr strings, which is exactly the hand-maintained table this project
|
|
159
|
+
* keeps deleting. The residual is named here rather than papered over.
|
|
160
|
+
*/
|
|
142
161
|
async function runGhAuthToken(execFileAsync) {
|
|
143
162
|
let stdout;
|
|
144
163
|
try {
|
|
145
164
|
({ stdout } = await execFileAsync("gh", ["auth", "token"]));
|
|
146
165
|
} catch (error) {
|
|
147
|
-
// ENOENT (binary absent) or a non-zero exit (logged out / broken) -- both deterministic.
|
|
148
166
|
const detail = error?.code ?? error?.message ?? "unknown";
|
|
167
|
+
// A child killed by a SIGNAL sets `code` to null and `signal` to the name. An OOM-killed `gh` is
|
|
168
|
+
// the host being under pressure, not a deployment that is wrong.
|
|
169
|
+
if (error?.signal) {
|
|
170
|
+
throw new InfraRetry(`\`gh auth token\` was killed by ${error.signal}; the host is under pressure`);
|
|
171
|
+
}
|
|
172
|
+
if (typeof error?.code === "string" && !isDeterminateFsCode(error.code)) {
|
|
173
|
+
throw new InfraRetry(`\`gh auth token\` could not be run (${detail}); the host is momentarily unable to spawn it`);
|
|
174
|
+
}
|
|
149
175
|
throw configError(`\`gh auth token\` failed (${detail}); is the gh CLI installed and logged in?`);
|
|
150
176
|
}
|
|
151
177
|
// Logged-out gh can exit 0 with empty stdout; an empty token is the money hole, so refuse it.
|
|
@@ -177,30 +203,53 @@ function repoNameOf(repo) {
|
|
|
177
203
|
|
|
178
204
|
/**
|
|
179
205
|
* Map an octokit/auth-app rejection to the retry-vs-config distinction the queue depends on.
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
206
|
+
*
|
|
207
|
+
* Determinate (configError): 401 bad credentials and 404 unknown installation, both of which refuse
|
|
208
|
+
* identically until an operator changes something. Everything the shared rule calls transient is an
|
|
209
|
+
* `InfraRetry`; every other 4xx is determinate.
|
|
210
|
+
*
|
|
211
|
+
* TWO CORRECTIONS TO WHAT THIS COMMENT USED TO CLAIM (issue #316), because the old version described a
|
|
212
|
+
* function that did not exist.
|
|
213
|
+
*
|
|
214
|
+
* It said 403-with-retry-after was the rate-limit case. That is the SECONDARY limit. GitHub answers the
|
|
215
|
+
* PRIMARY limit -- the one a busy deployment actually hits, and which clears within the hour -- with 403,
|
|
216
|
+
* `x-ratelimit-remaining: 0` and no `retry-after`, so it fell through to the catch-all and became a
|
|
217
|
+
* permanent refusal. 408 and 425 went the same way, and so did an unparseable status, which
|
|
218
|
+
* `@octokit/request-error` reports as the NUMBER 0.
|
|
219
|
+
*
|
|
220
|
+
* It also said status-less network faults reach the bottom branch. They do not:
|
|
221
|
+
* `@octokit/request`'s fetch wrapper turns every network rejection into `RequestError(message, 500)`, so
|
|
222
|
+
* that branch is unreachable through octokit, and the 5xx arm has been doing the work all along.
|
|
223
|
+
*
|
|
224
|
+
* TWO CONSEQUENCES OF THAT SAME FACT, and they run in opposite directions.
|
|
225
|
+
*
|
|
226
|
+
* A TRUST OR REDIRECT FAULT ARRIVES WEARING A 500. The wrapper keeps the original rejection as `cause`,
|
|
227
|
+
* so the chain is intact, but the status says 500 and 500 is transient. A private CA in front of a GHES
|
|
228
|
+
* instance, or a TLS-inspecting corporate proxy re-signing `api.github.com`, would otherwise be retried
|
|
229
|
+
* forever instead of naming `NODE_EXTRA_CA_CERTS`. So the determinate check runs FIRST.
|
|
230
|
+
*
|
|
231
|
+
* AND AN ABSENT STATUS MEANS THIS NEVER REACHED THE NETWORK. Octokit always sets one, so a status-less
|
|
232
|
+
* rejection came from local code: `@octokit/auth-app` signing the JWT with a key that is not PKCS//8, the
|
|
233
|
+
* commonest App setup mistake there is. That is determinate, and it used to be retried and then reported
|
|
234
|
+
* as an infrastructure failure with the real reason nowhere an operator would look.
|
|
184
235
|
*/
|
|
185
236
|
function classifyAppMintError(error) {
|
|
186
237
|
const status = typeof error?.status === "number" ? error.status : undefined;
|
|
187
|
-
const
|
|
238
|
+
const detail = error?.message ?? "unknown";
|
|
188
239
|
|
|
240
|
+
if (isDeterminateFetchFailure(error)) return configError(`app token mint refused: ${detail}`);
|
|
189
241
|
if (status === 401) return configError("app token mint refused: bad app credentials (401)");
|
|
190
242
|
if (status === 404) return configError("app token mint refused: unknown installation (404)");
|
|
191
|
-
if (status ===
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
return new InfraRetry(`app token mint: upstream error (${status})`);
|
|
243
|
+
if (status === undefined) {
|
|
244
|
+
// Local, and therefore the operator's. `code` is worth carrying: a bad PEM surfaces as an OpenSSL
|
|
245
|
+
// DECODER error whose code says more than its message does.
|
|
246
|
+
const code = error?.code ? `${error.code}: ` : "";
|
|
247
|
+
return configError(`app token mint failed before any request was made (${code}${detail})`);
|
|
197
248
|
}
|
|
198
|
-
if (status
|
|
199
|
-
return
|
|
249
|
+
if (isTransientStatus(status, octokitHeaderReader(error), detail)) {
|
|
250
|
+
return new InfraRetry(`app token mint: transient upstream refusal (${status})`);
|
|
200
251
|
}
|
|
201
|
-
|
|
202
|
-
const detail = error?.code ? `${error.code}: ` : "";
|
|
203
|
-
return new InfraRetry(`app token mint: network fault: ${detail}${error?.message ?? "unknown"}`);
|
|
252
|
+
return configError(`app token mint failed (${status}): ${detail}`);
|
|
204
253
|
}
|
|
205
254
|
|
|
206
255
|
/**
|
package/src/git-dirty.mjs
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { GIT_READ_FLAGS } from "./git-hardening.mjs";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Report a folder's git working-tree state: `true` = dirty, `false` = clean, `null` = not a usable
|
|
5
6
|
* git repository. Reads the WORKING TREE via `git status --porcelain` — distinct from
|
|
6
7
|
* flow-gate.mjs's object-store read, which reads committed content at a pinned SHA; the two are kept
|
|
7
8
|
* separate on purpose. `exec` is injectable for tests.
|
|
9
|
+
*
|
|
10
|
+
* HARDENED, and this is the site where it is not hypothetical. `git status` REFRESHES THE INDEX, so it
|
|
11
|
+
* invokes `core.fsmonitor` -- an operator-supplied command -- where `rev-parse` does not. The folder this
|
|
12
|
+
* reads is the same path a local job mounts at /workspace with write access, so the agent can write
|
|
13
|
+
* `.git/config` and the next `pi-dispatch run` on that folder executes it ON THE HOST, outside any
|
|
14
|
+
* container. This file was invisible to the sweep that hardened its six siblings, because that census
|
|
15
|
+
* grepped for the flags that were PRESENT and this one had none.
|
|
8
16
|
*/
|
|
9
17
|
export function gitDirty(folder, { exec = execFileSync } = {}) {
|
|
10
18
|
try {
|
|
11
|
-
const out = exec("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
|
|
19
|
+
const out = exec("git", [...GIT_READ_FLAGS, "-C", folder, "status", "--porcelain"], { encoding: "utf8" });
|
|
12
20
|
return out.trim().length > 0;
|
|
13
21
|
} catch {
|
|
14
22
|
return null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `-c` pairs that stop a hostile repository config executing code on the worker HOST, outside any
|
|
3
|
+
* container. `materialize.mjs` states the intent in one line and it is the whole of it: no hooks, no
|
|
4
|
+
* external filters, no pager.
|
|
5
|
+
*
|
|
6
|
+
* A LEAF that imports nothing, on `container-spec.mjs`'s precedent and for its reason. `materialize.mjs`
|
|
7
|
+
* is the file three "keep in sync" comments already named as canonical, but it imports `flow-gate.mjs`
|
|
8
|
+
* and `flow-gate.mjs` needs these flags too, so canonical-by-comment could not become
|
|
9
|
+
* canonical-by-import without a cycle. The array moves here and the comments become imports.
|
|
10
|
+
*
|
|
11
|
+
* Seven sites carried this by hand and one of them -- `prepare-local.mjs` -- was missing
|
|
12
|
+
* `core.fsmonitor`. Nothing was exploitable: its only command is `git rev-parse HEAD`, which does not
|
|
13
|
+
* refresh the index, so the fsmonitor hook is never invoked, and the `.pi/` materialisation beside it
|
|
14
|
+
* uses materialize's own hardened git. That is exactly the shape of a drift that survives review --
|
|
15
|
+
* harmless in the copy that has it wrong, load-bearing in the six that have it right -- and it is why
|
|
16
|
+
* this file exists rather than an eighth careful copy.
|
|
17
|
+
*
|
|
18
|
+
* `core.fsmonitor` is the one worth naming: it makes git run an operator-supplied command on any
|
|
19
|
+
* index-refreshing read, and a local-folder job's agent can write `.git/config` inside `/workspace`, so
|
|
20
|
+
* the value is attacker-controlled by construction on exactly the path that reads it.
|
|
21
|
+
*/
|
|
22
|
+
export const GIT_SAFE_CONFIG = Object.freeze(["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The whole prefix for a READ that must run no repository-supplied code. `-C <dir>` follows at the call
|
|
26
|
+
* site, because two callers pass a `-C` and one (doctor's probe) supplies its own directory handling.
|
|
27
|
+
*
|
|
28
|
+
* Two constants rather than one: `prepare-github.mjs` INTERLEAVES its clone-specific locks
|
|
29
|
+
* (`protocol.ext.allow`, an empty `credential.helper`) between the fsmonitor pair and `--no-pager`, so a
|
|
30
|
+
* single flat prefix would not compose there and would force that file's argv to be reordered for the
|
|
31
|
+
* convenience of this one.
|
|
32
|
+
*/
|
|
33
|
+
export const GIT_READ_FLAGS = Object.freeze([...GIT_SAFE_CONFIG, "--no-pager"]);
|
package/src/github-app-setup.mjs
CHANGED
|
@@ -235,16 +235,28 @@ export async function runGithubAppSetup(argv = [], deps = {}) {
|
|
|
235
235
|
fs.writeFileSync(envPath, "");
|
|
236
236
|
say(`note: no .env existed here — created one for the lines you approved\n`);
|
|
237
237
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
238
|
+
// WRAPPED, because `updateEnvFile` can now refuse rather than write: a `.env` owned by another
|
|
239
|
+
// account or another group, and a value no loader of that file can read back. Unwrapped, the
|
|
240
|
+
// refusal would leave a half-done setup -- the PEM already on disk at 0600, possibly an empty
|
|
241
|
+
// `.env` just created above -- and a stack trace instead of the summary this command exists to
|
|
242
|
+
// print. The App itself is already created on GitHub by this point, so saying exactly what landed
|
|
243
|
+
// is the whole remaining value.
|
|
244
|
+
try {
|
|
245
|
+
updateEnvFile(envPath, "GITHUB_AUTH_SOURCE", "app", { fs, overwrite: true });
|
|
246
|
+
updateEnvFile(envPath, "GITHUB_APP_ID", String(app.id), { fs, overwrite: true });
|
|
247
|
+
updateEnvFile(envPath, "GITHUB_APP_PRIVATE_KEY_PATH", pemPath, { fs, overwrite: true });
|
|
248
|
+
summary.push(["auth source", "GITHUB_AUTH_SOURCE=app (+ app id and key path) written to .env"]);
|
|
249
|
+
if (offerSecret) {
|
|
250
|
+
// setEnvKeyIfEmpty, NOT overwrite: an operator's existing webhook secret is what their
|
|
251
|
+
// already-configured hooks sign with — replacing it would invalidate working deliveries.
|
|
252
|
+
const { changed } = updateEnvFile(envPath, "WEBHOOK_SECRET", app.webhook_secret, { fs });
|
|
253
|
+
say(changed ? "✓ WEBHOOK_SECRET set in .env (value not shown)\n" : "✓ WEBHOOK_SECRET already set in .env — kept (your configured hooks keep verifying)\n");
|
|
254
|
+
summary.push(["WEBHOOK_SECRET", changed ? "set from the App's minted secret (value not shown)" : "already set — kept, so existing deliveries stay valid"]);
|
|
255
|
+
}
|
|
256
|
+
} catch (err) {
|
|
257
|
+
say(`error: could not write the App's lines into ${envPath}: ${err?.message}\n`);
|
|
258
|
+
say(` → the App exists on GitHub and its key is at ${pemPath}. Add GITHUB_AUTH_SOURCE=app, GITHUB_APP_ID=${app.id} and GITHUB_APP_PRIVATE_KEY_PATH=${pemPath} to that file by hand\n`);
|
|
259
|
+
summary.push(["auth source", `NOT written: ${err?.message}`]);
|
|
248
260
|
}
|
|
249
261
|
say(`✓ credentials written\n`);
|
|
250
262
|
|
|
@@ -320,7 +332,12 @@ export async function runGithubAppSetup(argv = [], deps = {}) {
|
|
|
320
332
|
// so this one IS printed in full.
|
|
321
333
|
say(`\nsetup would write to ${envPath}:\n GITHUB_APP_INSTALLATION_ID=${chosen.id}${chosen.account?.login ? ` (account: ${chosen.account.login})` : ""}\n`);
|
|
322
334
|
if (await consent(prompt, "write it? [y/N] ")) {
|
|
323
|
-
|
|
335
|
+
try {
|
|
336
|
+
updateEnvFile(envPath, "GITHUB_APP_INSTALLATION_ID", String(chosen.id), { fs, overwrite: true });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
say(`error: could not write GITHUB_APP_INSTALLATION_ID into ${envPath}: ${err?.message}\n`);
|
|
339
|
+
say(` → add GITHUB_APP_INSTALLATION_ID=${chosen.id} to that file by hand\n`);
|
|
340
|
+
}
|
|
324
341
|
say("✓ GITHUB_APP_INSTALLATION_ID written\n");
|
|
325
342
|
summary.push(["installation id", `GITHUB_APP_INSTALLATION_ID=${chosen.id} written to .env`]);
|
|
326
343
|
} else {
|
|
@@ -346,7 +363,7 @@ function printSummary(say, summary, { noWebhook }) {
|
|
|
346
363
|
say(" Polling delivery is a follow-up; until it lands, forge triggers for this App will not fire on their own.\n");
|
|
347
364
|
} else {
|
|
348
365
|
say(" start the receiver so deliveries have somewhere to land: `pi-dispatch-receiver`\n");
|
|
349
|
-
say(" (or the deploy/docker-compose.yml receiver profile), reachable at the --webhook-url you gave GitHub.\n");
|
|
366
|
+
say(" (or, where the folder holds deploy/docker-compose.yml, its receiver profile), reachable at the --webhook-url you gave GitHub.\n");
|
|
350
367
|
}
|
|
351
368
|
say(" `pi-dispatch doctor` re-checks the whole deployment, including these credentials.\n");
|
|
352
369
|
}
|
package/src/github-prompt.mjs
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ISOLATION BOUNDARY (read before touching the delimiter below):
|
|
5
5
|
* The string this returns is written to /job/prompt.md and handed to session.prompt() as the USER
|
|
6
|
-
* prompt
|
|
6
|
+
* prompt: never a system prompt, never appendSystemPrompt. See image/runner/run-job.mjs:105 (read from
|
|
7
|
+
* /job/prompt.md) and :367 (`session.prompt(prompt)`), and image/runner/src/loader.mjs:366, whose
|
|
8
|
+
* appendSystemPromptOverride carries only the guardrails, the outbox protocol and the personas (why it
|
|
9
|
+
* overrides rather than discovers: :219-224).
|
|
7
10
|
* That placement IS the control: issue/PR text is data because it enters as a user turn, after the
|
|
8
11
|
* persona and the baked HARD_RULES system prompt, which the model treats as authoritative
|
|
9
12
|
* (CONST-ISSUE-TEXT-IS-DATA). The `## Triggering …` heading and the code fence around the payload are
|
package/src/gitlab-host.mjs
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { configError } from "./config.mjs";
|
|
27
27
|
import { InfraRetry } from "./processor.mjs";
|
|
28
28
|
import { fetchFailureReason } from "./gitlab-identity.mjs";
|
|
29
|
+
import { isDeterminateFetchFailure } from "./transient.mjs";
|
|
29
30
|
|
|
30
31
|
const API_PREFIX = "/api/v4";
|
|
31
32
|
|
|
@@ -39,6 +40,15 @@ export function makeGitLabHost({ apiUrl = "https://gitlab.com", fetchFn = fetch
|
|
|
39
40
|
try {
|
|
40
41
|
res = await fetchFn(`${root}${path}`, { headers: { "PRIVATE-TOKEN": token }, redirect: "error" });
|
|
41
42
|
} catch (err) {
|
|
43
|
+
// The one condition where the identity modules and this one now agree, because the issue's
|
|
44
|
+
// acceptance asks for exactly that (#316): a TLS trust failure, a protocol mismatch and a URL
|
|
45
|
+
// that always redirects are the operator's, and retrying them twice before failing tells
|
|
46
|
+
// nobody anything. Everything else here stays unconditionally retryable, which is right for a
|
|
47
|
+
// per-job call behind the queue: a wrong retry costs one more attempt, a wrong refusal costs
|
|
48
|
+
// the delivery and posts publicly that the deployment is misconfigured.
|
|
49
|
+
if (isDeterminateFetchFailure(err)) {
|
|
50
|
+
throw configError(`gitlab-host: GET ${path} failed (${fetchFailureReason(err)})`);
|
|
51
|
+
}
|
|
42
52
|
throw new InfraRetry(`gitlab-host: GET ${path} failed (${fetchFailureReason(err)})`);
|
|
43
53
|
}
|
|
44
54
|
if (res.status === 404 && notFound !== undefined) return notFound;
|
|
@@ -122,6 +132,15 @@ export function makeGitLabHost({ apiUrl = "https://gitlab.com", fetchFn = fetch
|
|
|
122
132
|
redirect: "error",
|
|
123
133
|
});
|
|
124
134
|
} catch (err) {
|
|
135
|
+
// The one condition where the identity modules and this one now agree, because the issue's
|
|
136
|
+
// acceptance asks for exactly that (#316): a TLS trust failure, a protocol mismatch and a URL
|
|
137
|
+
// that always redirects are the operator's, and retrying them twice before failing tells
|
|
138
|
+
// nobody anything. Everything else here stays unconditionally retryable, which is right for a
|
|
139
|
+
// per-job call behind the queue: a wrong retry costs one more attempt, a wrong refusal costs
|
|
140
|
+
// the delivery and posts publicly that the deployment is misconfigured.
|
|
141
|
+
if (isDeterminateFetchFailure(err)) {
|
|
142
|
+
throw configError(`gitlab-host: POST ${path} failed (${fetchFailureReason(err)})`);
|
|
143
|
+
}
|
|
125
144
|
throw new InfraRetry(`gitlab-host: POST ${path} failed (${fetchFailureReason(err)})`);
|
|
126
145
|
}
|
|
127
146
|
if (!res.ok) {
|
package/src/gitlab-identity.mjs
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { configError } from "./config.mjs";
|
|
19
|
+
import { isDeterminateFetchFailure, isJsonContentType, isTransientStatus, responseHeaderReader, transientError } from "./transient.mjs";
|
|
19
20
|
|
|
20
21
|
/** Resolve the acting identity's integer user id from `GET /user`. `fetchFn` is injected for tests. */
|
|
21
22
|
export async function resolveGitLabSelfId({ apiUrl, token, fetchFn = fetch }) {
|
|
@@ -27,17 +28,33 @@ export async function resolveGitLabSelfId({ apiUrl, token, fetchFn = fetch }) {
|
|
|
27
28
|
try {
|
|
28
29
|
res = await fetchFn(url, { headers: { "PRIVATE-TOKEN": token }, redirect: "error" });
|
|
29
30
|
} catch (err) {
|
|
30
|
-
|
|
31
|
+
// Unreachable is not misconfigured. The adjacent gitlab-host.mjs has classified this as
|
|
32
|
+
// InfraRetry since it was written; this file disagreed, and the disagreement decided whether
|
|
33
|
+
// the receiver ever came back from a forge restart (issue #316).
|
|
34
|
+
if (isDeterminateFetchFailure(err)) throw configError(`gitlab identity: GET /user failed (${fetchFailureReason(err)})`);
|
|
35
|
+
throw transientError(`gitlab identity: GET /user failed (${fetchFailureReason(err)})`, err);
|
|
31
36
|
}
|
|
32
37
|
if (!res.ok) {
|
|
33
38
|
// The status alone, never the body: an error body can echo the token back.
|
|
39
|
+
if (isTransientStatus(res.status, responseHeaderReader(res))) {
|
|
40
|
+
throw transientError(`gitlab identity: GET /user returned ${res.status}`);
|
|
41
|
+
}
|
|
34
42
|
throw configError(`gitlab identity: GET /user returned ${res.status}`);
|
|
35
43
|
}
|
|
36
44
|
let body;
|
|
37
45
|
try {
|
|
38
46
|
body = await res.json();
|
|
39
47
|
} catch (err) {
|
|
40
|
-
|
|
48
|
+
// A truncated body or a proxy interstitial, not a deployment an operator can fix.
|
|
49
|
+
// A body that will not parse is ambiguous in the expensive direction, so the CONTENT TYPE
|
|
50
|
+
// decides it. A truncated JSON body is transient. An HTML page is not: it is a Cloudflare
|
|
51
|
+
// Access or OIDC portal answering instead of the forge, and no amount of restarting gets past
|
|
52
|
+
// one (issue #316).
|
|
53
|
+
const contentType = responseHeaderReader(res);
|
|
54
|
+
if (contentType("content-type") !== undefined && !isJsonContentType(contentType)) {
|
|
55
|
+
throw configError(`gitlab identity: GET /user answered ${String(contentType("content-type"))} instead of JSON, so something other than the GitLab API replied`);
|
|
56
|
+
}
|
|
57
|
+
throw transientError(`gitlab identity: GET /user returned unparseable JSON (${err?.message ?? "unknown"})`, err);
|
|
41
58
|
}
|
|
42
59
|
if (!Number.isInteger(body?.id)) {
|
|
43
60
|
throw configError("gitlab identity: GET /user returned no integer id");
|
package/src/host-pi.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* What it must never become. Nothing on the worker's BOOT path may import this file. It reads host paths and
|
|
16
16
|
* may spawn a package manager, and neither belongs anywhere near `start.mjs`.
|
|
17
17
|
*
|
|
18
|
-
* Everything here MIRRORS a private detail of the pinned pi (0.
|
|
18
|
+
* Everything here MIRRORS a private detail of the pinned pi (0.99.1) rather than calling it: pi exports no
|
|
19
19
|
* public answer to "where is this package installed" or "is this resource enabled", and importing the whole
|
|
20
20
|
* coding-agent SDK to read two well-known paths is not worth the weight. That mirroring is a real risk --
|
|
21
21
|
* pi could change the grammar and we would silently start staging something the operator turned off -- so it
|
|
@@ -66,7 +66,12 @@ export const PINNED_PI_NEEDLES = {
|
|
|
66
66
|
"function isEnabledByOverrides(filePath, patterns, baseDir) {",
|
|
67
67
|
// The npm spec split that yields a name from `@scope/name@version`.
|
|
68
68
|
"const match = spec.match(/^(@?[^@]+(?:\\/[^@]+)?)(?:@(.+))?$/);",
|
|
69
|
+
// Built-in extensions (0.99.1, issue #509) are addressed in the SAME `extensions` list as files, as
|
|
70
|
+
// `builtin:<name>` paths, and resolved in a loop of their own. The mirror ignores those entries
|
|
71
|
+
// (see isOverridePattern); these two pin that they are still a separate namespace resolved apart.
|
|
72
|
+
"const path = `${BUILTIN_PATH_PREFIX}${name}`;",
|
|
69
73
|
],
|
|
74
|
+
"dist/core/source-info.js": ['export const BUILTIN_PATH_PREFIX = "builtin:";'],
|
|
70
75
|
"dist/core/settings-manager.d.ts": [
|
|
71
76
|
"export type PackageSource = string | {",
|
|
72
77
|
" autoload?: boolean;",
|
|
@@ -148,9 +153,20 @@ export function parsePackageSource(source) {
|
|
|
148
153
|
return { kind: "git", name, spec: null, requested: null, raw };
|
|
149
154
|
}
|
|
150
155
|
|
|
151
|
-
/**
|
|
156
|
+
/** pi's BUILTIN_PATH_PREFIX (source-info.js): a path naming a built-in extension, never a file. */
|
|
157
|
+
const BUILTIN_PATH_PREFIX = "builtin:";
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The override patterns that can address a FILE: pi's prefixes (getOverridePatterns), minus the ones whose
|
|
161
|
+
* body is a `builtin:<name>` path. From the 0.99.1 pin on (issue #509) the same `extensions` list holds
|
|
162
|
+
* `-builtin:mcp` and friends, which pi resolves against its built-in extensions only, never against a file
|
|
163
|
+
* on disk. Kept, they would change no verdict pi reaches, but they would still make a settings file with
|
|
164
|
+
* only a builtin entry look like one with overrides, and a `!builtin:*` glob would mark every extension
|
|
165
|
+
* "could not be evaluated", which is a note about nothing. Dropped here, before any verdict is computed.
|
|
166
|
+
*/
|
|
152
167
|
function isOverridePattern(pattern) {
|
|
153
|
-
|
|
168
|
+
if (typeof pattern !== "string" || !(pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-"))) return false;
|
|
169
|
+
return !normalizeExact(pattern.slice(1)).startsWith(BUILTIN_PATH_PREFIX);
|
|
154
170
|
}
|
|
155
171
|
|
|
156
172
|
/** Any minimatch magic we decline to interpret. Deliberately over-broad: a false "unknown" only costs a note. */
|
package/src/host-registry.mjs
CHANGED
|
@@ -135,6 +135,17 @@ export function makeHostRegistry({ redis, name, now = () => Date.now(), ttlMs =
|
|
|
135
135
|
// supersede lease `wait:key:<dedupId>` -- and only after checking the lease is still ours, which is
|
|
136
136
|
// the same ownership check this key gets for free by being named after its only writer.
|
|
137
137
|
await bounded(redis.pexpire(key, ttlMs), timeoutMs);
|
|
138
|
+
// RE-CHECKED HERE, AND ONLY HERE (issue #302). `close`'s drain is bounded at one `timeoutMs` while
|
|
139
|
+
// this function can spend one per command, so a write that outlived the drain would SADD the name
|
|
140
|
+
// back AFTER `close`'s SREM had removed it: the ghost the `inFlight` drain exists to prevent,
|
|
141
|
+
// arriving by a different door. Measured: `hset@0, pexpire@74, del@104, srem@105, sadd@145`.
|
|
142
|
+
//
|
|
143
|
+
// NOT before the PEXPIRE, which is the tempting symmetry and is wrong. A close landing between the
|
|
144
|
+
// HSET and the PEXPIRE would then leave `host:h:<name>` with NO EXPIRY AT ALL, where today it dies in
|
|
145
|
+
// ninety seconds -- invisible, because every reader walks `host:live` and the SREM emptied it, so a
|
|
146
|
+
// keyspace leak rather than a ghost peer, and the reader's own prune does not reach it. The SADD is
|
|
147
|
+
// the index write `close` undoes; the PEXPIRE is the row's own fuse and is always worth attempting.
|
|
148
|
+
if (closed) return;
|
|
138
149
|
await bounded(redis.sadd(HOST_SET, name), timeoutMs);
|
|
139
150
|
};
|
|
140
151
|
|
|
@@ -149,12 +160,22 @@ export function makeHostRegistry({ redis, name, now = () => Date.now(), ttlMs =
|
|
|
149
160
|
try {
|
|
150
161
|
inFlight = write({ ...facts, name, beatAt: now() });
|
|
151
162
|
await inFlight;
|
|
152
|
-
|
|
163
|
+
// GATED ON `closed`, IN BOTH ARMS, and that is the whole of issue #302. `close` drains with ONE
|
|
164
|
+
// `bounded(inFlight, timeoutMs)` while `write` spends a fresh `timeoutMs` on each of its commands,
|
|
165
|
+
// so a write can outlive the drain -- and the continuation here then speaks through the boot's own
|
|
166
|
+
// `log` closure, stamped with a host that has stopped. Measured against a stalled Valkey:
|
|
167
|
+
// `host_registry_unreachable` at +76ms and `host_registry_restored` at +108ms after `close()` had
|
|
168
|
+
// RESOLVED. The restored arm is not the afterthought it looks like: a drain that gave up can see the
|
|
169
|
+
// write SUCCEED late just as easily as fail, and #302 named only the catch.
|
|
170
|
+
//
|
|
171
|
+
// This is `makeWatchCloser`'s `reloadLog` posture reached from the other side: work already in
|
|
172
|
+
// flight cannot be recalled, so what a close gates is its VOICE.
|
|
173
|
+
if (!reachable && !closed) {
|
|
153
174
|
reachable = true;
|
|
154
175
|
log("host_registry_restored", { host: name });
|
|
155
176
|
}
|
|
156
177
|
} catch (err) {
|
|
157
|
-
if (reachable) {
|
|
178
|
+
if (reachable && !closed) {
|
|
158
179
|
reachable = false;
|
|
159
180
|
log("host_registry_unreachable", { host: name, reason: err?.message });
|
|
160
181
|
}
|
|
@@ -218,6 +239,12 @@ export function makeHostRegistry({ redis, name, now = () => Date.now(), ttlMs =
|
|
|
218
239
|
timer = null;
|
|
219
240
|
// Drain before deleting, bounded like everything else here: an unbounded wait on a beat that is
|
|
220
241
|
// itself hung would be the shutdown hang this module's timeout exists to prevent.
|
|
242
|
+
//
|
|
243
|
+
// So the drain is BEST-EFFORT, and it deliberately stays at one `timeoutMs` (issue #302): `write`
|
|
244
|
+
// can spend one per command, so this can return while a write is still running, and widening the
|
|
245
|
+
// wait to match would triple a shutdown bound whose entire point is to be short. What makes that
|
|
246
|
+
// safe is the `closed` FLAG rather than the wait -- it gates the SADD inside `write` and both log
|
|
247
|
+
// arms in `beat`, so a write that outlives this drain can neither re-index the row nor say a word.
|
|
221
248
|
await bounded(inFlight ?? Promise.resolve(), timeoutMs).catch(() => {});
|
|
222
249
|
try {
|
|
223
250
|
await bounded(redis.del(hostKey(name)), timeoutMs);
|