@edgehero/pi-dispatch 2.0.0 → 3.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.
Files changed (80) hide show
  1. package/.env.example +44 -7
  2. package/README.md +14 -6
  3. package/deploy/com.pi-dispatch.worker.plist +1 -1
  4. package/deploy/docker-compose.yml +12 -0
  5. package/deploy/egress-proxy.conf +28 -3
  6. package/deploy/pi-dispatch-egress-proxy.container +8 -2
  7. package/deploy/worker-env-wrapper.cmd +1 -1
  8. package/deploy/worker-env-wrapper.sh +3 -3
  9. package/package.json +9 -2
  10. package/src/allocation.mjs +731 -0
  11. package/src/backends.mjs +243 -0
  12. package/src/budget.mjs +40 -4
  13. package/src/cli.mjs +222 -11
  14. package/src/config.mjs +126 -5
  15. package/src/daemon-facts.mjs +3 -0
  16. package/src/deployment-venue.mjs +1 -0
  17. package/src/doctor.mjs +2316 -183
  18. package/src/dollar-budget.mjs +373 -0
  19. package/src/dollar-fingerprint.mjs +83 -0
  20. package/src/egress-cli.mjs +316 -0
  21. package/src/egress-proxy-state.mjs +35 -5
  22. package/src/egress.mjs +16 -3
  23. package/src/env-allowlist.mjs +142 -18
  24. package/src/env-file.mjs +194 -25
  25. package/src/envelope.mjs +413 -0
  26. package/src/exit-code.mjs +22 -0
  27. package/src/fleet-lease.mjs +85 -25
  28. package/src/get-token.mjs +16 -5
  29. package/src/git-dirty.mjs +67 -0
  30. package/src/github-app-setup.mjs +6 -3
  31. package/src/github-host.mjs +5 -3
  32. package/src/host-pi.mjs +19 -3
  33. package/src/identity.mjs +2 -1
  34. package/src/image-preflight.mjs +98 -24
  35. package/src/image-ref.mjs +37 -0
  36. package/src/import-pi.mjs +4 -2
  37. package/src/index.mjs +407 -62
  38. package/src/init.mjs +18 -0
  39. package/src/job-id.mjs +26 -3
  40. package/src/live-probes.mjs +24 -9
  41. package/src/model-catalog.mjs +297 -0
  42. package/src/model-endpoints.mjs +649 -0
  43. package/src/model-ref.mjs +151 -0
  44. package/src/models-json.mjs +262 -0
  45. package/src/money.mjs +144 -0
  46. package/src/octokit-log.mjs +65 -0
  47. package/src/outbox-plan.mjs +218 -0
  48. package/src/outbox.mjs +29 -9
  49. package/src/output-cap.mjs +157 -0
  50. package/src/packages.mjs +2 -2
  51. package/src/pause-windows.mjs +81 -2
  52. package/src/pi-model-loader.mjs +77 -0
  53. package/src/podman-stack.mjs +16 -3
  54. package/src/portfolio-snapshot.mjs +304 -0
  55. package/src/prepare-local.mjs +247 -12
  56. package/src/prepare.mjs +35 -3
  57. package/src/pricing.mjs +9 -5
  58. package/src/priorities.mjs +569 -0
  59. package/src/processor.mjs +603 -173
  60. package/src/project-id.mjs +17 -0
  61. package/src/projects.mjs +238 -0
  62. package/src/provider-key.mjs +32 -7
  63. package/src/provider-steering.mjs +214 -59
  64. package/src/queue.mjs +111 -6
  65. package/src/reserved-env.mjs +30 -0
  66. package/src/run-container.mjs +59 -5
  67. package/src/run-history.mjs +379 -24
  68. package/src/run-mirror.mjs +30 -0
  69. package/src/runtime-settings.mjs +104 -9
  70. package/src/schedules.mjs +33 -1
  71. package/src/scoped-limits.mjs +447 -27
  72. package/src/secrets.mjs +2 -1
  73. package/src/service.mjs +15 -4
  74. package/src/session-store.mjs +131 -6
  75. package/src/start.mjs +528 -40
  76. package/src/subscriptions.mjs +7 -3
  77. package/src/triggers-file.mjs +65 -4
  78. package/src/triggers.mjs +140 -9
  79. package/src/up.mjs +308 -34
  80. package/src/valkey-endpoint.mjs +3 -2
