@edgehero/pi-dispatch 1.6.0 → 1.6.1
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/package.json +1 -1
- package/src/index.mjs +23 -14
- package/src/run-history.mjs +75 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Self-hosted job harness for the pi coding agent: a BullMQ worker that drains the queue, mints scoped forge tokens, and runs one container per job — plus the pi-dispatch CLI (init, up, doctor, service).",
|
|
6
6
|
"keywords": [
|
package/src/index.mjs
CHANGED
|
@@ -227,23 +227,32 @@ export function makeProcessor({ cancelJob, stopContainer, redis, getSettings, ap
|
|
|
227
227
|
throw new DelayedError();
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
//
|
|
231
|
-
// determinate refusal, and paying for a subprocess first inverts the free-before-costly rule
|
|
232
|
-
// this gate's own header invokes. Tier 1 already claims in this order.
|
|
233
|
-
const claim = await waitState.claim(job.id, { dedupId, untilMs: nowMs + waitBackoffMs(intervalMs(), held), isLive: deps?.isJobLive });
|
|
234
|
-
if (claim.heldBy) {
|
|
235
|
-
return await refuseWait("wait-superseded", "wait_superseded", { heldBy: claim.heldBy }, "Another delivery for this target is already waiting on the same conditions. Not run.");
|
|
236
|
-
}
|
|
237
|
-
if (claim.retry) {
|
|
238
|
-
deps?.log?.("wait_supersede_unverified", { jobId: job.id, heldBy: claim.holder ?? null, delayMs: SUPERSEDE_RECHECK_MS });
|
|
239
|
-
await job.moveToDelayed(nowMs + SUPERSEDE_RECHECK_MS, token);
|
|
240
|
-
throw new DelayedError();
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
await waitState.noteThrottle(job.id, { denied: false }); // granted: the run of denials ends here
|
|
230
|
+
// Declared outside the try below because the branches AFTER it read both.
|
|
244
231
|
let verdict = null;
|
|
245
232
|
let checked = null;
|
|
233
|
+
// THE LEASE IS HELD FROM THE `tryAcquire` ABOVE, so every exit from here down must release it.
|
|
234
|
+
// The try opens here and not at the check loop, which is where it used to open: the supersede
|
|
235
|
+
// claim sits between the two, and BOTH of its exits leave -- one returns `wait-superseded`,
|
|
236
|
+
// the other re-defers and throws -- so a claim that refused or could not be verified walked
|
|
237
|
+
// out holding the slot. At the shipped default of one slot that wedged every wait check on
|
|
238
|
+
// the worker until it restarted, and the symptom was silent in the worst way: held jobs kept
|
|
239
|
+
// throttling and eventually recorded `wait-expired` with `max-wait-unchecked`, which blames
|
|
240
|
+
// the deployment's capacity for a slot this gate leaked.
|
|
246
241
|
try {
|
|
242
|
+
// Claimed BEFORE the check, not after: a second delivery for an already-held target is a free
|
|
243
|
+
// determinate refusal, and paying for a subprocess first inverts the free-before-costly rule
|
|
244
|
+
// this gate's own header invokes. Tier 1 already claims in this order.
|
|
245
|
+
const claim = await waitState.claim(job.id, { dedupId, untilMs: nowMs + waitBackoffMs(intervalMs(), held), isLive: deps?.isJobLive });
|
|
246
|
+
if (claim.heldBy) {
|
|
247
|
+
return await refuseWait("wait-superseded", "wait_superseded", { heldBy: claim.heldBy }, "Another delivery for this target is already waiting on the same conditions. Not run.");
|
|
248
|
+
}
|
|
249
|
+
if (claim.retry) {
|
|
250
|
+
deps?.log?.("wait_supersede_unverified", { jobId: job.id, heldBy: claim.holder ?? null, delayMs: SUPERSEDE_RECHECK_MS });
|
|
251
|
+
await job.moveToDelayed(nowMs + SUPERSEDE_RECHECK_MS, token);
|
|
252
|
+
throw new DelayedError();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await waitState.noteThrottle(job.id, { denied: false }); // granted: the run of denials ends here
|
|
247
256
|
// Sequential, in the operator's writing order: the resolver's reason applies unchanged --
|
|
248
257
|
// naming the first condition that did not clear is what makes a held row readable, and a
|
|
249
258
|
// parallel fan-out would blame whichever lost the race on any given wake.
|
package/src/run-history.mjs
CHANGED
|
@@ -114,7 +114,34 @@ export function parseExitTurns(text) {
|
|
|
114
114
|
* Read-only telemetry, exactly like `parseExitTurns`: NEVER throws and MUST NOT feed exit-code or retry
|
|
115
115
|
* classification (INT-RUNNER-EXIT-CODE-PROTOCOL). A malformed or non-object `tokens` (or one missing a
|
|
116
116
|
* numeric `total`) is `null`, never a partial that could poison the daily token counter.
|
|
117
|
+
*
|
|
118
|
+
* The admitted object is REBUILT through `rebuildTokens` rather than returned as it arrived. See that
|
|
119
|
+
* function for why: the pass-through it replaces is what made this comment's "integer token counts and
|
|
120
|
+
* numeric cost only" false one level below `buildRecord`'s literal.
|
|
121
|
+
*/
|
|
122
|
+
/**
|
|
123
|
+
* The CLOSED `session.reason` enum, verbatim from `INT-RUN-HISTORY-FILE-CONTRACT`. Three producers write
|
|
124
|
+
* this field (resolve, runner, promote) and the contract has always called the set closed; until this
|
|
125
|
+
* list existed, nothing enforced it and the runner's half was an unchecked string. Kept here rather than
|
|
126
|
+
* beside the store because this module is where the container's copy is admitted, and an enum that lives
|
|
127
|
+
* anywhere but the admission point is a comment, not a check.
|
|
117
128
|
*/
|
|
129
|
+
const SESSION_REASONS = new Set([
|
|
130
|
+
"resumed",
|
|
131
|
+
"absent",
|
|
132
|
+
"expired",
|
|
133
|
+
"conversation-too-old",
|
|
134
|
+
"resume-chain-too-long",
|
|
135
|
+
"context-too-full",
|
|
136
|
+
"too-large",
|
|
137
|
+
"unparseable",
|
|
138
|
+
"not-a-regular-file",
|
|
139
|
+
"pi-version-changed",
|
|
140
|
+
"locked",
|
|
141
|
+
"promote-failed",
|
|
142
|
+
"disabled",
|
|
143
|
+
]);
|
|
144
|
+
|
|
118
145
|
/**
|
|
119
146
|
* The runner's `session` object off the exit line: `{ resumed: <bool>, reason: "<enum>" }` or null when
|
|
120
147
|
* the container died before emitting one (REQ-RESUMABLE-SESSION).
|
|
@@ -125,7 +152,8 @@ export function parseExitTurns(text) {
|
|
|
125
152
|
* without both numbers it is indistinguishable from an ordinary cold start. A feature that fails open
|
|
126
153
|
* must still say that it did.
|
|
127
154
|
*
|
|
128
|
-
* PII-free by construction: a boolean and a fixed enum. No key, no branch name, no path
|
|
155
|
+
* PII-free by construction: a boolean and a fixed enum. No key, no branch name, no path -- and since the
|
|
156
|
+
* `SESSION_REASONS` check below, that sentence is enforced rather than merely intended.
|
|
129
157
|
*/
|
|
130
158
|
export function parseExitSession(text) {
|
|
131
159
|
if (typeof text !== "string") return null;
|
|
@@ -137,7 +165,14 @@ export function parseExitSession(text) {
|
|
|
137
165
|
if (parsed?.event !== "exit") continue;
|
|
138
166
|
const sess = parsed?.session;
|
|
139
167
|
if (sess && typeof sess === "object" && !Array.isArray(sess) && typeof sess.resumed === "boolean") {
|
|
140
|
-
|
|
168
|
+
// The reason is checked against the CLOSED enum, not merely against `typeof === "string"`, which
|
|
169
|
+
// is what it used to be. The container owns this value, so an unchecked string put an
|
|
170
|
+
// attacker-shapeable one into a record whose PII-free property rests on holding none -- while
|
|
171
|
+
// the comment above claimed "a boolean and a fixed enum". An unrecognised token reads as `null`
|
|
172
|
+
// (the runner said nothing this contract can represent) rather than being carried through: the
|
|
173
|
+
// enum is documented CLOSED in INT-RUN-HISTORY-FILE-CONTRACT, so a value outside it was already
|
|
174
|
+
// contract-violating and every consumer already handles null.
|
|
175
|
+
return { resumed: sess.resumed, reason: SESSION_REASONS.has(sess.reason) ? sess.reason : null };
|
|
141
176
|
}
|
|
142
177
|
return null;
|
|
143
178
|
}
|
|
@@ -176,6 +211,43 @@ export function parseExitContext(text) {
|
|
|
176
211
|
return null;
|
|
177
212
|
}
|
|
178
213
|
|
|
214
|
+
/**
|
|
215
|
+
* The keys the runner actually emits, in its own emission order: the metered snapshot
|
|
216
|
+
* (`image/runner/src/usage-meter.mjs` -> `snapshot`) plus the token-budget fallback
|
|
217
|
+
* (`image/runner/run-job.mjs` -> `pickTotals`, which sends the first four and `metered: false`). Order
|
|
218
|
+
* matters because it is what makes a conformant runner's object round-trip byte-identically through the
|
|
219
|
+
* rebuild below, so the record's bytes do not move for anyone running a real image.
|
|
220
|
+
*/
|
|
221
|
+
const TOKEN_KEYS = ["input", "output", "total", "cost", "metered", "rootTotal", "otherTotal", "looseTotal", "sessions", "calls", "unresolved", "unpriced"];
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Rebuild the billed totals from a closed key list rather than passing the container's object through.
|
|
225
|
+
*
|
|
226
|
+
* This function exists because the pass-through was a hole. `parseExitTokens` used to `return t`
|
|
227
|
+
* verbatim whenever `t.total` was a number, so any key the container invented -- a path, a branch name,
|
|
228
|
+
* a string it read out of the workspace -- rode into the durable record, and from there into anything
|
|
229
|
+
* that mirrors it. The record's PII-free-by-construction property held at `buildRecord`'s own level and
|
|
230
|
+
* NOT one level down, while this module's own comment claimed "integer token counts and numeric cost
|
|
231
|
+
* only". `parseExitUsage` already rebuilds for exactly this reason and says so; this is the sibling that
|
|
232
|
+
* did not, and the asymmetry was an oversight rather than a decision.
|
|
233
|
+
*
|
|
234
|
+
* A key the runner omitted stays OMITTED rather than becoming null: the fallback shape legitimately
|
|
235
|
+
* carries only five of the twelve, and a null there would read as "measured zero" for a number nobody
|
|
236
|
+
* measured. `typeof === "number"` rather than `Number.isFinite`, deliberately, so this narrows WHICH
|
|
237
|
+
* KEYS survive and never which objects are admitted -- the admission gate above is unchanged.
|
|
238
|
+
*/
|
|
239
|
+
function rebuildTokens(t) {
|
|
240
|
+
const out = {};
|
|
241
|
+
for (const key of TOKEN_KEYS) {
|
|
242
|
+
if (key === "metered") {
|
|
243
|
+
if (typeof t.metered === "boolean") out.metered = t.metered;
|
|
244
|
+
} else if (typeof t[key] === "number") {
|
|
245
|
+
out[key] = t[key];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
179
251
|
export function parseExitTokens(text) {
|
|
180
252
|
if (typeof text !== "string") return null;
|
|
181
253
|
const lines = text.split("\n");
|
|
@@ -185,7 +257,7 @@ export function parseExitTokens(text) {
|
|
|
185
257
|
const parsed = parseTailLine(line);
|
|
186
258
|
if (parsed?.event !== "exit") continue;
|
|
187
259
|
const t = parsed?.tokens;
|
|
188
|
-
if (t && typeof t === "object" && !Array.isArray(t) && typeof t.total === "number") return t;
|
|
260
|
+
if (t && typeof t === "object" && !Array.isArray(t) && typeof t.total === "number") return rebuildTokens(t);
|
|
189
261
|
return null;
|
|
190
262
|
}
|
|
191
263
|
return null;
|