@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.
- package/.env.example +44 -7
- package/README.md +14 -6
- package/deploy/com.pi-dispatch.worker.plist +1 -1
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/deploy/worker-env-wrapper.cmd +1 -1
- package/deploy/worker-env-wrapper.sh +3 -3
- package/package.json +9 -2
- package/src/allocation.mjs +731 -0
- package/src/backends.mjs +243 -0
- package/src/budget.mjs +40 -4
- package/src/cli.mjs +222 -11
- package/src/config.mjs +126 -5
- package/src/daemon-facts.mjs +3 -0
- package/src/deployment-venue.mjs +1 -0
- package/src/doctor.mjs +2316 -183
- package/src/dollar-budget.mjs +373 -0
- package/src/dollar-fingerprint.mjs +83 -0
- package/src/egress-cli.mjs +316 -0
- package/src/egress-proxy-state.mjs +35 -5
- package/src/egress.mjs +16 -3
- package/src/env-allowlist.mjs +142 -18
- package/src/env-file.mjs +194 -25
- package/src/envelope.mjs +413 -0
- package/src/exit-code.mjs +22 -0
- package/src/fleet-lease.mjs +85 -25
- package/src/get-token.mjs +16 -5
- package/src/git-dirty.mjs +67 -0
- package/src/github-app-setup.mjs +6 -3
- package/src/github-host.mjs +5 -3
- package/src/host-pi.mjs +19 -3
- package/src/identity.mjs +2 -1
- package/src/image-preflight.mjs +98 -24
- package/src/image-ref.mjs +37 -0
- package/src/import-pi.mjs +4 -2
- package/src/index.mjs +407 -62
- package/src/init.mjs +18 -0
- package/src/job-id.mjs +26 -3
- package/src/live-probes.mjs +24 -9
- package/src/model-catalog.mjs +297 -0
- package/src/model-endpoints.mjs +649 -0
- package/src/model-ref.mjs +151 -0
- package/src/models-json.mjs +262 -0
- package/src/money.mjs +144 -0
- package/src/octokit-log.mjs +65 -0
- package/src/outbox-plan.mjs +218 -0
- package/src/outbox.mjs +29 -9
- package/src/output-cap.mjs +157 -0
- package/src/packages.mjs +2 -2
- package/src/pause-windows.mjs +81 -2
- package/src/pi-model-loader.mjs +77 -0
- package/src/podman-stack.mjs +16 -3
- package/src/portfolio-snapshot.mjs +304 -0
- package/src/prepare-local.mjs +247 -12
- package/src/prepare.mjs +35 -3
- package/src/pricing.mjs +9 -5
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +603 -173
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-key.mjs +32 -7
- package/src/provider-steering.mjs +214 -59
- package/src/queue.mjs +111 -6
- package/src/reserved-env.mjs +30 -0
- package/src/run-container.mjs +59 -5
- package/src/run-history.mjs +379 -24
- package/src/run-mirror.mjs +30 -0
- package/src/runtime-settings.mjs +104 -9
- package/src/schedules.mjs +33 -1
- package/src/scoped-limits.mjs +447 -27
- package/src/secrets.mjs +2 -1
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/subscriptions.mjs +7 -3
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +140 -9
- package/src/up.mjs +308 -34
- package/src/valkey-endpoint.mjs +3 -2
package/src/session-store.mjs
CHANGED
|
@@ -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:
|
|
156
|
-
*
|
|
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
|
|
167
|
-
|
|
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
|
-
|
|
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
|
|