@@ -19,6 +19,7 @@ import { scrubCredentials } from "./redact.mjs";
19
19
  import { sessionKeyFor } from "./session-key.mjs";
20
20
  import { resolveBackendName } from "./backend-registry.mjs";
21
21
  import { UNATTRIBUTED_BACKEND } from "./backends.mjs";
22
+ import { MODEL_REF_PATTERN } from "./model-ref.mjs";
22
23
 
23
24
  /**
24
25
  * session-store.mjs -- the host side of a resumable session (INT-SESSION-STORE-CONTRACT).
@@ -152,9 +153,16 @@ const SIDECAR_MAX_BYTES = 4096;
152
153
  * The host-effective provider and model as one token, or null when the job names neither.
153
154
  *
154
155
  * CONSERVATIVE BY CONSTRUCTION: the sidecar is whitespace-delimited, so a value carrying a space would
155
- * split the record and be read back as a different field. Rather than escape, refuse: anything outside
156
- * the charset the run record already validates model ids against is no identity, and no identity means
157
- * the reading stays usable rather than being thrown away.
156
+ * split the record and be read back as a different field. Rather than escape, refuse: a provider or model
157
+ * that fails the run record's own id rule (MODEL_REF_PATTERN, model-ref.mjs) is no identity, and no
158
+ * identity means the reading stays usable rather than being thrown away.
159
+ *
160
+ * Each HALF is held to that rule, not the joined string, so this cannot drift from the record's charset
161
+ * again: it used to carry its own copy of the record's old pattern, and when issues #501/#502 widened the
162
+ * record to admit `@cf/...` and `~alias` ids, a job on one of those got no stamp and its reading was used
163
+ * for any other model on the same key. The old 128-character cap on the joined id was never load-bearing:
164
+ * the reader splits on whitespace and compares the whole token, and the sidecar is bounded by
165
+ * SIDECAR_MAX_BYTES; two 64-character halves make at most 129.
158
166
  */
