@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "1.6.0",
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
- // Claimed BEFORE the check, not after: a second delivery for an already-held target is a free
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.
@@ -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
- return { resumed: sess.resumed, reason: typeof sess.reason === "string" ? sess.reason : null };
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;