@coreplane/switchboard 1.213.0 → 1.215.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/config/config.example.yaml +7 -1
- package/dist/assets/deploy/cloudflare/artifactsCopy.ts +180 -0
- package/dist/assets/deploy/cloudflare/ensure-bucket.mjs +71 -0
- package/dist/assets/deploy/cloudflare/package.json +1 -1
- package/dist/assets/deploy/cloudflare/worker.ts +19 -1
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +15 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +568 -6
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +11 -2
- package/dist/assets/deploy/profile.example.json +1 -1
- 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 +39 -14
- package/dist/assets/src/config/profile.ts +8 -5
- package/dist/assets/src/core/coordinator/contract.ts +6 -0
- package/dist/assets/src/core/runEvents.ts +18 -9
- package/dist/assets/src/core/runFriction.ts +9 -2
- package/dist/assets/src/core/runLedger/sessionLog.ts +207 -0
- package/dist/assets/src/core/runLedger/transcript.ts +187 -0
- package/dist/assets/src/core/runLedger/types.ts +25 -4
- package/dist/assets/src/core/runRecord.ts +62 -1
- package/dist/assets/src/core/ship/coordinator.ts +70 -33
- package/dist/assets/src/core/trace/workerTrace.ts +2 -0
- package/dist/assets/src/deploy/profile.ts +11 -0
- package/dist/assets/src/execution/residentInstanceId.ts +11 -3
- package/dist/assets/src/execution/residentText.ts +17 -2
- package/dist/assets/src/providers/types.ts +6 -0
- package/dist/cli.js +1689 -627
- package/package.json +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// The transcript on the wire (docs/reference/specs/run-history.md item 32). The runner's
|
|
2
|
+
// `messages` array is append-only, so the durable form is one row per content
|
|
3
|
+
// part: no row nears the Durable Object's 2 MB limit, a step writes only its
|
|
4
|
+
// new turns, and the read side assembles the exact array — thinking blocks and
|
|
5
|
+
// all — or names the gap. Base64 attachment data over a threshold is stored
|
|
6
|
+
// once and referenced, so a 20 MB PDF in the seed is one attachment row, not
|
|
7
|
+
// a 20 MB part row.
|
|
8
|
+
|
|
9
|
+
import type { ChatMessage, ContentPart } from "../../providers/types.js";
|
|
10
|
+
import { utf8ByteLength } from "../runRecord.js";
|
|
11
|
+
import {
|
|
12
|
+
ATTACHMENT_REF_BYTES,
|
|
13
|
+
TRANSCRIPT_PART_BYTES,
|
|
14
|
+
type CompactionEntry,
|
|
15
|
+
type TranscriptAttachment,
|
|
16
|
+
type TranscriptRow,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
|
|
19
|
+
// Every budget here is in UTF-8 BYTES, measured with `utf8ByteLength`, because
|
|
20
|
+
// the fences they must stay under are bytes: the Worker's Content-Length check
|
|
21
|
+
// and the Durable Object's row limit. `String.length` counts UTF-16 code units,
|
|
22
|
+
// and JSON.stringify leaves non-ASCII literal, so a 1.4M-character CJK part is
|
|
23
|
+
// ~4.2 MB on the wire.
|
|
24
|
+
|
|
25
|
+
/** The stored form of one part: the turn's role rides on every row so a turn
|
|
26
|
+
* is reconstructible from its rows alone. */
|
|
27
|
+
export interface StoredPart {
|
|
28
|
+
role: ChatMessage["role"];
|
|
29
|
+
part: ContentPart | (ContentPart & { dataRef: string });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The stored form of a compaction row: no role, no part — the entry alone. */
|
|
33
|
+
export interface StoredCompaction {
|
|
34
|
+
compaction: CompactionEntry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type StoredRow = StoredPart | StoredCompaction;
|
|
38
|
+
|
|
39
|
+
const attachmentRef = (idx: number, part: number) => `t${idx}p${part}`;
|
|
40
|
+
|
|
41
|
+
function hasData(part: ContentPart): part is ContentPart & { data: string; mediaType: string } {
|
|
42
|
+
return (part.type === "image" || part.type === "document") && typeof (part as { data?: unknown }).data === "string";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isCompaction = (v: ChatMessage | StoredCompaction): v is StoredCompaction => "compaction" in v;
|
|
46
|
+
|
|
47
|
+
/** One turn → its rows (and any externalized attachments). Refuses a part the
|
|
48
|
+
* row budget cannot hold: a transcript is never truncated. A compaction entry
|
|
49
|
+
* is one row at its index, part 0. */
|
|
50
|
+
export function turnRows(
|
|
51
|
+
idx: number,
|
|
52
|
+
message: ChatMessage | StoredCompaction,
|
|
53
|
+
opts: { partBytes?: number; attachmentRefBytes?: number } = {},
|
|
54
|
+
): { rows: TranscriptRow[]; attachments: TranscriptAttachment[] } {
|
|
55
|
+
const partBytes = opts.partBytes ?? TRANSCRIPT_PART_BYTES;
|
|
56
|
+
const refBytes = opts.attachmentRefBytes ?? ATTACHMENT_REF_BYTES;
|
|
57
|
+
const rows: TranscriptRow[] = [];
|
|
58
|
+
const attachments: TranscriptAttachment[] = [];
|
|
59
|
+
if (isCompaction(message)) {
|
|
60
|
+
const json = JSON.stringify({ compaction: message.compaction } satisfies StoredCompaction);
|
|
61
|
+
const bytes = utf8ByteLength(json);
|
|
62
|
+
if (bytes > partBytes) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`transcript: the compaction row at turn ${idx} is ${bytes} bytes, over the ${partBytes} row budget`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return { rows: [{ idx, part: 0, json }], attachments };
|
|
68
|
+
}
|
|
69
|
+
message.content.forEach((part, i) => {
|
|
70
|
+
let stored: StoredPart["part"] = part;
|
|
71
|
+
if (hasData(part) && part.data.length > refBytes) {
|
|
72
|
+
const ref = attachmentRef(idx, i);
|
|
73
|
+
attachments.push({ ref, mediaType: part.mediaType, data: part.data });
|
|
74
|
+
stored = { ...(part as ContentPart), data: "", dataRef: ref } as StoredPart["part"];
|
|
75
|
+
}
|
|
76
|
+
const json = JSON.stringify({ role: message.role, part: stored } satisfies StoredPart);
|
|
77
|
+
const bytes = utf8ByteLength(json);
|
|
78
|
+
if (bytes > partBytes) {
|
|
79
|
+
throw new Error(`transcript: part ${i} of turn ${idx} is ${bytes} bytes, over the ${partBytes} row budget`);
|
|
80
|
+
}
|
|
81
|
+
rows.push({ idx, part: i, json });
|
|
82
|
+
});
|
|
83
|
+
return { rows, attachments };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Pack rows into requests whose JSON stays under `maxBytes`, in order. A
|
|
87
|
+
* single row larger than the fence travels alone (the row budget already
|
|
88
|
+
* bounds it below the fence in production). */
|
|
89
|
+
export function chunkRows(rows: readonly TranscriptRow[], maxBytes: number): TranscriptRow[][] {
|
|
90
|
+
const chunks: TranscriptRow[][] = [];
|
|
91
|
+
let current: TranscriptRow[] = [];
|
|
92
|
+
let size = 2; // the array brackets
|
|
93
|
+
for (const row of rows) {
|
|
94
|
+
const rowSize = utf8ByteLength(JSON.stringify(row)) + 1;
|
|
95
|
+
if (current.length > 0 && size + rowSize > maxBytes) {
|
|
96
|
+
chunks.push(current);
|
|
97
|
+
current = [];
|
|
98
|
+
size = 2;
|
|
99
|
+
}
|
|
100
|
+
current.push(row);
|
|
101
|
+
size += rowSize;
|
|
102
|
+
}
|
|
103
|
+
if (current.length > 0) chunks.push(current);
|
|
104
|
+
return chunks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A compaction row as the assembled transcript reports it: the entry, the
|
|
108
|
+
* index in `messages` of the first message after it (`before`), and — when
|
|
109
|
+
* the entry names `keptFrom` and that row is in the assembled span — the
|
|
110
|
+
* index in `messages` of the message pi kept first. */
|
|
111
|
+
export interface AssembledCompaction {
|
|
112
|
+
before: number;
|
|
113
|
+
entry: CompactionEntry;
|
|
114
|
+
keptBefore?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type AssembledTranscript =
|
|
118
|
+
| { complete: true; turns: number; messages: ChatMessage[]; compactions: AssembledCompaction[] }
|
|
119
|
+
| { complete: false; turns: number; messages: ChatMessage[]; compactions: AssembledCompaction[]; gap: string };
|
|
120
|
+
|
|
121
|
+
/** Rows (any order) → the turns, contiguous from `base` (a log index; 0 for a
|
|
122
|
+
* run's own object), as a conversation counted from 0. `turns` counts every
|
|
123
|
+
* row index — a compaction row included, since the step records count it —
|
|
124
|
+
* while `messages` carries the conversation alone and `compactions` says
|
|
125
|
+
* where each entry sits in it. Stops at the first gap and says where it is;
|
|
126
|
+
* the turns before it are returned so an `interrupted` record can still carry
|
|
127
|
+
* what was stored. */
|
|
128
|
+
export function assembleTranscript(
|
|
129
|
+
rows: readonly TranscriptRow[],
|
|
130
|
+
attachments: readonly TranscriptAttachment[],
|
|
131
|
+
base = 0,
|
|
132
|
+
): AssembledTranscript {
|
|
133
|
+
const byRef = new Map(attachments.map((a) => [a.ref, a]));
|
|
134
|
+
const byTurn = new Map<number, Map<number, StoredRow>>();
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
const idx = row.idx - base;
|
|
137
|
+
let parts = byTurn.get(idx);
|
|
138
|
+
if (!parts) byTurn.set(idx, (parts = new Map()));
|
|
139
|
+
parts.set(row.part, JSON.parse(row.json) as StoredRow);
|
|
140
|
+
}
|
|
141
|
+
const messages: ChatMessage[] = [];
|
|
142
|
+
const compactions: AssembledCompaction[] = [];
|
|
143
|
+
/** The `messages` index each turn index landed at (a compaction row lands nowhere). */
|
|
144
|
+
const messageIndexOf = new Map<number, number>();
|
|
145
|
+
const gap = (why: string): AssembledTranscript => ({
|
|
146
|
+
complete: false,
|
|
147
|
+
turns: messages.length + compactions.length,
|
|
148
|
+
messages,
|
|
149
|
+
compactions,
|
|
150
|
+
gap: why,
|
|
151
|
+
});
|
|
152
|
+
const turnCount = byTurn.size === 0 ? 0 : Math.max(...byTurn.keys()) + 1;
|
|
153
|
+
for (let idx = 0; idx < turnCount; idx++) {
|
|
154
|
+
const parts = byTurn.get(idx);
|
|
155
|
+
if (!parts) return gap(`turn ${idx} is missing`);
|
|
156
|
+
const first = parts.get(0);
|
|
157
|
+
if (first && "compaction" in first) {
|
|
158
|
+
compactions.push({ before: messages.length, entry: first.compaction });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const content: ContentPart[] = [];
|
|
162
|
+
let role: ChatMessage["role"] | undefined;
|
|
163
|
+
const partCount = Math.max(...parts.keys()) + 1;
|
|
164
|
+
for (let p = 0; p < partCount; p++) {
|
|
165
|
+
const stored = parts.get(p);
|
|
166
|
+
if (!stored || "compaction" in stored) return gap(`turn ${idx} is missing part ${p}`);
|
|
167
|
+
role = stored.role;
|
|
168
|
+
const part = stored.part as ContentPart & { dataRef?: string };
|
|
169
|
+
if (part.dataRef !== undefined) {
|
|
170
|
+
const att = byRef.get(part.dataRef);
|
|
171
|
+
if (!att) return gap(`attachment ${part.dataRef} is missing`);
|
|
172
|
+
const { dataRef: _ref, ...rest } = part;
|
|
173
|
+
content.push({ ...rest, data: att.data } as ContentPart);
|
|
174
|
+
} else {
|
|
175
|
+
content.push(part);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
messageIndexOf.set(idx, messages.length);
|
|
179
|
+
messages.push({ role: role ?? "user", content });
|
|
180
|
+
}
|
|
181
|
+
for (const c of compactions) {
|
|
182
|
+
if (c.entry.keptFrom === undefined) continue;
|
|
183
|
+
const kept = messageIndexOf.get(c.entry.keptFrom - base);
|
|
184
|
+
if (kept !== undefined) c.keptBefore = kept;
|
|
185
|
+
}
|
|
186
|
+
return { complete: true, turns: messages.length + compactions.length, messages, compactions };
|
|
187
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { ChatMessage, ToolDef } from "../../providers/types.js";
|
|
|
7
7
|
import type { ChannelVisibility } from "../authz/types.js";
|
|
8
8
|
import type { RunProfile } from "../../config/profile.js";
|
|
9
9
|
import type { RunEvent } from "../runEvents.js";
|
|
10
|
+
import type { RunSeed, RunSession } from "../runRecord.js";
|
|
10
11
|
|
|
11
12
|
/** How long a generation's claim on a run lasts without a heartbeat. */
|
|
12
13
|
export const LEASE_MS = 30_000;
|
|
@@ -69,6 +70,15 @@ export interface LiveRunMeta {
|
|
|
69
70
|
* still sends the parent its event and a retried spawn finds its run. */
|
|
70
71
|
parentInstanceId?: string;
|
|
71
72
|
idempotencyKey?: string;
|
|
73
|
+
/** Where the run's conversation started (item 52), so a reclaimed run's
|
|
74
|
+
* record still says so: `parent` for a spawned child, `channel` otherwise. */
|
|
75
|
+
seed?: RunSeed;
|
|
76
|
+
/** The run's place in its session's log (docs/reference/specs/session-log.md item
|
|
77
|
+
* 2): set at the claim, so a resume reads the rows from `seedFrom` and
|
|
78
|
+
* continues appending at its indices. Absent on rows claimed before the
|
|
79
|
+
* session log existed — those resume from their own transcript object —
|
|
80
|
+
* and on runs without a conversation of their own. */
|
|
81
|
+
session?: RunSession;
|
|
72
82
|
/** Which executor the run attached: what `makeExecutor` chose. */
|
|
73
83
|
selection?: "resident" | "sandbox" | "local" | "none";
|
|
74
84
|
/** The worktree path the system prompt names. */
|
|
@@ -190,10 +200,21 @@ export interface TranscriptAttachment {
|
|
|
190
200
|
data: string;
|
|
191
201
|
}
|
|
192
202
|
|
|
193
|
-
/**
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
/** pi's compaction entry as the log stores it (docs/reference/specs/session-log.md item
|
|
204
|
+
* 6): the summary pi wrote of everything before the row, the size it replaced,
|
|
205
|
+
* pi's own id for the first entry it kept (forensics: it names nothing in a
|
|
206
|
+
* rebuilt file) and, when the mirror can say, `keptFrom` — the log index of
|
|
207
|
+
* that entry — so a rebuilt session keeps what pi kept. */
|
|
208
|
+
export interface CompactionEntry {
|
|
209
|
+
summary: string;
|
|
210
|
+
tokensBefore?: number;
|
|
211
|
+
firstKeptEntryId?: string;
|
|
212
|
+
keptFrom?: number;
|
|
197
213
|
}
|
|
198
214
|
|
|
215
|
+
/** The rows a step write carries, each at its log index: the previous step's
|
|
216
|
+
* results and this step's assistant turn as messages, and pi's compaction
|
|
217
|
+
* entry as a row of its own between them. */
|
|
218
|
+
export type TranscriptTurn = { idx: number; message: ChatMessage } | { idx: number; compaction: CompactionEntry };
|
|
219
|
+
|
|
199
220
|
export type AppendableEvent = RunEvent & { seq: number };
|
|
@@ -145,6 +145,52 @@ export interface RunRecord {
|
|
|
145
145
|
* item 48), stored at the claim so a retried spawn finds its run. Present
|
|
146
146
|
* exactly when `parentInstanceId` is — both or neither, never one alone. */
|
|
147
147
|
idempotencyKey?: string;
|
|
148
|
+
/** Where the run's conversation started (item 52): `channel` — its own
|
|
149
|
+
* thread's history, as for every run a person, a schedule or a coordinator
|
|
150
|
+
* started — or `parent` — a spawned child seeded from its parent's text
|
|
151
|
+
* turns at the spawn (`DispatchOptions.seed`). Absent on records written
|
|
152
|
+
* before it existed. */
|
|
153
|
+
seed?: RunSeed;
|
|
154
|
+
/** The run as a range of its session's log (item 53; docs/reference/specs/session-log.md):
|
|
155
|
+
* the object's key, the log index its seed began at, its request row and
|
|
156
|
+
* the rows it appended — closed at finish, or `broken` when a refused or
|
|
157
|
+
* failed write detached the run and its later turns never landed. Absent
|
|
158
|
+
* on records written before the session log existed and on runs without
|
|
159
|
+
* a conversation of their own. */
|
|
160
|
+
session?: RunSession;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The two places a run's conversation can start (item 52). */
|
|
164
|
+
export const RUN_SEEDS = ["channel", "parent"] as const;
|
|
165
|
+
export type RunSeed = (typeof RUN_SEEDS)[number];
|
|
166
|
+
|
|
167
|
+
/** A session log's name: `<threadKey>:<agent>` (docs/reference/specs/session-log.md item 1). */
|
|
168
|
+
export const SESSION_KEY_PATTERN = /^[A-Za-z0-9_.:@+/-]{1,512}$/;
|
|
169
|
+
|
|
170
|
+
/** Where a run sits in its session's log (session-log item 2): `seedFrom` is
|
|
171
|
+
* the first log index its seed included, `request` the index of its request
|
|
172
|
+
* row, `range.from` the first row it appended and `range.to` the last, set
|
|
173
|
+
* at finish; `broken` says the run detached from the ledger and the log
|
|
174
|
+
* ends short of what the model saw. */
|
|
175
|
+
export interface RunSession {
|
|
176
|
+
key: string;
|
|
177
|
+
seedFrom: number;
|
|
178
|
+
request: number;
|
|
179
|
+
range: { from: number; to?: number } | "broken";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const isIndex = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
183
|
+
|
|
184
|
+
export function isRunSession(v: unknown): v is RunSession {
|
|
185
|
+
if (typeof v !== "object" || v === null) return false;
|
|
186
|
+
const s = v as Record<string, unknown>;
|
|
187
|
+
if (typeof s.key !== "string" || !SESSION_KEY_PATTERN.test(s.key)) return false;
|
|
188
|
+
if (!isIndex(s.seedFrom) || !isIndex(s.request) || s.request < s.seedFrom) return false;
|
|
189
|
+
if (s.range === "broken") return true;
|
|
190
|
+
if (typeof s.range !== "object" || s.range === null) return false;
|
|
191
|
+
const range = s.range as Record<string, unknown>;
|
|
192
|
+
if (!isIndex(range.from) || range.from < s.seedFrom) return false;
|
|
193
|
+
return range.to === undefined || (isIndex(range.to) && range.to >= range.from);
|
|
148
194
|
}
|
|
149
195
|
|
|
150
196
|
/** The profile as the record stores it: the run's effective profile plus the preset it came from. */
|
|
@@ -383,6 +429,10 @@ export interface RetentionPolicy {
|
|
|
383
429
|
retentionDays: number;
|
|
384
430
|
maxRuns: number;
|
|
385
431
|
maxBytes: number;
|
|
432
|
+
/** The most bytes one session log holds (docs/reference/specs/session-log.md item 5):
|
|
433
|
+
* past it the object replaces its oldest tool results with a marker;
|
|
434
|
+
* `maxBytes` bounds the run records alone. */
|
|
435
|
+
sessionLogMaxBytes: number;
|
|
386
436
|
}
|
|
387
437
|
|
|
388
438
|
const KIB = 1024;
|
|
@@ -393,6 +443,7 @@ export const DEFAULT_RETENTION_POLICY: Readonly<RetentionPolicy> = {
|
|
|
393
443
|
retentionDays: 30,
|
|
394
444
|
maxRuns: 5000,
|
|
395
445
|
maxBytes: 2 * GIB,
|
|
446
|
+
sessionLogMaxBytes: 200 * MIB,
|
|
396
447
|
};
|
|
397
448
|
|
|
398
449
|
/** Inclusive `[min, max]` per policy field. */
|
|
@@ -400,6 +451,7 @@ export const RETENTION_BOUNDS: Readonly<Record<keyof RetentionPolicy, readonly [
|
|
|
400
451
|
retentionDays: [1, 365],
|
|
401
452
|
maxRuns: [1, 20_000],
|
|
402
453
|
maxBytes: [16 * MIB, 8 * GIB],
|
|
454
|
+
sessionLogMaxBytes: [16 * MIB, 2 * GIB],
|
|
403
455
|
};
|
|
404
456
|
|
|
405
457
|
/** Fill a partial policy from the defaults and clamp every field into its
|
|
@@ -411,7 +463,12 @@ export function clampRetentionPolicy(partial: Partial<RetentionPolicy>): Retenti
|
|
|
411
463
|
const [lo, hi] = RETENTION_BOUNDS[k];
|
|
412
464
|
return Math.min(hi, Math.max(lo, n));
|
|
413
465
|
};
|
|
414
|
-
return {
|
|
466
|
+
return {
|
|
467
|
+
retentionDays: field("retentionDays"),
|
|
468
|
+
maxRuns: field("maxRuns"),
|
|
469
|
+
maxBytes: field("maxBytes"),
|
|
470
|
+
sessionLogMaxBytes: field("sessionLogMaxBytes"),
|
|
471
|
+
};
|
|
415
472
|
}
|
|
416
473
|
|
|
417
474
|
// ---- validation -------------------------------------------------------------
|
|
@@ -515,6 +572,10 @@ export function isRunRecord(v: unknown): v is RunRecord {
|
|
|
515
572
|
// A parent is named by a run id (item 46): the same shape as the record's own.
|
|
516
573
|
if (r.parentRunId !== undefined && (typeof r.parentRunId !== "string" || !RUN_ID_PATTERN.test(r.parentRunId)))
|
|
517
574
|
return false;
|
|
575
|
+
// Where the conversation started (item 52): one of the two words, or absent.
|
|
576
|
+
if (r.seed !== undefined && !RUN_SEEDS.includes(r.seed as RunSeed)) return false;
|
|
577
|
+
// The run's place in its session's log (item 53), or absent.
|
|
578
|
+
if (r.session !== undefined && !isRunSession(r.session)) return false;
|
|
518
579
|
// A coordinator's child (item 48): the instance id in the platform's alphabet
|
|
519
580
|
// and the key `<instance>:<step>` — both or neither; one alone is no tag.
|
|
520
581
|
if ((r.parentInstanceId === undefined) !== (r.idempotencyKey === undefined)) return false;
|
|
@@ -283,22 +283,26 @@ export function cursorFinished(cursor: PlanCursor): boolean {
|
|
|
283
283
|
|
|
284
284
|
// ---- the unit pipeline: shapes --------------------------------------------------------------------
|
|
285
285
|
|
|
286
|
-
|
|
286
|
+
/** `findings` is the step after a verdict that requests changes: the review's
|
|
287
|
+
* findings dispatched into the unit thread as `agent:coding`, so the coding
|
|
288
|
+
* session there continues with them (record 0034). Never a `fix` child briefed
|
|
289
|
+
* from the review. */
|
|
290
|
+
export type RoundKind = "coding" | "review" | "findings";
|
|
287
291
|
export interface RoundRef {
|
|
288
|
-
/** Round 0 is the coding round; review round n and its
|
|
292
|
+
/** Round 0 is the coding round; review round n and its findings step share n. */
|
|
289
293
|
index: number;
|
|
290
294
|
kind: RoundKind;
|
|
291
295
|
}
|
|
292
296
|
export type ChildPreset = "coding" | "review";
|
|
293
297
|
|
|
294
|
-
/** The preset a round's child runs as:
|
|
298
|
+
/** The preset a round's child runs as: the findings step's run is a coding run. */
|
|
295
299
|
export function presetOf(kind: RoundKind): ChildPreset {
|
|
296
300
|
return kind === "review" ? "review" : "coding";
|
|
297
301
|
}
|
|
298
302
|
|
|
299
303
|
/** How a spawn's child is briefed — ids only, never text. The bot composes the
|
|
300
304
|
* turn: the unit's contract, or the review turn from the pull request and the
|
|
301
|
-
* prior rounds' records, or the
|
|
305
|
+
* prior rounds' records, or the findings message from the review run's verdict. */
|
|
302
306
|
export type Brief =
|
|
303
307
|
| { kind: "contract"; unit: string; rebase: { branch: string; onto: string } }
|
|
304
308
|
| {
|
|
@@ -307,10 +311,10 @@ export type Brief =
|
|
|
307
311
|
pr: number;
|
|
308
312
|
headSha?: string;
|
|
309
313
|
round: number;
|
|
310
|
-
/** The previous review round's run and the
|
|
311
|
-
prior?: { reviewRunId: string;
|
|
314
|
+
/** The previous review round's run and the coding run that answered its findings, for a re-review. */
|
|
315
|
+
prior?: { reviewRunId: string; codingRunId?: string };
|
|
312
316
|
}
|
|
313
|
-
| { kind: "
|
|
317
|
+
| { kind: "findings"; unit: string; pr: number; reviewRunId: string };
|
|
314
318
|
|
|
315
319
|
export interface PrRef {
|
|
316
320
|
number: number;
|
|
@@ -351,7 +355,7 @@ export type ChildFacts =
|
|
|
351
355
|
/** Why the child recorded no post, when it recorded one it chose or failed. */
|
|
352
356
|
reviewPostReason?: string;
|
|
353
357
|
reviewHead?: string;
|
|
354
|
-
/**
|
|
358
|
+
/** The dispositions a coding run recorded, as it submitted them: the machine matches them to the round's findings. */
|
|
355
359
|
dispositions?: FindingDisposition[];
|
|
356
360
|
handoff?: boolean;
|
|
357
361
|
};
|
|
@@ -451,13 +455,16 @@ export interface UnitPipelineState {
|
|
|
451
455
|
readonly pr?: PrRef;
|
|
452
456
|
readonly lastReviewHead?: string;
|
|
453
457
|
readonly lastVerdictSummary?: string;
|
|
454
|
-
/** Findings per review round and dispositions
|
|
455
|
-
* review round they belong to — finding
|
|
458
|
+
/** Findings per review round and the dispositions the round's findings step
|
|
459
|
+
* recorded against them, keyed by the review round they belong to — finding
|
|
460
|
+
* ids are unique within one round only. A disposition naming an id the review
|
|
461
|
+
* never issued is dropped at the match (`matchDispositions`). */
|
|
456
462
|
readonly findingsByRound: Readonly<Record<number, Finding[]>>;
|
|
457
463
|
readonly dispositionsByRound: Readonly<Record<number, FindingDisposition[]>>;
|
|
458
464
|
readonly reviewRunByRound: Readonly<Record<number, string>>;
|
|
459
|
-
|
|
460
|
-
|
|
465
|
+
/** The coding run each round's findings step dispatched. */
|
|
466
|
+
readonly findingsRunByRound: Readonly<Record<number, string>>;
|
|
467
|
+
/** The last coding run (round 0's child or a findings step's): its record carries the unit's handoff. */
|
|
461
468
|
readonly lastCodingRunId?: string;
|
|
462
469
|
readonly ending?: UnitEnding;
|
|
463
470
|
}
|
|
@@ -490,7 +497,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
|
|
|
490
497
|
findingsByRound: {},
|
|
491
498
|
dispositionsByRound: {},
|
|
492
499
|
reviewRunByRound: {},
|
|
493
|
-
|
|
500
|
+
findingsRunByRound: {},
|
|
494
501
|
};
|
|
495
502
|
if (!input.resume) return base;
|
|
496
503
|
const url = input.resume.url ?? `https://github.com/${input.repo}/pull/${input.resume.pr}`;
|
|
@@ -528,9 +535,9 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
|
|
|
528
535
|
if (round.kind === "coding")
|
|
529
536
|
return { kind: "contract", unit, rebase: { branch: s.input.unit.branch, onto: s.input.base } };
|
|
530
537
|
const pr = s.pr!.number;
|
|
531
|
-
if (round.kind === "
|
|
538
|
+
if (round.kind === "findings") return { kind: "findings", unit, pr, reviewRunId: s.reviewRunByRound[round.index]! };
|
|
532
539
|
const priorReview = s.reviewRunByRound[round.index - 1];
|
|
533
|
-
const
|
|
540
|
+
const priorCoding = s.findingsRunByRound[round.index - 1];
|
|
534
541
|
return {
|
|
535
542
|
kind: "review",
|
|
536
543
|
unit,
|
|
@@ -538,11 +545,27 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
|
|
|
538
545
|
...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
|
|
539
546
|
round: round.index,
|
|
540
547
|
...(priorReview !== undefined
|
|
541
|
-
? { prior: { reviewRunId: priorReview, ...(
|
|
548
|
+
? { prior: { reviewRunId: priorReview, ...(priorCoding !== undefined ? { codingRunId: priorCoding } : {}) } }
|
|
542
549
|
: {}),
|
|
543
550
|
};
|
|
544
551
|
}
|
|
545
552
|
|
|
553
|
+
/** The dispositions a coding run recorded that answer `findings`, and the ids
|
|
554
|
+
* it named that the review never issued (agent-ship item 6). The tool records
|
|
555
|
+
* whatever the run submits, so the match is the runner's: `matched` is what the
|
|
556
|
+
* state, the report and the re-review carry, `dropped` what the re-review's
|
|
557
|
+
* note names. Pure and node-free, so the spawn route composing the re-review
|
|
558
|
+
* turn and this machine agree by construction. */
|
|
559
|
+
export function matchDispositions(
|
|
560
|
+
findings: readonly Finding[],
|
|
561
|
+
dispositions: readonly FindingDisposition[],
|
|
562
|
+
): { matched: FindingDisposition[]; dropped: string[] } {
|
|
563
|
+
const issued = new Set(findings.map((f) => f.id));
|
|
564
|
+
const matched = dispositions.filter((d) => issued.has(d.findingId));
|
|
565
|
+
const dropped = [...new Set(dispositions.filter((d) => !issued.has(d.findingId)).map((d) => d.findingId))];
|
|
566
|
+
return { matched, dropped };
|
|
567
|
+
}
|
|
568
|
+
|
|
546
569
|
/** The step the machine is at. Pure over the state: asked before every step
|
|
547
570
|
* and again after a replay, it names the same step for the same state. */
|
|
548
571
|
export function nextAction(s: UnitPipelineState): CoordinatorAction {
|
|
@@ -629,7 +652,7 @@ function nextReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transi
|
|
|
629
652
|
const stopMode = (status: RunStatus): "soft" | "hard" | undefined =>
|
|
630
653
|
status === "stopped_soft" ? "soft" : status === "stopped_hard" ? "hard" : undefined;
|
|
631
654
|
|
|
632
|
-
/** A coding
|
|
655
|
+
/** A coding run's confirmed end: round 0's child, or the run a findings step dispatched. */
|
|
633
656
|
function settleCoding(
|
|
634
657
|
s: UnitPipelineState,
|
|
635
658
|
round: RoundRef,
|
|
@@ -637,12 +660,19 @@ function settleCoding(
|
|
|
637
660
|
facts: Extract<ChildFacts, { finished: true }>,
|
|
638
661
|
): Transition {
|
|
639
662
|
// Recorded before the checks below: a pull request is a fact a stop must
|
|
640
|
-
// still report, and submitted dispositions are a fact no ending erases.
|
|
663
|
+
// still report, and submitted dispositions are a fact no ending erases. The
|
|
664
|
+
// run records whatever it submitted; only the dispositions that answer this
|
|
665
|
+
// round's findings enter the state (agent-ship item 6).
|
|
641
666
|
let next: UnitPipelineState = {
|
|
642
667
|
...s,
|
|
643
668
|
...(facts.pr !== undefined ? { pr: { number: facts.pr.number, url: facts.pr.url } } : {}),
|
|
644
|
-
...(round.kind === "
|
|
645
|
-
? {
|
|
669
|
+
...(round.kind === "findings" && facts.dispositions !== undefined
|
|
670
|
+
? {
|
|
671
|
+
dispositionsByRound: {
|
|
672
|
+
...s.dispositionsByRound,
|
|
673
|
+
[round.index]: matchDispositions(s.findingsByRound[round.index] ?? [], facts.dispositions).matched,
|
|
674
|
+
},
|
|
675
|
+
}
|
|
646
676
|
: {}),
|
|
647
677
|
};
|
|
648
678
|
const mode = stopMode(facts.status);
|
|
@@ -752,7 +782,7 @@ function settleReview(
|
|
|
752
782
|
return { state: { ...next, phase: { at: "merge", pr, headSha, n: 1, since: next.clock } }, notes };
|
|
753
783
|
}
|
|
754
784
|
// request_changes: the verdict settled (and posted) — now a stop
|
|
755
|
-
// short-circuits the
|
|
785
|
+
// short-circuits the findings step, naming the review standing on the pull request.
|
|
756
786
|
if (mode !== undefined)
|
|
757
787
|
return end(
|
|
758
788
|
next,
|
|
@@ -772,7 +802,11 @@ function settleReview(
|
|
|
772
802
|
{ kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
|
|
773
803
|
notes,
|
|
774
804
|
);
|
|
775
|
-
|
|
805
|
+
// The findings step (agent-ship item 7): the review's findings dispatched into
|
|
806
|
+
// the unit thread as a coding run, under the review round's index. A run live
|
|
807
|
+
// there is a person's (this machine awaited its own child's end), so the
|
|
808
|
+
// spawn answers busy and the step waits under the unit's clock like any round.
|
|
809
|
+
return enterRound(next, { index: round.index, kind: "findings" }, notes);
|
|
776
810
|
}
|
|
777
811
|
|
|
778
812
|
/** The unit's ending when its pull request is found merged — by a person, or
|
|
@@ -792,7 +826,7 @@ function foundMerged(
|
|
|
792
826
|
);
|
|
793
827
|
}
|
|
794
828
|
|
|
795
|
-
/** The pull request heading the branch after
|
|
829
|
+
/** The pull request heading the branch after round 0 or a findings step. */
|
|
796
830
|
function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-check" }>, pr: PrCheck): Transition {
|
|
797
831
|
const { round } = phase;
|
|
798
832
|
// The merge landed during the round: the child found nothing left to ship
|
|
@@ -803,7 +837,7 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
|
|
|
803
837
|
const reason =
|
|
804
838
|
round.index === 0
|
|
805
839
|
? `⚠️ Ship ended at round 0: the coding round ended without opening a pull request (a clarifying question, a budget write-up, an unproven push or a description-less push ends the pipeline here). No review round ran.`
|
|
806
|
-
: `⚠️
|
|
840
|
+
: `⚠️ Round ${round.index}'s findings step left no open pull request heading \`${s.input.unit.branch}\` — the pull request was closed out from under the pipeline and none was reopened, so there is nothing to re-review.`;
|
|
807
841
|
return end(
|
|
808
842
|
s,
|
|
809
843
|
{
|
|
@@ -818,13 +852,13 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
|
|
|
818
852
|
}
|
|
819
853
|
const head = pr.headSha ?? phase.childHead;
|
|
820
854
|
const next: UnitPipelineState = { ...s, pr: { number: pr.prNumber, url: pr.url } };
|
|
821
|
-
if (round.kind === "
|
|
855
|
+
if (round.kind === "findings") {
|
|
822
856
|
// Nothing repushed → nothing to re-review, unless every finding of the
|
|
823
857
|
// last review was declined on the record: that re-review verifies the
|
|
824
858
|
// arguments and may concede.
|
|
825
|
-
const
|
|
859
|
+
const codingHead = normalizeHead(head);
|
|
826
860
|
const reviewedAt = normalizeHead(s.lastReviewHead);
|
|
827
|
-
if (
|
|
861
|
+
if (codingHead !== undefined && reviewedAt !== undefined && sameCommit(codingHead, reviewedAt)) {
|
|
828
862
|
const findings = s.findingsByRound[round.index] ?? [];
|
|
829
863
|
const dispositions = s.dispositionsByRound[round.index] ?? [];
|
|
830
864
|
const allDeclined =
|
|
@@ -835,7 +869,7 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
|
|
|
835
869
|
next,
|
|
836
870
|
{
|
|
837
871
|
kind: "aborted",
|
|
838
|
-
reason: `⚠️
|
|
872
|
+
reason: `⚠️ Round ${round.index}'s findings step produced no new head — the branch still sits at \`${codingHead.slice(0, 7)}\`, the commit the review already read, and not every finding was declined on the record, so there is nothing new to re-review.`,
|
|
839
873
|
round,
|
|
840
874
|
reviewRounds: next.reviewRounds,
|
|
841
875
|
},
|
|
@@ -889,8 +923,11 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
889
923
|
const runs =
|
|
890
924
|
p.round.kind === "review"
|
|
891
925
|
? { reviewRunByRound: { ...s.reviewRunByRound, [p.round.index]: r.runId } }
|
|
892
|
-
: p.round.kind === "
|
|
893
|
-
? {
|
|
926
|
+
: p.round.kind === "findings"
|
|
927
|
+
? {
|
|
928
|
+
findingsRunByRound: { ...s.findingsRunByRound, [p.round.index]: r.runId },
|
|
929
|
+
lastCodingRunId: r.runId,
|
|
930
|
+
}
|
|
894
931
|
: { lastCodingRunId: r.runId };
|
|
895
932
|
return {
|
|
896
933
|
state: { ...clocked, ...runs, phase: { at: "wait", round: p.round, runId: r.runId, n: 1, until } },
|
|
@@ -999,8 +1036,8 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
999
1036
|
const sameFinding = (a: Finding, b: Finding) => a.severity === b.severity && a.file === b.file && a.title === b.title;
|
|
1000
1037
|
|
|
1001
1038
|
/** The disposition that answers `finding` as review round `round` listed it:
|
|
1002
|
-
* that round's
|
|
1003
|
-
* earlier round — never across a reused id, which inherits nothing. */
|
|
1039
|
+
* the one that round's findings step recorded, else one carried forward
|
|
1040
|
+
* unchanged from an earlier round — never across a reused id, which inherits nothing. */
|
|
1004
1041
|
function dispositionFor(s: UnitPipelineState, finding: Finding, round: number): FindingDisposition | undefined {
|
|
1005
1042
|
let cur = finding;
|
|
1006
1043
|
for (let r = round; r >= 1; r--) {
|
|
@@ -1097,7 +1134,7 @@ export function renderUnitReport(s: UnitPipelineState): string {
|
|
|
1097
1134
|
return join([e.finalReply, e.reason, `⚠️ Ship aborted after ${rounds}.`, reissue]);
|
|
1098
1135
|
case "no_verdict":
|
|
1099
1136
|
return join([
|
|
1100
|
-
`⚠️ Review round ${e.round.index} ended without a submitted verdict (budget, refusal, or stop) — ship never converts that into a request for changes, so no
|
|
1137
|
+
`⚠️ Review round ${e.round.index} ended without a submitted verdict (budget, refusal, or stop) — ship never converts that into a request for changes, so no findings step ran.`,
|
|
1101
1138
|
e.finalReply ? `Review round's final message:\n\n${e.finalReply}` : undefined,
|
|
1102
1139
|
`⚠️ Ship aborted after ${rounds}.`,
|
|
1103
1140
|
reissue,
|
|
@@ -52,6 +52,8 @@ export function shimRoute(pathname: string): string | undefined {
|
|
|
52
52
|
if (pathname === "/costs" || pathname.startsWith("/costs/")) return "costs";
|
|
53
53
|
if (pathname.startsWith("/api/")) return "api";
|
|
54
54
|
if (pathname.startsWith("/admin/")) return "admin";
|
|
55
|
+
// The artifact copy (deploy/cloudflare/artifactsCopy.ts): the shim's own route, a 1 GB stream.
|
|
56
|
+
if (pathname === "/artifacts/copy") return "artifacts";
|
|
55
57
|
// The model proxy's two routes (docs/reference/specs/model-proxy.md): a bounded
|
|
56
58
|
// request per model call, forwarded to the container like everything else.
|
|
57
59
|
if (pathname === "/v1/messages" || pathname === "/v1/chat/completions") return "model-proxy";
|
|
@@ -89,6 +89,17 @@ export const profileSchema = z.object({
|
|
|
89
89
|
/** The Cloudflare Access application in front of the bot's dashboards, when
|
|
90
90
|
* there is one: the team domain the JWT is issued by and the app's AUD. */
|
|
91
91
|
access: z.object({ teamDomain: hostname, aud: z.string().regex(/^[0-9a-f]{64}$/) }).optional(),
|
|
92
|
+
/** The artifact store's bucket (docs/reference/specs/execution.md item 20), when the installation
|
|
93
|
+
* has one: the bot Worker's template binds it as `ARTIFACTS` and `deploy` creates it before the
|
|
94
|
+
* upload. The bot's runtime config (`artifacts.r2.bucket`) must name the same bucket — the two
|
|
95
|
+
* are held equal by `artifacts check`, not by the profile. R2's own bucket-name rules. */
|
|
96
|
+
artifacts: z
|
|
97
|
+
.object({
|
|
98
|
+
bucket: z
|
|
99
|
+
.string()
|
|
100
|
+
.regex(/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/, "an R2 bucket name: 3–63 lowercase letters, digits and hyphens"),
|
|
101
|
+
})
|
|
102
|
+
.optional(),
|
|
92
103
|
});
|
|
93
104
|
|
|
94
105
|
export type DeploymentProfile = z.infer<typeof profileSchema>;
|
|
@@ -136,13 +136,21 @@ export type InstanceDecision =
|
|
|
136
136
|
/** Create an instance only for a row no live cycle blocks (`refreshCycleBlocked`)
|
|
137
137
|
* whose cadence has elapsed since the last instance — counted in whole
|
|
138
138
|
* buckets so cron jitter never skips a due bucket: the awake cadence is one
|
|
139
|
-
* bucket, the idle cadence the
|
|
140
|
-
*
|
|
139
|
+
* bucket, the idle cadence is the idle interval in buckets. The idle cadence
|
|
140
|
+
* is for a `warm` row with `idleSince` set and no other: only a cycle's gate
|
|
141
|
+
* clears `idleSince`, so a wake that dies before its cycle (a deploy rolling
|
|
142
|
+
* the container mid-restore) leaves the marker on a `restoring` row, and on
|
|
143
|
+
* the `degraded(stale-mid-flight…)` the watchdog stamps it with; both must
|
|
144
|
+
* run at the next bucket, or the resident sits degraded for the idle
|
|
145
|
+
* interval. A streak-parked `degraded` row (item 16b) runs every bucket
|
|
146
|
+
* too: its gate parks it again without a fetch, and the first rehydrate
|
|
147
|
+
* flips it `warm`. */
|
|
141
148
|
export function shouldCreateRefreshInstance(row: RefreshRow, nowMs: number, cadence: RefreshCadence): InstanceDecision {
|
|
142
149
|
const blocked = refreshCycleBlocked(row, nowMs);
|
|
143
150
|
if (blocked) return { create: false, why: blocked };
|
|
144
151
|
if (row.lastInstanceAt !== null) {
|
|
145
|
-
const
|
|
152
|
+
const parked = row.state === "warm" && row.idleSince !== null;
|
|
153
|
+
const delayS = parked ? cadence.idleIntervalS : cadence.intervalS;
|
|
146
154
|
const dueBuckets = Math.max(1, Math.ceil((delayS * 1000) / REFRESH_BUCKET_MS));
|
|
147
155
|
if (refreshBucket(nowMs) - refreshBucket(row.lastInstanceAt) < dueBuckets) return { create: false, why: "not-due" };
|
|
148
156
|
}
|