@coreplane/switchboard 1.267.2 → 1.268.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/dist/assets/deploy/cloudflare-memory/testDiagnostics.ts +44 -3
- package/dist/assets/deploy/cloudflare-memory/testDiagnosticsProtocol.ts +7 -0
- package/dist/assets/deploy/cloudflare-memory/testDiagnosticsReporter.ts +22 -0
- package/dist/assets/deploy/cloudflare-memory/testFetch.ts +21 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +66 -1
- package/dist/assets/deploy/cloudflare-resident/imageReconcileState.ts +28 -0
- package/dist/assets/deploy/cloudflare-resident/runRegistration.ts +68 -0
- package/dist/assets/deploy/cloudflare-resident/shared.ts +2 -3
- package/dist/assets/deploy/cloudflare-resident/worker.ts +541 -192
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +17 -2
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +2 -2
- package/dist/assets/src/core/budgets.ts +6 -0
- package/dist/assets/src/core/coordinator/contract.ts +14 -8
- package/dist/assets/src/core/costs.ts +2 -0
- package/dist/assets/src/core/runEvents.ts +18 -1
- package/dist/assets/src/core/runLedger/types.ts +2 -0
- package/dist/assets/src/core/runRecord.ts +27 -1
- package/dist/assets/src/core/runUsage.ts +53 -0
- package/dist/assets/src/core/ship/coordinator.ts +4 -1
- package/dist/assets/src/core/shipSpend.ts +52 -0
- package/dist/assets/src/core/trace/attrs.ts +22 -0
- package/dist/assets/src/core/trace/tracer.ts +10 -2
- package/dist/assets/src/core/trace/types.ts +12 -1
- package/dist/assets/src/core/trace/workerTrace.ts +2 -2
- package/dist/assets/src/execution/residentPoolSpends.ts +9 -0
- package/dist/assets/src/execution/residentRecreateAdmission.ts +28 -0
- package/dist/assets/src/execution/seedPlan.ts +13 -4
- package/dist/assets/web/dist/.vite/manifest.json +31 -31
- package/dist/assets/web/dist/assets/CostsPage-6P9rJ7k_.js +1 -0
- package/dist/assets/web/dist/assets/{HomePage-BQmun9kS.js → HomePage-B4LGwXHO.js} +1 -1
- package/dist/assets/web/dist/assets/ResidentDetailPage-jpGfsR5n.js +1 -0
- package/dist/assets/web/dist/assets/ResidentsIndexPage-C4-5nG9M.js +1 -0
- package/dist/assets/web/dist/assets/{RunFoldRow-C1w-mwch.js → RunFoldRow-u4FTh57c.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-zWMHTzla.js → RunRoutePage-CxOKb7oC.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-DF16D2Gg.js → RunsIndexPage-CULSlv_d.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-CyRs0fXN.js → ScheduledPage-CDjkZuqM.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-BRuMCHSj.js → SettingsPage-BMSomotV.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BIAQN7iD.js → StatusDot-XxLkmRaK.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-CxiQ7luL.js → Tooltip-qB73RL4b.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-BQB5mAAl.js → UnitRoutePage-DFRWAdOT.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-I2OEmPqz.js → dist-BsLBAkQJ.js} +1 -1
- package/dist/assets/web/dist/assets/{main-DYwKyLY5.js → main-9ynpo780.js} +10 -10
- package/dist/assets/web/dist/assets/main-DSiJ9sHU.css +1 -0
- package/dist/cli.js +1178 -431
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/CostsPage-RRggVvIx.js +0 -1
- package/dist/assets/web/dist/assets/ResidentDetailPage-CT6v9INM.js +0 -1
- package/dist/assets/web/dist/assets/ResidentsIndexPage-CWqPFf40.js +0 -1
- package/dist/assets/web/dist/assets/main-DPB0UO1-.css +0 -1
|
@@ -95,6 +95,7 @@ import {
|
|
|
95
95
|
SEED_MARKER,
|
|
96
96
|
SEED_RESTORE_MAX_MS,
|
|
97
97
|
SEED_ABANDONED_RESTORE_WAIT_MS,
|
|
98
|
+
seedDoorRemote,
|
|
98
99
|
seedFixupScript,
|
|
99
100
|
seedMarkerText,
|
|
100
101
|
type RestorePhases,
|
|
@@ -451,12 +452,25 @@ export class SwitchboardSandbox extends Sandbox<Env> {
|
|
|
451
452
|
detail: `presigned R2 transfer needs ${transfer.missing.join(", ")}`,
|
|
452
453
|
};
|
|
453
454
|
}
|
|
455
|
+
let doorRemote: string;
|
|
456
|
+
try {
|
|
457
|
+
doorRemote = seedDoorRemote(envVars.GIT_DOOR_ORIGIN ?? "", seed.slug);
|
|
458
|
+
} catch {
|
|
459
|
+
return { seeded: false, reason: "seed-failed", detail: "Git door origin unavailable", step: "fixup" };
|
|
460
|
+
}
|
|
454
461
|
// The marker names the seed this container carries — the handle and the
|
|
455
462
|
// ref and head checked out: the same seed again is the run's second
|
|
456
463
|
// request (a retry, a re-attach), and a restore over the live tree would
|
|
457
464
|
// destroy the run's work; a seed naming another ref is a new seed.
|
|
458
465
|
const marker = await this.runRoot(["cat", SEED_MARKER], 30_000);
|
|
459
466
|
if (marker.exitCode === 0 && marker.stdout.trim() === seedMarkerText(seed)) {
|
|
467
|
+
const origin = await this.runRoot(
|
|
468
|
+
["git", "-C", SEED_CHECKOUT_DIR, "remote", "set-url", "origin", doorRemote],
|
|
469
|
+
30_000,
|
|
470
|
+
envVars,
|
|
471
|
+
);
|
|
472
|
+
if (origin.exitCode !== 0)
|
|
473
|
+
return { seeded: false, reason: "seed-failed", detail: "seed origin refresh failed", step: "fixup" };
|
|
460
474
|
const head = await this.runRoot(["git", "-C", SEED_CHECKOUT_DIR, "rev-parse", "HEAD"], 30_000);
|
|
461
475
|
return {
|
|
462
476
|
seeded: true,
|
|
@@ -490,14 +504,15 @@ export class SwitchboardSandbox extends Sandbox<Env> {
|
|
|
490
504
|
t = systemClock();
|
|
491
505
|
const script = seedFixupScript({
|
|
492
506
|
slug: seed.slug,
|
|
507
|
+
doorOrigin: envVars.GIT_DOOR_ORIGIN ?? "",
|
|
493
508
|
ref: seed.ref,
|
|
494
509
|
...(seed.fetchRef ? { fetchRef: seed.fetchRef } : {}),
|
|
495
510
|
...(seed.fetchSha ? { fetchSha: seed.fetchSha } : {}),
|
|
496
511
|
checkoutDir: SEED_CHECKOUT_DIR,
|
|
497
512
|
...(seed.depsBackupId ? { depsDir: SEED_DEPS_STAGING_DIR } : {}),
|
|
498
513
|
});
|
|
499
|
-
// The fix-up's fetch authenticates through the
|
|
500
|
-
//
|
|
514
|
+
// The fix-up's fetch authenticates through the door-scoped helper using
|
|
515
|
+
// the run bearer in the command environment.
|
|
501
516
|
const fix = await this.runRoot(["bash", "-c", script], SEED_FIXUP_TIMEOUT_MS, envVars);
|
|
502
517
|
if (fix.exitCode !== 0) throw new Error(`fix-up exited ${fix.exitCode}: ${tail(fix.stderr || fix.stdout)}`);
|
|
503
518
|
steps.fixup = systemClock() - t;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.268.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.268.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -21023,7 +21023,7 @@
|
|
|
21023
21023
|
},
|
|
21024
21024
|
"packages/switchboard": {
|
|
21025
21025
|
"name": "@coreplane/switchboard",
|
|
21026
|
-
"version": "1.
|
|
21026
|
+
"version": "1.268.0",
|
|
21027
21027
|
"license": "Apache-2.0",
|
|
21028
21028
|
"dependencies": {
|
|
21029
21029
|
"@earendil-works/pi-ai": "0.85.1",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.268.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
6
6
|
"license": "Apache-2.0",
|
package/dist/assets/source.json
CHANGED
|
@@ -306,7 +306,7 @@ SCOPE FIRST — a hard rule, at most 5 tool calls: identify the target repositor
|
|
|
306
306
|
- Use \`gh search code\` / \`gh api\` for cross-repo lookups; clone at most ONE repo per task.
|
|
307
307
|
|
|
308
308
|
Workflow for shipping a PR:
|
|
309
|
-
1. Clone the repo into the workspace if it
|
|
309
|
+
1. Clone the repo into the workspace if it is not already there: \`gh repo clone <owner/name>\` or \`git clone "$GIT_DOOR_REMOTE"\` for the bound repository. A private \`github.com\` clone URL cannot use the run bearer. Orient with a few BATCHED commands (tree + the relevant files in one call), not file-by-file exploration.
|
|
310
310
|
2. Create a branch with a descriptive name.
|
|
311
311
|
3. Implement the change. Match the surrounding code's style and conventions.
|
|
312
312
|
4. Prove the change with the cheapest checks that can (CHECKS BY COST below): the linter and the tests nearest the files you touched, the documentation checks for a documentation change.
|
|
@@ -628,7 +628,7 @@ You cannot run commands, clone repositories, edit code, or review pull requests,
|
|
|
628
628
|
// preset, not a directive.
|
|
629
629
|
const EXPLORE_SYSTEM = `You are Switchboard's explore agent: a long, read-only investigation of a repository, answering a request from Slack.
|
|
630
630
|
|
|
631
|
-
You work in a fresh sandbox with a shell (bash), read_file, and a read-scoped GitHub credential:
|
|
631
|
+
You work in a fresh sandbox with a shell (bash), read_file, and a read-scoped GitHub credential: clone the bound repository first with \`gh repo clone <owner/name>\` or \`git clone "$GIT_DOOR_REMOTE"\`, then check out the ref the request names. A private \`github.com\` clone URL cannot use the run bearer. Install what you need and run whatever the investigation calls for — builds, test suites, benchmarks, \`act\`. ${SANDBOX_TOOLCHAIN} You cannot push. Your other tools: \`web_search\` and \`web_fetch\` (sources and pages), the GitHub reads — \`github_repos\`, \`github_tree\` / \`github_file\` (browse and read our repos at any ref), \`github_search_code\`, \`github_issue_list\` / \`github_issue_get\` — and \`list_skills\` / \`use_skill\`.
|
|
632
632
|
|
|
633
633
|
THE DELIVERABLE IS A CLAIM TABLE. Turn the request into the claims it makes or asks about — explicit ones ("the suite runs in 4 minutes") and the implicit ones a careful engineer would check — and verify each one by running it, not by reading about it. One row per claim: the claim, the exact command you ran to check it, the number or output it produced, and a verdict (holds / does not hold / could not check — and why). Numbers over adjectives: measure a duration, count the failures, quote the version. Say what you did not get to.
|
|
634
634
|
|
|
@@ -21,6 +21,8 @@ export const SECOND_MS = 1_000;
|
|
|
21
21
|
export const secondsToMs = (seconds: number): number => seconds * SECOND_MS;
|
|
22
22
|
|
|
23
23
|
export const MINUTE_MS = 60_000;
|
|
24
|
+
/** Allow the run's ending to release its resident workspace after its budget ends. */
|
|
25
|
+
export const RUN_REGISTRATION_GRACE_MS = MINUTE_MS;
|
|
24
26
|
|
|
25
27
|
/** Minutes → milliseconds, for a duration a request names in minutes (a drain's
|
|
26
28
|
* length): the multiplication lives here so no other file holds it. */
|
|
@@ -253,6 +255,10 @@ export const PROVIDER_RETRY_BACKOFFS_MS = [5_000, 15_000, 45_000] as const;
|
|
|
253
255
|
* model call itself remains bounded by the run's lease. */
|
|
254
256
|
export const MODEL_STREAM_HEARTBEAT_MS = 15_000;
|
|
255
257
|
|
|
258
|
+
/** A residents page re-reads the registry while open so a deploy's drain and
|
|
259
|
+
* image reports advance even when no run can attach to trigger its feed. */
|
|
260
|
+
export const RESIDENT_LISTING_POLL_MS = 30_000;
|
|
261
|
+
|
|
256
262
|
/** A hosted ship parent's deadline margin past the pipeline's wall clock
|
|
257
263
|
* (record 0060): the row's `state.hosting.until` is the hand-off time plus
|
|
258
264
|
* the instance's `caps.maxMinutes` plus this hour, absorbing the runner's own
|
|
@@ -307,12 +307,13 @@ export interface ExistingPrPublicationBinding {
|
|
|
307
307
|
owner: { instanceId: string; unit: string };
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
/** What a coordinator's spawn stamps on the child's every row:
|
|
311
|
-
*
|
|
312
|
-
* own post-step, the base its pull request targets. */
|
|
310
|
+
/** What a coordinator's spawn stamps on the child's every row: its instance,
|
|
311
|
+
* spawn key and admitted cost cap, plus the base for its PR post-step. */
|
|
313
312
|
export interface CoordinatorTag {
|
|
314
313
|
parentInstanceId: string;
|
|
315
314
|
idempotencyKey: string;
|
|
315
|
+
/** The unit's original dollar limit, fixed at admission; absent means no cost cap. */
|
|
316
|
+
costCapUsd?: number;
|
|
316
317
|
/** The unit's durable write branch, read from its row at child admission. */
|
|
317
318
|
branch?: string;
|
|
318
319
|
/** Workflow transport for a recovered child. Identity and idempotency remain
|
|
@@ -346,15 +347,20 @@ export interface CoordinatorTag {
|
|
|
346
347
|
publication?: ExistingPrPublicationBinding;
|
|
347
348
|
}
|
|
348
349
|
|
|
349
|
-
/** The tag
|
|
350
|
-
*
|
|
351
|
-
* key. The base never rides here: rows and records keep the shape written
|
|
352
|
-
* before it existed. */
|
|
350
|
+
/** The tag's identity and optional cap as flat record fields, or nothing — so
|
|
351
|
+
* a row, summary and record agree. The base never rides here. */
|
|
353
352
|
export function coordinatorFields(tag: CoordinatorTag | undefined): {
|
|
354
353
|
parentInstanceId?: string;
|
|
355
354
|
idempotencyKey?: string;
|
|
355
|
+
costCapUsd?: number;
|
|
356
356
|
} {
|
|
357
|
-
return tag
|
|
357
|
+
return tag
|
|
358
|
+
? {
|
|
359
|
+
parentInstanceId: tag.parentInstanceId,
|
|
360
|
+
idempotencyKey: tag.idempotencyKey,
|
|
361
|
+
...(tag.costCapUsd !== undefined ? { costCapUsd: tag.costCapUsd } : {}),
|
|
362
|
+
}
|
|
363
|
+
: {};
|
|
358
364
|
}
|
|
359
365
|
|
|
360
366
|
/** The parent ship record: what the bot writes at an instance's creation and
|
|
@@ -516,6 +516,8 @@ export interface CostReport {
|
|
|
516
516
|
/** Each biller's daily invoice tie-out (item 4d); absent when no provider block is known
|
|
517
517
|
* (a report built without the wiring) or run history is off. */
|
|
518
518
|
billers?: BillerTieOut[];
|
|
519
|
+
/** Ship children across this bot's runs, independent of the Cloudflare group. */
|
|
520
|
+
shipSpend?: import("./shipSpend.js").ShipSpendReport;
|
|
519
521
|
}
|
|
520
522
|
|
|
521
523
|
/** What the report carries beyond the priced rows: the account behind the
|
|
@@ -974,6 +974,7 @@ export type RunEvent =
|
|
|
974
974
|
type: "coordinator_tag";
|
|
975
975
|
parentInstanceId: string;
|
|
976
976
|
unit?: string;
|
|
977
|
+
costCapUsd?: number;
|
|
977
978
|
branch?: string;
|
|
978
979
|
transportWorkflowId?: string;
|
|
979
980
|
recovery?: import("./coordinator/contract.js").CoordinatorTag["recovery"];
|
|
@@ -1142,7 +1143,15 @@ export type RunEvent =
|
|
|
1142
1143
|
* `model` is the ref the run uses, resolved from a model the person
|
|
1143
1144
|
* named in plain words (the plain-words model unit) — applied at
|
|
1144
1145
|
* directive precedence. Additive: unknown → no model. */
|
|
1145
|
-
binds?: ReadonlyArray<{
|
|
1146
|
+
binds?: ReadonlyArray<{
|
|
1147
|
+
line: string;
|
|
1148
|
+
reason: string;
|
|
1149
|
+
model?: string;
|
|
1150
|
+
repo?: string;
|
|
1151
|
+
/** The admissible fact behind the model's typed repo choice. */
|
|
1152
|
+
repoSource?: "request" | "attachment" | "thread" | "channel";
|
|
1153
|
+
confirmed?: true;
|
|
1154
|
+
}>;
|
|
1146
1155
|
question?: string;
|
|
1147
1156
|
/** A question's proposed line, redacted and cut like the receipt — what
|
|
1148
1157
|
* the next turn's "yes" binds (`bindFromAnswer`). */
|
|
@@ -1160,6 +1169,14 @@ export type RunEvent =
|
|
|
1160
1169
|
* boundary. Its refusal text is always the cause-owned renderer. */
|
|
1161
1170
|
providerFailure?: ProviderFailureCause;
|
|
1162
1171
|
attempts?: ReadonlyArray<{ outcome: "accepted" | "violation"; violation?: string }>;
|
|
1172
|
+
/** Snapshot of the factual repository context the operator received. */
|
|
1173
|
+
repoContext?: {
|
|
1174
|
+
organization: string;
|
|
1175
|
+
threadRepo?: string;
|
|
1176
|
+
channelRepo?: string;
|
|
1177
|
+
candidateStatus: "skipped" | "available" | "truncated" | "empty" | "unavailable";
|
|
1178
|
+
candidateCount: number;
|
|
1179
|
+
};
|
|
1163
1180
|
intake?: { verdict: string; reason: string };
|
|
1164
1181
|
latencyMs?: number;
|
|
1165
1182
|
outputTokens?: number;
|
|
@@ -99,6 +99,8 @@ export interface LiveRunMeta {
|
|
|
99
99
|
* still sends the parent its event and a retried spawn finds its run. */
|
|
100
100
|
parentInstanceId?: string;
|
|
101
101
|
idempotencyKey?: string;
|
|
102
|
+
/** The original Ship unit cap carried by the claim, available before its tag event is retained. */
|
|
103
|
+
costCapUsd?: number;
|
|
102
104
|
/** Where the run's conversation started (item 52), so a reclaimed run's
|
|
103
105
|
* record still says so: `parent` for a spawned child, `channel` otherwise. */
|
|
104
106
|
seed?: RunSeed;
|
|
@@ -227,6 +227,8 @@ export interface RunRecord {
|
|
|
227
227
|
* item 48), stored at the claim so a retried spawn finds its run. Present
|
|
228
228
|
* exactly when `parentInstanceId` is — both or neither, never one alone. */
|
|
229
229
|
idempotencyKey?: string;
|
|
230
|
+
/** The coordinator unit's original cost cap, carried by each child. */
|
|
231
|
+
costCapUsd?: number;
|
|
230
232
|
/** The plan runner instance this run's hand-off created (record 0051 R2;
|
|
231
233
|
* item 2): the last `ship_handoff` event, folded at the assembly like the
|
|
232
234
|
* coordinator tag. Present only on a ship run whose hand-off succeeded;
|
|
@@ -484,7 +486,14 @@ export interface RunOperatorDecision {
|
|
|
484
486
|
mode: "shadow" | "on";
|
|
485
487
|
outcome: "binds" | "question" | "refusal" | "non_decision";
|
|
486
488
|
reason: string;
|
|
487
|
-
binds?: {
|
|
489
|
+
binds?: {
|
|
490
|
+
line: string;
|
|
491
|
+
reason: string;
|
|
492
|
+
model?: string;
|
|
493
|
+
repo?: string;
|
|
494
|
+
repoSource?: "request" | "attachment" | "thread" | "channel";
|
|
495
|
+
confirmed?: true;
|
|
496
|
+
}[];
|
|
488
497
|
question?: string;
|
|
489
498
|
/** A question's proposed line — what the next turn's "yes" binds. */
|
|
490
499
|
proposal?: string;
|
|
@@ -497,6 +506,13 @@ export interface RunOperatorDecision {
|
|
|
497
506
|
/** The structured seam's attempts (record 0067): what each answer violated,
|
|
498
507
|
* or that it was accepted. */
|
|
499
508
|
attempts?: { outcome: "accepted" | "violation"; violation?: string }[];
|
|
509
|
+
repoContext?: {
|
|
510
|
+
organization: string;
|
|
511
|
+
threadRepo?: string;
|
|
512
|
+
channelRepo?: string;
|
|
513
|
+
candidateStatus: "skipped" | "available" | "truncated" | "empty" | "unavailable";
|
|
514
|
+
candidateCount: number;
|
|
515
|
+
};
|
|
500
516
|
intake?: { verdict: string; reason: string };
|
|
501
517
|
latencyMs?: number;
|
|
502
518
|
outputTokens?: number;
|
|
@@ -520,6 +536,7 @@ export function operatorOfEvents(events: readonly RunEvent[]): RunOperatorDecisi
|
|
|
520
536
|
reason: b.reason,
|
|
521
537
|
...(b.model !== undefined ? { model: b.model } : {}),
|
|
522
538
|
...(b.repo !== undefined ? { repo: b.repo } : {}),
|
|
539
|
+
...(b.repoSource !== undefined ? { repoSource: b.repoSource } : {}),
|
|
523
540
|
...(b.confirmed ? { confirmed: true as const } : {}),
|
|
524
541
|
})),
|
|
525
542
|
}
|
|
@@ -538,6 +555,7 @@ export function operatorOfEvents(events: readonly RunEvent[]): RunOperatorDecisi
|
|
|
538
555
|
})),
|
|
539
556
|
}
|
|
540
557
|
: {}),
|
|
558
|
+
...(e.repoContext ? { repoContext: { ...e.repoContext } } : {}),
|
|
541
559
|
...(e.intake ? { intake: { verdict: e.intake.verdict, reason: e.intake.reason } } : {}),
|
|
542
560
|
...(e.latencyMs !== undefined ? { latencyMs: e.latencyMs } : {}),
|
|
543
561
|
...(e.outputTokens !== undefined ? { outputTokens: e.outputTokens } : {}),
|
|
@@ -1179,6 +1197,14 @@ export function isRunRecord(v: unknown): v is RunRecord {
|
|
|
1179
1197
|
(typeof r.idempotencyKey !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(r.idempotencyKey))
|
|
1180
1198
|
)
|
|
1181
1199
|
return false;
|
|
1200
|
+
if (
|
|
1201
|
+
r.costCapUsd !== undefined &&
|
|
1202
|
+
(r.parentInstanceId === undefined ||
|
|
1203
|
+
typeof r.costCapUsd !== "number" ||
|
|
1204
|
+
!Number.isFinite(r.costCapUsd) ||
|
|
1205
|
+
r.costCapUsd <= 0)
|
|
1206
|
+
)
|
|
1207
|
+
return false;
|
|
1182
1208
|
// The instance a ship run's hand-off created (record 0051 R2; item 2).
|
|
1183
1209
|
if (r.instanceId !== undefined && (typeof r.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(r.instanceId)))
|
|
1184
1210
|
return false;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RunEvent } from "./runEvents.js";
|
|
2
|
+
import { unitOfIdempotencyKey } from "./coordinator/contract.js";
|
|
2
3
|
|
|
3
4
|
// What a run cost in tokens, and who it belongs to — the data behind the cost
|
|
4
5
|
// dimensions of the costs page (docs/reference/specs/costs.md items 10–10a) and
|
|
@@ -200,6 +201,9 @@ export interface UsageRun {
|
|
|
200
201
|
userName?: string;
|
|
201
202
|
/** A child run is billed to whoever started its parent (run-history item 46). */
|
|
202
203
|
parentRunId?: string;
|
|
204
|
+
parentInstanceId?: string;
|
|
205
|
+
idempotencyKey?: string;
|
|
206
|
+
costCapUsd?: number;
|
|
203
207
|
/** The thread the run ran in and the channel it belongs to (platform-namespaced, invariant 4). */
|
|
204
208
|
threadKey: string;
|
|
205
209
|
channelId: string;
|
|
@@ -258,6 +262,8 @@ export interface UsageRow {
|
|
|
258
262
|
|
|
259
263
|
export interface RunUsageReport {
|
|
260
264
|
rows: UsageRow[];
|
|
265
|
+
/** Individual Ship children retained for the unit-spend trend; old snapshots omit this. */
|
|
266
|
+
shipRuns?: ShipRunUsage[];
|
|
261
267
|
/** Runs in range whose usage is not known yet (written before the field; backfill outstanding). */
|
|
262
268
|
pending: number;
|
|
263
269
|
/** The oldest finish the store still holds, so a page can bound its range to the data. */
|
|
@@ -265,6 +271,33 @@ export interface RunUsageReport {
|
|
|
265
271
|
retentionDays: number;
|
|
266
272
|
}
|
|
267
273
|
|
|
274
|
+
export interface ShipRunUsage {
|
|
275
|
+
id: string;
|
|
276
|
+
unitKey: string;
|
|
277
|
+
finishedAt: number;
|
|
278
|
+
costCapUsd?: number;
|
|
279
|
+
/** Missing while an older record is still being backfilled. */
|
|
280
|
+
usage?: RunUsage;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The unit identity comes from the coordinator's durable key, never a thread or branch guess. */
|
|
284
|
+
export function shipRunsOf(runs: readonly UsageRun[]): ShipRunUsage[] {
|
|
285
|
+
return runs.flatMap((run) => {
|
|
286
|
+
if (!run.parentInstanceId || !run.idempotencyKey) return [];
|
|
287
|
+
const unit = unitOfIdempotencyKey(run.idempotencyKey);
|
|
288
|
+
if (!unit) return [];
|
|
289
|
+
return [
|
|
290
|
+
{
|
|
291
|
+
id: run.id,
|
|
292
|
+
unitKey: `${run.parentInstanceId}:${unit}`,
|
|
293
|
+
finishedAt: run.finishedAt,
|
|
294
|
+
...(run.costCapUsd !== undefined ? { costCapUsd: run.costCapUsd } : {}),
|
|
295
|
+
...(run.usage !== undefined ? { usage: run.usage } : {}),
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
268
301
|
export const dayOf = (epochMs: number): string => new Date(epochMs).toISOString().slice(0, 10);
|
|
269
302
|
|
|
270
303
|
const identityOf = (who: UsageIdentity): UsageIdentity => ({
|
|
@@ -334,6 +367,7 @@ export function reportOfUsageRows(rows: RunUsageRows): RunUsageReport {
|
|
|
334
367
|
const { rows: cells, pending } = aggregateUsage(rows.runs, (id) => rows.parents[id]);
|
|
335
368
|
return {
|
|
336
369
|
rows: cells,
|
|
370
|
+
shipRuns: shipRunsOf(rows.runs),
|
|
337
371
|
// The store counts what it could not price; the fold sees the same runs without `usage`.
|
|
338
372
|
pending: Math.max(pending, rows.pending),
|
|
339
373
|
...(rows.earliestFinishedAt !== undefined ? { earliestFinishedAt: rows.earliestFinishedAt } : {}),
|
|
@@ -356,6 +390,10 @@ function isUsageRun(v: unknown): v is UsageRun {
|
|
|
356
390
|
typeof r.channelId === "string" &&
|
|
357
391
|
(r.agent === undefined || typeof r.agent === "string") &&
|
|
358
392
|
(r.parentRunId === undefined || typeof r.parentRunId === "string") &&
|
|
393
|
+
(r.parentInstanceId === undefined || typeof r.parentInstanceId === "string") &&
|
|
394
|
+
(r.idempotencyKey === undefined || typeof r.idempotencyKey === "string") &&
|
|
395
|
+
(r.costCapUsd === undefined ||
|
|
396
|
+
(typeof r.costCapUsd === "number" && Number.isFinite(r.costCapUsd) && r.costCapUsd > 0)) &&
|
|
359
397
|
typeof r.startedAt === "number" &&
|
|
360
398
|
typeof r.finishedAt === "number" &&
|
|
361
399
|
(r.usage === undefined || isRunUsage(r.usage))
|
|
@@ -382,6 +420,7 @@ export function isRunUsageReport(v: unknown): v is RunUsageReport {
|
|
|
382
420
|
if (typeof v !== "object" || v === null) return false;
|
|
383
421
|
const r = v as Record<string, unknown>;
|
|
384
422
|
if (!Array.isArray(r.rows) || !hasReportTail(r)) return false;
|
|
423
|
+
if (r.shipRuns !== undefined && (!Array.isArray(r.shipRuns) || !r.shipRuns.every(isShipRunUsage))) return false;
|
|
385
424
|
return r.rows.every(
|
|
386
425
|
(row) =>
|
|
387
426
|
isIdentity(row) &&
|
|
@@ -394,3 +433,17 @@ export function isRunUsageReport(v: unknown): v is RunUsageReport {
|
|
|
394
433
|
isRunUsage((row as UsageRow).usage),
|
|
395
434
|
);
|
|
396
435
|
}
|
|
436
|
+
|
|
437
|
+
function isShipRunUsage(v: unknown): v is ShipRunUsage {
|
|
438
|
+
if (typeof v !== "object" || v === null) return false;
|
|
439
|
+
const r = v as ShipRunUsage;
|
|
440
|
+
return (
|
|
441
|
+
typeof r.id === "string" &&
|
|
442
|
+
typeof r.unitKey === "string" &&
|
|
443
|
+
typeof r.finishedAt === "number" &&
|
|
444
|
+
Number.isFinite(r.finishedAt) &&
|
|
445
|
+
(r.costCapUsd === undefined ||
|
|
446
|
+
(typeof r.costCapUsd === "number" && Number.isFinite(r.costCapUsd) && r.costCapUsd > 0)) &&
|
|
447
|
+
(r.usage === undefined || isRunUsage(r.usage))
|
|
448
|
+
);
|
|
449
|
+
}
|
|
@@ -3886,7 +3886,10 @@ function renderUnitReportWithWake(
|
|
|
3886
3886
|
const spentCapAction = (): string | undefined => {
|
|
3887
3887
|
const cap = s.input.grant?.costCapUsd;
|
|
3888
3888
|
if (cap === undefined || s.spendUsd === null || s.spendUsd < cap) return undefined;
|
|
3889
|
-
|
|
3889
|
+
const next = prUrl
|
|
3890
|
+
? `If the pull request (${prUrl}) is still open, continue it only under separate authorization, then request review of its exact head. If it has merged, no recovery is needed.`
|
|
3891
|
+
: "If work remains on the saved branch, continue it only under separate authorization, then request review of its exact head.";
|
|
3892
|
+
return `Next action: $${s.spendUsd.toFixed(2)} spent against the original $${cap.toFixed(2)} cost cap; this unit cannot start another child. ${next}`;
|
|
3890
3893
|
};
|
|
3891
3894
|
const nextAction = (checkpoint?: { branch: string; sha: string }): string => {
|
|
3892
3895
|
if (durableWake)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DateRange } from "./costs.js";
|
|
2
|
+
import type { RunUsage, ShipRunUsage } from "./runUsage.js";
|
|
3
|
+
|
|
4
|
+
/** Ship child costs within one snapshot, grouped by their durable unit key. */
|
|
5
|
+
export interface ShipSpendReport {
|
|
6
|
+
range: DateRange;
|
|
7
|
+
units: Array<{
|
|
8
|
+
key: string;
|
|
9
|
+
/** The admitted unit cap; null on older runs or inconsistent records. */
|
|
10
|
+
capUsd: number | null;
|
|
11
|
+
/** Unknown after any child with unpriced usage. */
|
|
12
|
+
totalUsd: number | null;
|
|
13
|
+
runs: Array<{ id: string; finishedAt: number; usd: number | null; cumulativeUsd: number | null }>;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The cap gate sums recorded per-model dollars; missing dollars stay unknown, while no model turns cost zero. */
|
|
18
|
+
function recordedUsd(usage: RunUsage | undefined): number | null {
|
|
19
|
+
const models = Object.values(usage?.byModel ?? {});
|
|
20
|
+
if (!usage || !Number.isSafeInteger(usage.turns) || usage.turns < 0) return null;
|
|
21
|
+
if (usage.turns === 0 && models.length === 0) return 0;
|
|
22
|
+
if (
|
|
23
|
+
models.length === 0 ||
|
|
24
|
+
models.reduce((sum, model) => sum + model.turns, 0) !== usage.turns ||
|
|
25
|
+
models.some((model) => typeof model.usd !== "number" || !Number.isFinite(model.usd) || model.usd < 0)
|
|
26
|
+
)
|
|
27
|
+
return null;
|
|
28
|
+
return models.reduce((sum, model) => sum + model.usd!, 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildShipSpendReport(runs: readonly ShipRunUsage[], range: DateRange): ShipSpendReport {
|
|
32
|
+
const groups = new Map<string, ShipRunUsage[]>();
|
|
33
|
+
for (const run of runs) {
|
|
34
|
+
const day = new Date(run.finishedAt).toISOString().slice(0, 10);
|
|
35
|
+
if (day < range.from || day > range.to) continue;
|
|
36
|
+
groups.set(run.unitKey, [...(groups.get(run.unitKey) ?? []), run]);
|
|
37
|
+
}
|
|
38
|
+
const units = [...groups].map(([key, children]) => {
|
|
39
|
+
const ordered = children.sort((a, b) => a.finishedAt - b.finishedAt || a.id.localeCompare(b.id));
|
|
40
|
+
const caps = new Set(ordered.map((r) => r.costCapUsd));
|
|
41
|
+
const capUsd = caps.size === 1 ? (ordered[0]?.costCapUsd ?? null) : null;
|
|
42
|
+
let cumulative: number | null = 0;
|
|
43
|
+
const points = ordered.map((r) => {
|
|
44
|
+
const usd = recordedUsd(r.usage);
|
|
45
|
+
cumulative = cumulative === null || usd === null ? null : cumulative + usd;
|
|
46
|
+
return { id: r.id, finishedAt: r.finishedAt, usd, cumulativeUsd: cumulative };
|
|
47
|
+
});
|
|
48
|
+
return { key, capUsd, totalUsd: cumulative, runs: points };
|
|
49
|
+
});
|
|
50
|
+
units.sort((a, b) => (b.runs.at(-1)?.finishedAt ?? 0) - (a.runs.at(-1)?.finishedAt ?? 0));
|
|
51
|
+
return { range, units };
|
|
52
|
+
}
|
|
@@ -45,6 +45,16 @@ export interface AttrDomain {
|
|
|
45
45
|
refusal: string;
|
|
46
46
|
/** The refusal's cause, from the one code→cause table in src/core/refusal.ts. */
|
|
47
47
|
cause: "request" | "policy" | "system";
|
|
48
|
+
/** The operator's decision, separate from the request's final outcome. */
|
|
49
|
+
operatorOutcome: "binds" | "question" | "refusal" | "non_decision";
|
|
50
|
+
/** Provenance of an accepted typed repository; the slug stays on the run event. */
|
|
51
|
+
operatorRepoSource: "request" | "attachment" | "thread" | "channel";
|
|
52
|
+
operatorAttempts: number;
|
|
53
|
+
operatorRepoCatalog: "skipped" | "available" | "truncated" | "empty" | "unavailable";
|
|
54
|
+
operatorRepoCandidates: number;
|
|
55
|
+
/** Final target resolution, separate from the operator's proposed repo. */
|
|
56
|
+
repoResolution: "skipped" | "resolved" | "rejected" | "unverified" | "conflict" | "unresolved";
|
|
57
|
+
repoTargetRelation: "not_proposed" | "matched" | "overridden" | "unresolved";
|
|
48
58
|
count: number;
|
|
49
59
|
backend: Backend;
|
|
50
60
|
// run.command
|
|
@@ -146,6 +156,11 @@ const IDENTIFIER_KEYS: ReadonlySet<SpanAttrKey> = new Set<SpanAttrKey>([
|
|
|
146
156
|
"outcome",
|
|
147
157
|
"refusal",
|
|
148
158
|
"cause",
|
|
159
|
+
"operatorOutcome",
|
|
160
|
+
"operatorRepoSource",
|
|
161
|
+
"operatorRepoCatalog",
|
|
162
|
+
"repoResolution",
|
|
163
|
+
"repoTargetRelation",
|
|
149
164
|
"command",
|
|
150
165
|
"route",
|
|
151
166
|
"host",
|
|
@@ -204,6 +219,13 @@ const ATTR_TYPE: Record<SpanAttrKey, "string" | "number" | "boolean"> = {
|
|
|
204
219
|
outcome: "string",
|
|
205
220
|
refusal: "string",
|
|
206
221
|
cause: "string",
|
|
222
|
+
operatorOutcome: "string",
|
|
223
|
+
operatorRepoSource: "string",
|
|
224
|
+
operatorAttempts: "number",
|
|
225
|
+
operatorRepoCatalog: "string",
|
|
226
|
+
operatorRepoCandidates: "number",
|
|
227
|
+
repoResolution: "string",
|
|
228
|
+
repoTargetRelation: "string",
|
|
207
229
|
count: "number",
|
|
208
230
|
backend: "string",
|
|
209
231
|
command: "string",
|
|
@@ -99,6 +99,7 @@ interface SpanInit {
|
|
|
99
99
|
sinks: SpanSink[];
|
|
100
100
|
startedAt: number | undefined;
|
|
101
101
|
attrs: SpanAttrs | undefined;
|
|
102
|
+
rootIdentity?: { name: string; runId?: string };
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
class SpanImpl implements Span {
|
|
@@ -109,6 +110,8 @@ class SpanImpl implements Span {
|
|
|
109
110
|
ended = false;
|
|
110
111
|
private readonly sinks: SpanSink[];
|
|
111
112
|
private readonly rec: SpanRecord;
|
|
113
|
+
private readonly rootIdentity: { name: string; runId?: string };
|
|
114
|
+
private readonly isLocalRoot: boolean;
|
|
112
115
|
|
|
113
116
|
constructor(
|
|
114
117
|
private readonly shared: Shared,
|
|
@@ -118,6 +121,8 @@ class SpanImpl implements Span {
|
|
|
118
121
|
this.parentId = init.parentId;
|
|
119
122
|
this.name = sanitizeSpanName(init.name);
|
|
120
123
|
this.sinks = init.sinks;
|
|
124
|
+
this.isLocalRoot = init.rootIdentity === undefined;
|
|
125
|
+
this.rootIdentity = init.rootIdentity ?? { name: this.name, runId: init.attrs?.runId };
|
|
121
126
|
this.rec = {
|
|
122
127
|
traceId: this.traceId,
|
|
123
128
|
spanId: this.id,
|
|
@@ -164,6 +169,7 @@ class SpanImpl implements Span {
|
|
|
164
169
|
sinks: this.sinks,
|
|
165
170
|
startedAt: opts?.startedAt,
|
|
166
171
|
attrs: opts?.attrs,
|
|
172
|
+
rootIdentity: this.rootIdentity,
|
|
167
173
|
});
|
|
168
174
|
child.emitStart();
|
|
169
175
|
return child;
|
|
@@ -177,6 +183,7 @@ class SpanImpl implements Span {
|
|
|
177
183
|
sinks: this.sinks,
|
|
178
184
|
startedAt: opts.startedAt,
|
|
179
185
|
attrs: opts.attrs,
|
|
186
|
+
rootIdentity: this.rootIdentity,
|
|
180
187
|
});
|
|
181
188
|
child.emitStart();
|
|
182
189
|
child.endAt(Math.max(opts.startedAt, opts.endedAt), opts.status ?? "ok", opts.errorKind, opts.errorCode);
|
|
@@ -196,7 +203,7 @@ class SpanImpl implements Span {
|
|
|
196
203
|
}
|
|
197
204
|
for (const s of this.sinks) {
|
|
198
205
|
try {
|
|
199
|
-
s.onEnd(this.record());
|
|
206
|
+
s.onEnd(this.record(), this.rootIdentity);
|
|
200
207
|
} catch (err) {
|
|
201
208
|
this.shared.warn(
|
|
202
209
|
`[trace] sink onEnd threw for ${this.name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -215,7 +222,7 @@ class SpanImpl implements Span {
|
|
|
215
222
|
this.rec.status = status ?? this.rec.status ?? "ok";
|
|
216
223
|
for (const s of this.sinks) {
|
|
217
224
|
try {
|
|
218
|
-
s.onEnd(this.record());
|
|
225
|
+
s.onEnd(this.record(), this.rootIdentity);
|
|
219
226
|
} catch (err) {
|
|
220
227
|
this.shared.warn(
|
|
221
228
|
`[trace] sink onEnd threw for ${this.name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -239,6 +246,7 @@ class SpanImpl implements Span {
|
|
|
239
246
|
|
|
240
247
|
setAttrs(attrs: SpanAttrs): void {
|
|
241
248
|
(this.rec as { attrs: SpanAttrs }).attrs = { ...this.rec.attrs, ...attrs };
|
|
249
|
+
if (this.isLocalRoot) this.rootIdentity.runId = this.rec.attrs.runId;
|
|
242
250
|
}
|
|
243
251
|
|
|
244
252
|
record(): SpanRecord {
|
|
@@ -40,12 +40,23 @@ export interface SpanRecord {
|
|
|
40
40
|
attrs: SpanAttrs;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** A live, read-only view of the local root's run identity. Shared by its
|
|
44
|
+
* descendants, including late children; changing the root's runId updates
|
|
45
|
+
* the view without changing any span record. No span, sink or payload is
|
|
46
|
+
* retained here, so a bounded sink can keep it after the root ends. */
|
|
47
|
+
export interface SpanRootIdentity {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly runId?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
/** Observer of span starts and ends (Observer). Sinks are attached to a root
|
|
44
53
|
* and inherited by its whole subtree; a throwing sink never reaches traced
|
|
45
54
|
* code. */
|
|
46
55
|
export interface SpanSink {
|
|
47
56
|
onStart?(span: SpanRecord): void;
|
|
48
|
-
|
|
57
|
+
/** The tracer supplies root identity separately from the record snapshot;
|
|
58
|
+
* synthetic records supplied directly by a caller may omit it. */
|
|
59
|
+
onEnd(span: SpanRecord, root?: SpanRootIdentity): void;
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
/** The seam the no-gaps test enters through (Strategy): production is the
|
|
@@ -82,9 +82,9 @@ export function refusalFilter(sink: SpanSink): SpanSink {
|
|
|
82
82
|
const refused = (status: unknown) => status === 401 || status === 403;
|
|
83
83
|
return {
|
|
84
84
|
...(sink.onStart ? { onStart: (rec) => sink.onStart!(rec) } : {}),
|
|
85
|
-
onEnd: (rec) => {
|
|
85
|
+
onEnd: (rec, root) => {
|
|
86
86
|
if (refused(rec.attrs.httpStatus)) return;
|
|
87
|
-
sink.onEnd(rec);
|
|
87
|
+
sink.onEnd(rec, root);
|
|
88
88
|
},
|
|
89
89
|
};
|
|
90
90
|
}
|
|
@@ -5,6 +5,15 @@ export interface SpentPoolUser {
|
|
|
5
5
|
owner: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** An owner may reclaim its own UID; only a different owner needs a fresh one. */
|
|
9
|
+
export function unavailablePoolUsers(spent: ReadonlyMap<string, string>, owner: string): Set<string> {
|
|
10
|
+
return new Set([...spent].filter(([, prior]) => prior !== owner).map(([user]) => user));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ownedPoolUsers(spent: ReadonlyMap<string, string>, owner: string): string[] {
|
|
14
|
+
return [...spent].filter(([, prior]) => prior === owner).map(([user]) => user);
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
export function parseSpentPoolUsers(value: unknown, pool: readonly string[]): ReadonlyMap<string, string> | null {
|
|
9
18
|
if (
|
|
10
19
|
!Array.isArray(value) ||
|