@coreplane/switchboard 1.214.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.
@@ -151,12 +151,48 @@ export interface RunRecord {
151
151
  * turns at the spawn (`DispatchOptions.seed`). Absent on records written
152
152
  * before it existed. */
153
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;
154
161
  }
155
162
 
156
163
  /** The two places a run's conversation can start (item 52). */
157
164
  export const RUN_SEEDS = ["channel", "parent"] as const;
158
165
  export type RunSeed = (typeof RUN_SEEDS)[number];
159
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);
194
+ }
195
+
160
196
  /** The profile as the record stores it: the run's effective profile plus the preset it came from. */
161
197
  export type RunProfileRecord = RunProfile & { preset: string };
162
198
 
@@ -393,6 +429,10 @@ export interface RetentionPolicy {
393
429
  retentionDays: number;
394
430
  maxRuns: number;
395
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;
396
436
  }
397
437
 
398
438
  const KIB = 1024;
@@ -403,6 +443,7 @@ export const DEFAULT_RETENTION_POLICY: Readonly<RetentionPolicy> = {
403
443
  retentionDays: 30,
404
444
  maxRuns: 5000,
405
445
  maxBytes: 2 * GIB,
446
+ sessionLogMaxBytes: 200 * MIB,
406
447
  };
407
448
 
408
449
  /** Inclusive `[min, max]` per policy field. */
@@ -410,6 +451,7 @@ export const RETENTION_BOUNDS: Readonly<Record<keyof RetentionPolicy, readonly [
410
451
  retentionDays: [1, 365],
411
452
  maxRuns: [1, 20_000],
412
453
  maxBytes: [16 * MIB, 8 * GIB],
454
+ sessionLogMaxBytes: [16 * MIB, 2 * GIB],
413
455
  };
414
456
 
415
457
  /** Fill a partial policy from the defaults and clamp every field into its
@@ -421,7 +463,12 @@ export function clampRetentionPolicy(partial: Partial<RetentionPolicy>): Retenti
421
463
  const [lo, hi] = RETENTION_BOUNDS[k];
422
464
  return Math.min(hi, Math.max(lo, n));
423
465
  };
424
- return { retentionDays: field("retentionDays"), maxRuns: field("maxRuns"), maxBytes: field("maxBytes") };
466
+ return {
467
+ retentionDays: field("retentionDays"),
468
+ maxRuns: field("maxRuns"),
469
+ maxBytes: field("maxBytes"),
470
+ sessionLogMaxBytes: field("sessionLogMaxBytes"),
471
+ };
425
472
  }
426
473
 
427
474
  // ---- validation -------------------------------------------------------------
@@ -527,6 +574,8 @@ export function isRunRecord(v: unknown): v is RunRecord {
527
574
  return false;
528
575
  // Where the conversation started (item 52): one of the two words, or absent.
529
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;
530
579
  // A coordinator's child (item 48): the instance id in the platform's alphabet
531
580
  // and the key `<instance>:<step>` — both or neither; one alone is no tag.
532
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
- export type RoundKind = "coding" | "review" | "fix";
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 fix round share n. */
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: a fix round is a coding child. */
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 fix turn from the review run's verdict. */
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 fix round that answered it, for a re-review. */
311
- prior?: { reviewRunId: string; fixRunId?: 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: "fix"; unit: string; pr: number; reviewRunId: string };
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
- /** A fix child's dispositions. */
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 per fix round, keyed by the
455
- * review round they belong to — finding ids are unique within one round only. */
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
- readonly fixRunByRound: Readonly<Record<number, string>>;
460
- /** The last coding child (round 0 or a fix round): its record carries the unit's handoff. */
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
- fixRunByRound: {},
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 === "fix") return { kind: "fix", unit, pr, reviewRunId: s.reviewRunByRound[round.index]! };
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 priorFix = s.fixRunByRound[round.index - 1];
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, ...(priorFix !== undefined ? { fixRunId: priorFix } : {}) } }
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 or fix child's confirmed end. */
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 === "fix" && facts.dispositions !== undefined
645
- ? { dispositionsByRound: { ...s.dispositionsByRound, [round.index]: facts.dispositions } }
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 fix round, naming the review standing on the pull request.
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
- return enterRound(next, { index: round.index, kind: "fix" }, notes);
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 a coding or fix round. */
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
- : `⚠️ Fix round ${round.index} 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.`;
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 === "fix") {
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 fixHead = normalizeHead(head);
859
+ const codingHead = normalizeHead(head);
826
860
  const reviewedAt = normalizeHead(s.lastReviewHead);
827
- if (fixHead !== undefined && reviewedAt !== undefined && sameCommit(fixHead, reviewedAt)) {
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: `⚠️ Fix round ${round.index} produced no new head — the branch still sits at \`${fixHead.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.`,
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 === "fix"
893
- ? { fixRunByRound: { ...s.fixRunByRound, [p.round.index]: r.runId }, lastCodingRunId: r.runId }
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 own fix round's, else one carried forward unchanged from an
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 fix round ran.`,
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,
@@ -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 row records (`idleSince` set) is the idle
140
- * interval in buckets. */
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 delayS = row.idleSince !== null ? cadence.idleIntervalS : cadence.intervalS;
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
  }