159
167
  function modelIdentity(job) {
160
168
  const provider = typeof job?.provider === "string" ? job.provider : "";
@@ -163,8 +171,10 @@ function modelIdentity(job) {
163
171
  // Lowercased first, the same normalisation the run record's own model ids get, so a trigger written
164
172
  // `Claude-Sonnet` and one written `claude-sonnet` are one model rather than two -- and so that a
165
173
  // perfectly ordinary id does not fall out of the charset below and silently stop stamping.
166
- const id = `${provider}/${model}`.toLowerCase();
167
- return /^[a-z0-9][a-z0-9._:/-]{0,127}$/.test(id) ? id : null;
174
+ const lowerProvider = provider.toLowerCase();
175
+ const lowerModel = model.toLowerCase();
176
+ if (!MODEL_REF_PATTERN.test(lowerProvider) || !MODEL_REF_PATTERN.test(lowerModel)) return null;
177
+ return `${lowerProvider}/${lowerModel}`;
168
178
  }
169
179
 
170
180
  /**
@@ -183,6 +193,106 @@ function normaliseVenue(venue) {
183
193
  */
184
194
  const COLD = (reason) => ({ resume: false, reason, bytes: null });
185
195
 
196
+ /**
197
+ * The text pi 0.99.1 puts around a model's words in a compaction summary (issue #535). pi's `compact()`
198
+ * joins a split turn's two summaries with SPLIT_TURN_MARKER (`compaction.js`), and then appends the files
199
+ * the folded turns read and changed (`formatFileOperations`, `utils.js`). None of it is the model's, so
200
+ * none of it counts as a summary. A pin bump that rewords either is held by the runner's integration test
201
+ * (`compaction-refused.integration.test.mjs`), which feeds pi's own output through this check.
202
+ */
203
+ const SPLIT_TURN_MARKER = "\n\n---\n\n**Turn Context (split turn):**\n\n";
204
+ /**
205
+ * pi's two lists, in the REVERSE of the order `formatFileOperations` appends them (read, then modified), because
206
+ * they are taken off the end. Each is `\n\n<tag>\n...\n</tag>`, and either may be absent.
207
+ */
208
+ const FILE_LIST_TAGS_FROM_END = ["modified-files", "read-files"];
209
+
210
+ /**
211
+ * pi's exact file-list suffix for the lists a compaction entry records in `details`, or null when `details`
212
+ * does not hold two lists of strings. The same text pi's `formatFileOperations` builds (utils.js, held to it
213
+ * by the runner's integration test): each non-empty list as `<tag>\n<path per line>\n</tag>`, read first,
214
+ * joined by a blank line, and the whole led by one. No lists is the empty string.
215
+ */
216
+ export function fileListSuffix(details) {
217
+ const read = details?.readFiles;
218
+ const modified = details?.modifiedFiles;
219
+ const strings = (list) => Array.isArray(list) && list.every((item) => typeof item === "string");
220
+ if (!strings(read) || !strings(modified)) return null;
221
+ const sections = [];
222
+ if (read.length > 0) sections.push(`<read-files>\n${read.join("\n")}\n</read-files>`);
223
+ if (modified.length > 0) sections.push(`<modified-files>\n${modified.join("\n")}\n</modified-files>`);
224
+ return sections.length === 0 ? "" : `\n\n${sections.join("\n\n")}`;
225
+ }
226
+
227
+ /**
228
+ * Whether a compaction summary holds nothing the model wrote, so a resumed session would go on without
229
+ * the turns it replaced.
230
+ *
231
+ * pi writes a refused summarization call as an EMPTY summary, not as a failed compaction: the runner's
232
+ * hard stop is `stopReason: "aborted"`, and pi rejects only `"error"` and `"length"`. Three shapes were
233
+ * measured at the pin, each optionally followed by the file lists:
234
+ * - `""`, when the cut fell on a user message and the one call was refused;
235
+ * - `"\n\n---\n\n**Turn Context (split turn):**\n\n"`, a split turn whose two calls were both refused;
236
+ * - `"No prior history." + marker`, a split turn with no history to summarise and its one call refused.
237
+ * So the check strips pi's file lists from the END only (exactly, from the entry's `details`, when it has
238
+ * them), splits on the LAST marker, and calls the summary empty when ANY part is blank. A summary of the history with no summary of the split turn still lost that
239
+ * turn's opening. The last marker, because the history part can be a previous summary that holds a marker of
240
+ * its own.
241
+ *
242
+ * A summary that is not a string is empty: an entry with no summary carries none. Whitespace is blank, so
243
+ * a provider that answered a summary with only a newline is caught too.
244
+ */
245
+ export function compactionSummaryIsEmpty(summary, details) {
246
+ if (typeof summary !== "string") return true;
247
+ let text = summary;
248
+ // FIRST CHOICE: the entry's own `details` say which files pi listed, so the exact suffix pi appended is
249
+ // rebuilt and taken off with one `endsWith`. That is the only strip a path cannot fool: pi copies a
250
+ // tool call's path verbatim, so a path holding a list header would misplace any search for one.
251
+ const exact = fileListSuffix(details);
252
+ if (exact !== null && text.endsWith(exact)) {
253
+ text = text.slice(0, text.length - exact.length);
254
+ } else {
255
+ // FALLBACK, for an entry with no usable `details`: pi appends at most two lists, read then modified, so
256
+ // they come off the END, modified first, each at most once. A list is taken only when the text ENDS in
257
+ // its closer, and from that list's LAST opener: nothing before it is searched, so a list the model
258
+ // quoted mid-text, or one inside a previous summary that pi reused as a split turn's history, is never
259
+ // taken for pi's own. Linear, with no regular expression.
260
+ for (const tag of FILE_LIST_TAGS_FROM_END) {
261
+ if (!text.endsWith(`\n</${tag}>`)) continue;
262
+ const open = text.lastIndexOf(`\n\n<${tag}>\n`);
263
+ if (open !== -1) text = text.slice(0, open);
264
+ }
265
+ }
266
+ const at = text.lastIndexOf(SPLIT_TURN_MARKER);
267
+ const parts = at === -1 ? [text] : [text.slice(0, at), text.slice(at + SPLIT_TURN_MARKER.length)];
268
+ return parts.some((part) => part.trim() === "");
269
+ }
270
+
271
+ /**
272
+ * Whether a transcript holds ANY compaction whose summary is empty (issue #535).
273
+ *
274
+ * Any, not only the last. A later compaction is built on the earlier one's summary, so an empty one
275
+ * anywhere means the turns it replaced are gone from every summary after it. A compaction on a branch
276
+ * the session has left is judged too; that can only cost a cold start, the safe direction.
277
+ *
278
+ * A line is parsed only when it names `"compaction"` at all, and a line that does not parse is skipped,
279
+ * as pi's own reader skips it. Never throws.
280
+ */
281
+ export function hasEmptyCompaction(transcript) {
282
+ if (typeof transcript !== "string") return false;
283
+ for (const line of transcript.split("\n")) {
284
+ if (!line.includes('"compaction"')) continue;
285
+ let entry;
286
+ try {
287
+ entry = JSON.parse(line);
288
+ } catch {
289
+ continue;
290
+ }
291
+ if (entry?.type === "compaction" && compactionSummaryIsEmpty(entry.summary, entry.details)) return true;
292
+ }
293
+ return false;
294
+ }
295
+
186
296
  export function makeSessionStore({
187
297
  sessionsDir,
188
298
  ttlDays,
@@ -918,8 +1028,10 @@ export function makeSessionStore({
918
1028
  // the container's degrade path for genuine surprises rather than for a file we could already tell
919
1029
  // was wrong.
920
1030
  let header = null;
1031
+ let transcript = "";
921
1032
  try {
922
- const head = String(fs.readFileSync(file, "utf8")).split("\n", 1)[0];
1033
+ transcript = String(fs.readFileSync(file, "utf8"));
1034
+ const head = transcript.split("\n", 1)[0];
923
1035
  header = JSON.parse(head);
924
1036
  if (header?.type !== "session") return COLD("unparseable");
925
1037
  } catch {
@@ -954,6 +1066,19 @@ export function makeSessionStore({
954
1066
  if (!Number.isFinite(started)) return COLD("conversation-too-old");
955
1067
  if (now() - started > maxAgeDays * 86400000) return COLD("conversation-too-old");
956
1068
  }
1069
+
1070
+ // A compaction with nothing in its summary (issue #535). pi replaces the turns a compaction folds away
1071
+ // with its summary, so a resumed job would go on without that context and nothing would say so. The
1072
+ // runner's brake is the measured cause: pi records a refused summarization call as an empty summary
1073
+ // rather than failing the compaction. That run stops with a policy exit and is never promoted, but a
1074
+ // provider that answers a summary with no text writes the same shape on a run that completes.
1075
+ //
1076
+ // ALWAYS ON, unlike the bounds above: it is not an operator's limit but a transcript that cannot be
1077
+ // resumed as what it claims to be. LAST, because it is the one arm that reads past the header, and a
1078
+ // damaged or aged-out transcript should name itself first. A cold start, like every arm here, and not
1079
+ // a failed job: only a completed run replaces the canonical transcript, so a refusal that failed the
1080
+ // job would refuse that key on every later run as well.
1081
+ if (hasEmptyCompaction(transcript)) return COLD("compaction-summary-empty");
957
1082
  return { resume: true, reason: "resumed", bytes: check.bytes, ident: check.ident, dirIdent: dirCheck.ident };
958
1083
  }
959
1084