codecartographer-pi 0.22.1 → 0.22.2

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.
@@ -3,4 +3,4 @@
3
3
  # workspace's framework-owned files (GUIDE.md, templates/, workflow/ pipelines
4
4
  # and VALIDATE.md) predate the running release. Written at release time and
5
5
  # copied verbatim by init — never edit by hand.
6
- scaffold_version: 0.22.1
6
+ scaffold_version: 0.22.2
package/README.md CHANGED
@@ -396,7 +396,7 @@ codecarto_broadside {cwd, action: "status"} # what is in fligh
396
396
  codecarto_broadside {cwd, action: "collect"} # poll, save, synthesize, triage
397
397
  ```
398
398
 
399
- Submit and collect are separate because batch jobs routinely take tens of minutes; collect is resumable and picks up whatever is still in flight (`wait_seconds: 0`, the default, polls once and returns). Submit prices the run from the collected file sizes against the model's live per-token pricing (cached 24h) and refuses when the estimate exceeds `max_cost` — $1.00 unless the config or the call sets another value, `0` for no limit — unless `force: true` is passed — a pre-flight estimate, not a runtime stop. Actual spend lands in each run's `run-meta.json`.
399
+ Submit and collect are separate because batch jobs routinely take tens of minutes; collect is resumable and picks up whatever is still in flight (`wait_seconds: 0`, the default, polls once and returns), and two collects on one run — a retried tool call, a second session — never pay for the synthesis, triage, or truncation retry twice: each is claimed in the run's state before it is submitted, and a collect whose client has gone away stops polling and submits nothing further. Submit prices the run from the collected file sizes against the model's live per-token pricing (cached 24h) and refuses when the estimate exceeds `max_cost` — $1.00 unless the config or the call sets another value, `0` for no limit — unless `force: true` is passed — a pre-flight estimate, not a runtime stop. Actual spend lands in each run's `run-meta.json`.
400
400
 
401
401
  Repository defaults live in `.codecarto/broadside/config.yaml` (`model`, `api_key`, `default_lenses`, `max_cost`, `pricing` overrides, `lens_models`, `incremental`, `retry_truncated`, `include_synthesis`, `include_triage`, `wait_seconds`); an explicit tool parameter always wins. `lens_models` routes individual lenses to their own batch model — a stronger model changes security and defect findings far more than it changes an architecture map — and each override is priced, capability-checked, and clamped exactly like the default, with the estimate broken out per lens so a mixed-model run cannot be approved without seeing which lens costs what. CodeCartographer ships no stronger default: which model earns its price depends on your repository and budget, so compare candidates with the `models` action and choose — for one run with the `model` and `lens_models` parameters (Pi: `--model=ID`, `--lens-model=LENS:ID`), or for the repository in `config.yaml`. The `models` listing is advisory: OpenRouter's catalog returns a `:batch` id for some models its Batch API then refuses (`does not have a :batch endpoint`), at no cost, and nothing in the catalog tells them apart — so the listing tags the ids this repository's own submits have seen accepted or refused, and a refused lens says why in the submit report. `codecarto_skill {cwd, name: "broadside"}` returns the reading guide for a completed run, and unlike post-pipeline skills it is not gated on a finished pipeline.
402
402
 
@@ -230,7 +230,24 @@ export type BroadsideTriageEntry = {
230
230
  batchId?: string;
231
231
  status: "pending" | "submitted" | "completed" | "failed";
232
232
  cost?: number;
233
+ error?: string;
233
234
  };
235
+ /** The truncation retry pass of one run: one batch per model (#206). */
236
+ export type BroadsideRetryEntry = {
237
+ status: "submitted" | "completed" | "failed";
238
+ batches: Array<{
239
+ model: string;
240
+ batchId: string;
241
+ }>;
242
+ /** When the owning collect claimed the pass (#322). */
243
+ claimedAt: string;
244
+ };
245
+ /**
246
+ * The parts of a run that cost money to submit and that exactly one collect
247
+ * may own: the two post-passes and the truncation retry (#322).
248
+ */
249
+ export type BroadsideRunSlot = "synthesis" | "triage" | "retry";
250
+ export declare const BROADSIDE_RUN_SLOTS: readonly BroadsideRunSlot[];
234
251
  export type BroadsideRun = {
235
252
  id: string;
236
253
  createdAt: string;
@@ -241,6 +258,11 @@ export type BroadsideRun = {
241
258
  batches: Partial<Record<BroadsideLensId, BroadsideBatchEntry>>;
242
259
  synthesis: BroadsideSynthesisEntry;
243
260
  triage: BroadsideTriageEntry;
261
+ /**
262
+ * The truncation retry pass (#133), recorded so that two collects on one
263
+ * run cannot both submit it (#322). Absent until a collect claims it.
264
+ */
265
+ retry?: BroadsideRetryEntry;
244
266
  totalCost?: number;
245
267
  pricing?: ModelPricing;
246
268
  maxCost?: number;
@@ -438,6 +460,8 @@ export type BroadsideCollectResult = {
438
460
  truncatedCount: number;
439
461
  /** Truncated slices recovered by the automatic re-submit pass (#133). */
440
462
  retriedCount: number;
463
+ /** Another collect on this run owns the retry pass; its result lands on a later collect (#322). */
464
+ retryElsewhere?: boolean;
441
465
  lensOutcomes: Partial<Record<BroadsideLensId, {
442
466
  status: string;
443
467
  cost?: number;
@@ -555,6 +579,35 @@ export declare function updateBroadsideStateAtomically(broadsideDir: string, mut
555
579
  * restored the next time its own operation checkpoints.
556
580
  */
557
581
  export declare function persistBroadsideRun(broadsideDir: string, run: BroadsideRun): Promise<BroadsideStateFile>;
582
+ /**
583
+ * Record a collect's view of its run, keeping whatever is further along on
584
+ * disk (#322).
585
+ *
586
+ * Two collects on one run each hold the run in memory and each used to write
587
+ * the whole thing back, so the last writer replaced the other's post-pass
588
+ * entries with its own — and both had submitted their own post-passes, since
589
+ * each decided from the copy it loaded at entry. This writer merges slot by
590
+ * slot: a post-pass or retry entry that is further along on disk (claimed
591
+ * over pending, submitted over claimed, settled over submitted) wins and is
592
+ * copied into `run`, so the caller reports what is true; a lens entry never
593
+ * goes backwards from terminal to polling. A tie keeps this collect's copy,
594
+ * so the collect that settled a pass records its cost. Submitting is guarded
595
+ * separately by {@link claimRunSlot}.
596
+ */
597
+ export declare function persistBroadsideRunMerging(broadsideDir: string, run: BroadsideRun): Promise<BroadsideStateFile>;
598
+ /**
599
+ * Claim one spending slot of a run for this collect (#322).
600
+ *
601
+ * Read-modify-write under the state lock: if the slot on disk is still
602
+ * unclaimed (`pending`, or absent for the retry), it is marked `submitted`
603
+ * with no batch id *before* any network call and `true` comes back — this
604
+ * collect owns it and may submit. Otherwise another collect got there first:
605
+ * its entry is copied into `run` and `false` comes back. An adopted entry
606
+ * with a batch id can be polled (polling is idempotent); one without an id
607
+ * is a claim whose owner has not recorded the id yet, and is reported as in
608
+ * flight elsewhere.
609
+ */
610
+ export declare function claimRunSlot(broadsideDir: string, run: BroadsideRun, slot: BroadsideRunSlot): Promise<boolean>;
558
611
  export declare function loadBroadsideConfig(broadsideDir: string): Promise<BroadsideConfig>;
559
612
  /** The shipped defaults: what an absent config.yaml means. */
560
613
  export declare function defaultBroadsideConfig(): BroadsideConfig;
@@ -624,6 +677,12 @@ export declare function pollBatchUntilTerminal(batchId: string, apiKey: string,
624
677
  onStatus?: (status: string, counts: Record<string, unknown>) => void;
625
678
  fetcher?: FetchLike;
626
679
  pollIntervalMs?: number;
680
+ /**
681
+ * Stops polling early with the same synthetic `timeout` a spent budget
682
+ * returns: the batch keeps running server-side and a later collect
683
+ * claims it. The MCP server aborts when its client disconnects (#322).
684
+ */
685
+ signal?: AbortSignal;
627
686
  }): Promise<Record<string, unknown>>;
628
687
  /**
629
688
  * Poll several batch ids in parallel against one shared deadline. Collect
@@ -639,6 +698,7 @@ export declare function pollBatchesConcurrently(entries: Array<{
639
698
  deadlineMs?: number;
640
699
  fetcher?: FetchLike;
641
700
  pollIntervalMs?: number;
701
+ signal?: AbortSignal;
642
702
  onStatus?: (lensId: string, status: string, counts: Record<string, unknown>) => void;
643
703
  }): Promise<Map<string, Record<string, unknown>>>;
644
704
  export declare function runBroadsideSubmit(cwd: string, apiKey: string, opts?: {
@@ -713,6 +773,14 @@ export declare function runBroadsideCollect(cwd: string, apiKey: string, opts?:
713
773
  * once a newer submit existed (#268). `status` lists the ids.
714
774
  */
715
775
  runId?: string;
776
+ /**
777
+ * Stops polling and submits nothing further once fired; what was
778
+ * already submitted keeps running server-side for a later collect to
779
+ * claim. The MCP server fires it when its client disconnects (#322).
780
+ */
781
+ signal?: AbortSignal;
782
+ /** Poll cadence override; tests drive the loop faster than 15 s. */
783
+ pollIntervalMs?: number;
716
784
  }): Promise<BroadsideCollectResult>;
717
785
  export declare function runBroadsideStatus(cwd: string): Promise<{
718
786
  state: BroadsideStateFile;
@@ -140,6 +140,7 @@ export function retryReasoningFor(original) {
140
140
  const { max_tokens: _cap, effort: _effort, ...rest } = original ?? {};
141
141
  return { ...rest, effort: "low" };
142
142
  }
143
+ export const BROADSIDE_RUN_SLOTS = ["synthesis", "triage", "retry"];
143
144
  /**
144
145
  * OpenRouter rejected the API key (HTTP 401/403). Thrown from the catalog
145
146
  * lookup rather than swallowed into "could not price" or a silent built-in
@@ -1534,6 +1535,110 @@ export async function persistBroadsideRun(broadsideDir, run) {
1534
1535
  state.runs[index] = run;
1535
1536
  });
1536
1537
  }
1538
+ /** Where a lens batch entry stands, for keeping the more advanced of two. */
1539
+ function batchEntryRank(entry) {
1540
+ if (!entry)
1541
+ return -1;
1542
+ if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status))
1543
+ return 2;
1544
+ if (entry.batchId)
1545
+ return 1;
1546
+ return 0;
1547
+ }
1548
+ /** Where a post-pass entry stands: unclaimed, claimed, submitted, settled. */
1549
+ function passEntryRank(entry) {
1550
+ if (!entry || entry.status === "pending")
1551
+ return 0;
1552
+ if (entry.status === "submitted")
1553
+ return entry.batchId ? 2 : 1;
1554
+ return 3;
1555
+ }
1556
+ /** Where the retry pass stands: absent, claimed, submitted, settled. */
1557
+ function retryEntryRank(entry) {
1558
+ if (!entry)
1559
+ return 0;
1560
+ if (entry.status === "submitted")
1561
+ return entry.batches.length > 0 ? 2 : 1;
1562
+ return 3;
1563
+ }
1564
+ /**
1565
+ * Record a collect's view of its run, keeping whatever is further along on
1566
+ * disk (#322).
1567
+ *
1568
+ * Two collects on one run each hold the run in memory and each used to write
1569
+ * the whole thing back, so the last writer replaced the other's post-pass
1570
+ * entries with its own — and both had submitted their own post-passes, since
1571
+ * each decided from the copy it loaded at entry. This writer merges slot by
1572
+ * slot: a post-pass or retry entry that is further along on disk (claimed
1573
+ * over pending, submitted over claimed, settled over submitted) wins and is
1574
+ * copied into `run`, so the caller reports what is true; a lens entry never
1575
+ * goes backwards from terminal to polling. A tie keeps this collect's copy,
1576
+ * so the collect that settled a pass records its cost. Submitting is guarded
1577
+ * separately by {@link claimRunSlot}.
1578
+ */
1579
+ export async function persistBroadsideRunMerging(broadsideDir, run) {
1580
+ return updateBroadsideStateAtomically(broadsideDir, (state) => {
1581
+ const index = state.runs.findIndex((candidate) => candidate.id === run.id);
1582
+ const onDisk = index === -1 ? undefined : state.runs[index];
1583
+ if (onDisk) {
1584
+ if (passEntryRank(onDisk.synthesis) > passEntryRank(run.synthesis))
1585
+ run.synthesis = onDisk.synthesis;
1586
+ if (passEntryRank(onDisk.triage) > passEntryRank(run.triage))
1587
+ run.triage = onDisk.triage;
1588
+ if (retryEntryRank(onDisk.retry) > retryEntryRank(run.retry))
1589
+ run.retry = onDisk.retry;
1590
+ for (const [lensId, theirs] of Object.entries(onDisk.batches)) {
1591
+ if (theirs && batchEntryRank(theirs) > batchEntryRank(run.batches[lensId]))
1592
+ run.batches[lensId] = theirs;
1593
+ }
1594
+ }
1595
+ if (index === -1)
1596
+ state.runs.push(run);
1597
+ else
1598
+ state.runs[index] = run;
1599
+ });
1600
+ }
1601
+ /**
1602
+ * Claim one spending slot of a run for this collect (#322).
1603
+ *
1604
+ * Read-modify-write under the state lock: if the slot on disk is still
1605
+ * unclaimed (`pending`, or absent for the retry), it is marked `submitted`
1606
+ * with no batch id *before* any network call and `true` comes back — this
1607
+ * collect owns it and may submit. Otherwise another collect got there first:
1608
+ * its entry is copied into `run` and `false` comes back. An adopted entry
1609
+ * with a batch id can be polled (polling is idempotent); one without an id
1610
+ * is a claim whose owner has not recorded the id yet, and is reported as in
1611
+ * flight elsewhere.
1612
+ */
1613
+ export async function claimRunSlot(broadsideDir, run, slot) {
1614
+ let owned = false;
1615
+ const claimedAt = new Date().toISOString();
1616
+ await updateBroadsideStateAtomically(broadsideDir, (state) => {
1617
+ const index = state.runs.findIndex((candidate) => candidate.id === run.id);
1618
+ const onDisk = index === -1 ? undefined : state.runs[index];
1619
+ const theirs = onDisk?.[slot];
1620
+ const unclaimed = slot === "retry" ? theirs === undefined : theirs?.status === "pending";
1621
+ if (onDisk && !unclaimed) {
1622
+ run[slot] = theirs;
1623
+ owned = false;
1624
+ return;
1625
+ }
1626
+ owned = true;
1627
+ if (slot === "retry") {
1628
+ run.retry = { status: "submitted", batches: [], claimedAt };
1629
+ }
1630
+ else {
1631
+ run[slot] = { ...run[slot], status: "submitted", batchId: undefined };
1632
+ }
1633
+ if (!onDisk) {
1634
+ state.runs.push(run);
1635
+ }
1636
+ else {
1637
+ onDisk[slot] = run[slot];
1638
+ }
1639
+ });
1640
+ return owned;
1641
+ }
1537
1642
  /** Read a `reasoning:` block from config.yaml, ignoring anything malformed. */
1538
1643
  function parseReasoningConfig(raw) {
1539
1644
  if (raw === false)
@@ -2025,6 +2130,8 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
2025
2130
  ...(lastError && sawBatch && { last_error: lastError }),
2026
2131
  });
2027
2132
  for (;;) {
2133
+ if (opts.signal?.aborted)
2134
+ return { ...timedOut(), aborted: true };
2028
2135
  let batch;
2029
2136
  try {
2030
2137
  batch = await fetchBatch(batchId, apiKey, fetcher);
@@ -2057,8 +2164,26 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
2057
2164
  return batch;
2058
2165
  if (Date.now() >= deadline)
2059
2166
  return timedOut();
2060
- await sleep(intervalMs);
2061
- }
2167
+ await sleepUnlessAborted(intervalMs, opts.signal);
2168
+ }
2169
+ }
2170
+ /** Sleep, but wake at once when the signal fires so an abort is not a poll interval late. */
2171
+ function sleepUnlessAborted(ms, signal) {
2172
+ if (!signal)
2173
+ return sleep(ms);
2174
+ if (signal.aborted)
2175
+ return Promise.resolve();
2176
+ return new Promise((resolve) => {
2177
+ const timer = setTimeout(() => {
2178
+ signal.removeEventListener("abort", onAbort);
2179
+ resolve();
2180
+ }, ms);
2181
+ const onAbort = () => {
2182
+ clearTimeout(timer);
2183
+ resolve();
2184
+ };
2185
+ signal.addEventListener("abort", onAbort, { once: true });
2186
+ });
2062
2187
  }
2063
2188
  /**
2064
2189
  * Poll several batch ids in parallel against one shared deadline. Collect
@@ -2075,6 +2200,7 @@ export async function pollBatchesConcurrently(entries, apiKey, opts = {}) {
2075
2200
  deadlineMs,
2076
2201
  fetcher: opts.fetcher,
2077
2202
  pollIntervalMs: opts.pollIntervalMs,
2203
+ signal: opts.signal,
2078
2204
  onStatus: (status, counts) => opts.onStatus?.(lensId, status, counts),
2079
2205
  });
2080
2206
  results.set(batchId, batch);
@@ -2606,6 +2732,12 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2606
2732
  }
2607
2733
  const runDir = join(broadsideDir, run.outputDir);
2608
2734
  await mkdir(runDir, { recursive: true });
2735
+ // The spending slots this collect has claimed (#322); only a claimed slot
2736
+ // is ever submitted from here. Every write-back merges with the file, so a
2737
+ // slot another collect has moved further along is never overwritten.
2738
+ const owned = new Set();
2739
+ const persist = () => persistBroadsideRunMerging(broadsideDir, run);
2740
+ const aborted = () => opts.signal?.aborted === true;
2609
2741
  const deadline = Date.now() + (opts.waitMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
2610
2742
  let totalCost = 0;
2611
2743
  let resultCount = 0;
@@ -2633,6 +2765,8 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2633
2765
  const polled = await pollBatchesConcurrently(inFlight, apiKey, {
2634
2766
  deadlineMs: Math.max(0, deadline - Date.now()),
2635
2767
  fetcher: opts.fetcher,
2768
+ pollIntervalMs: opts.pollIntervalMs,
2769
+ signal: opts.signal,
2636
2770
  onStatus: opts.onStatus,
2637
2771
  });
2638
2772
  for (const { lensId } of inFlight) {
@@ -2682,7 +2816,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2682
2816
  const error = explainBatchError(batch.error);
2683
2817
  lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, ...(error && { error }) };
2684
2818
  }
2685
- await persistBroadsideRun(broadsideDir, run);
2819
+ await persist();
2686
2820
  }
2687
2821
  // #133: re-submit truncated slices once with a bumped output cap and low
2688
2822
  // reasoning effort. Batch requests are pure, so re-running is always safe;
@@ -2700,7 +2834,36 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2700
2834
  // lens pass, still present here (#206). Grouping also keeps the retry to
2701
2835
  // one job per model against OpenRouter's 16-concurrent-job quota.
2702
2836
  let retriedCount = 0;
2703
- if (opts.retryTruncated !== false && truncatedCount > 0) {
2837
+ let retryElsewhere = false;
2838
+ // A collect that polled nothing — every lens already terminal — still owes
2839
+ // the retry if the collect that saved the results never got to it (it
2840
+ // died, or its client did: #322). Read the saved results back and let the
2841
+ // claim decide; a recovered slice re-parses clean, so this costs nothing
2842
+ // once the retry has run.
2843
+ if (opts.retryTruncated !== false && allLensResults.length === 0 && !aborted()) {
2844
+ const everyLensTerminal = run.lenses.every((lensId) => {
2845
+ const entry = run.batches[lensId];
2846
+ return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
2847
+ });
2848
+ if (everyLensTerminal) {
2849
+ const restored = await loadSavedLensResults(runDir, run.lenses);
2850
+ if (restored.some((s) => s.truncated)) {
2851
+ allLensResults.push(...restored);
2852
+ truncatedCount = restored.filter((s) => s.truncated).length;
2853
+ }
2854
+ }
2855
+ }
2856
+ if (opts.retryTruncated !== false && truncatedCount > 0 && !aborted()) {
2857
+ // Claim the pass before spending: a second collect on this run finds the
2858
+ // claim and leaves the retry to the first (#322). A retry another
2859
+ // collect has already settled is not run again — its truncation is
2860
+ // what it is.
2861
+ if (await claimRunSlot(broadsideDir, run, "retry"))
2862
+ owned.add("retry");
2863
+ else if (run.retry?.status === "submitted")
2864
+ retryElsewhere = true;
2865
+ }
2866
+ if (opts.retryTruncated !== false && truncatedCount > 0 && owned.has("retry")) {
2704
2867
  const requestsByCustomId = await loadStoredRequests(runDir);
2705
2868
  const byModel = new Map();
2706
2869
  for (const stored of allLensResults) {
@@ -2730,6 +2893,8 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2730
2893
  // Submit every group, then poll whatever was accepted, together.
2731
2894
  const submitted = [];
2732
2895
  for (const [model, group] of byModel) {
2896
+ if (aborted())
2897
+ break;
2733
2898
  try {
2734
2899
  const { batchId, error } = await submitBatch(group.requests, apiKey, opts.fetcher, model);
2735
2900
  if (!error && batchId)
@@ -2740,6 +2905,15 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2740
2905
  // truncated results in place — nothing is lost.
2741
2906
  }
2742
2907
  }
2908
+ // Record the ids under the claim so a later collect can see what was
2909
+ // paid for, even if this one never returns. No group at all means every
2910
+ // truncated slice was already at its model's ceiling: nothing to retry.
2911
+ run.retry = {
2912
+ ...run.retry,
2913
+ batches: submitted,
2914
+ status: submitted.length > 0 ? "submitted" : byModel.size === 0 ? "completed" : "failed",
2915
+ };
2916
+ await persist();
2743
2917
  const polled = await pollBatchesConcurrently(submitted.map(({ model, batchId }) => ({ lensId: `retry:${model}`, batchId })), apiKey, {
2744
2918
  // Share the caller's deadline. Each of these polls used to start a
2745
2919
  // fresh 25-minute budget, so `wait_seconds` bounded only the lens
@@ -2747,6 +2921,8 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2747
2921
  // minutes.
2748
2922
  deadlineMs: Math.max(0, deadline - Date.now()),
2749
2923
  fetcher: opts.fetcher,
2924
+ pollIntervalMs: opts.pollIntervalMs,
2925
+ signal: opts.signal,
2750
2926
  onStatus: opts.onStatus,
2751
2927
  });
2752
2928
  for (const { model, batchId } of submitted) {
@@ -2774,13 +2950,17 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2774
2950
  retriedCount += 1;
2775
2951
  }
2776
2952
  }
2953
+ // Every retry batch reached a terminal status, or the poll ran out.
2954
+ if (submitted.length > 0 && submitted.every(({ batchId }) => polled.get(batchId)?.status === "completed")) {
2955
+ run.retry = { ...run.retry, status: "completed" };
2956
+ }
2777
2957
  truncatedCount = allLensResults.filter((s) => s.truncated).length;
2778
2958
  for (const [lensId, outcome] of Object.entries(lensOutcomes)) {
2779
2959
  if (outcome.truncated !== undefined) {
2780
2960
  outcome.truncated = allLensResults.filter((s) => s.lensId === lensId && s.truncated).length;
2781
2961
  }
2782
2962
  }
2783
- await persistBroadsideRun(broadsideDir, run);
2963
+ await persist();
2784
2964
  }
2785
2965
  // Synthesis + triage: cross-lens post-passes, only after every lens batch
2786
2966
  // is terminal. Triage turns the leads into a prioritized work order.
@@ -2819,22 +2999,27 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2819
2999
  // Both post-passes consume the same findings; they run as two
2820
3000
  // batches (different response_format schemas cannot share one)
2821
3001
  // submitted together and polled in turn.
2822
- const passes = [
2823
- ...(wantSynthesis && run.synthesis.status === "pending"
2824
- ? [{
2825
- kind: "synthesis",
2826
- request: buildSynthesisRequest(findingsText, truncatedNote, run.model),
2827
- entry: run.synthesis,
2828
- }]
2829
- : []),
2830
- ...(wantTriage && run.triage.status === "pending"
2831
- ? [{
2832
- kind: "triage",
2833
- request: buildTriageRequest(findingsText, truncatedNote, run.model),
2834
- entry: run.triage,
2835
- }]
2836
- : []),
2837
- ];
3002
+ // Claim each wanted, still-pending pass before building its request:
3003
+ // a second collect on this run adopts the first one's entry instead
3004
+ // of submitting its own (#322). An abort submits nothing further.
3005
+ const passes = [];
3006
+ for (const kind of ["synthesis", "triage"]) {
3007
+ const want = kind === "synthesis" ? wantSynthesis : wantTriage;
3008
+ if (!want || aborted())
3009
+ continue;
3010
+ if ((kind === "synthesis" ? run.synthesis : run.triage).status !== "pending")
3011
+ continue;
3012
+ if (!(await claimRunSlot(broadsideDir, run, kind)))
3013
+ continue;
3014
+ owned.add(kind);
3015
+ passes.push({
3016
+ kind,
3017
+ request: kind === "synthesis"
3018
+ ? buildSynthesisRequest(findingsText, truncatedNote, run.model)
3019
+ : buildTriageRequest(findingsText, truncatedNote, run.model),
3020
+ entry: kind === "synthesis" ? run.synthesis : run.triage,
3021
+ });
3022
+ }
2838
3023
  const submitted = new Map();
2839
3024
  // A pass can be left at "submitted" when an earlier collect returned
2840
3025
  // before its batch reached a terminal status — the batch still runs
@@ -2869,16 +3054,22 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2869
3054
  pass.entry.status = "failed";
2870
3055
  }
2871
3056
  }));
2872
- await persistBroadsideRun(broadsideDir, run);
3057
+ await persist();
3058
+ // Poll both passes together against the shared deadline. Polled in
3059
+ // turn, the first pass could spend the whole budget and leave the
3060
+ // second a single poll (0.22.1 live run: triage settled, synthesis
3061
+ // left running though it had been submitted at the same moment).
3062
+ // A pass whose poll runs out stays `submitted`, so the batch is
3063
+ // already paid for and a later collect claims its result.
3064
+ const polledPasses = await pollBatchesConcurrently([...submitted.values()].map(({ batchId, pass }) => ({ lensId: pass.kind, batchId })), apiKey, {
3065
+ deadlineMs: Math.max(0, deadline - Date.now()),
3066
+ fetcher: opts.fetcher,
3067
+ pollIntervalMs: opts.pollIntervalMs,
3068
+ signal: opts.signal,
3069
+ onStatus: opts.onStatus,
3070
+ });
2873
3071
  for (const { batchId, pass } of submitted.values()) {
2874
- const batch = await pollBatchUntilTerminal(batchId, apiKey, {
2875
- // Shares the caller's deadline, as the retry poll above does.
2876
- // A pass whose poll runs out stays `submitted`, so the batch
2877
- // is already paid for and a later collect claims its result.
2878
- deadlineMs: Math.max(0, deadline - Date.now()),
2879
- onStatus: (status, counts) => opts.onStatus?.(pass.kind, status, counts),
2880
- fetcher: opts.fetcher,
2881
- });
3072
+ const batch = polledPasses.get(batchId) ?? { id: batchId, status: "timeout" };
2882
3073
  if (batch.status === "completed") {
2883
3074
  const usage = (batch.usage ?? {});
2884
3075
  const cost = typeof usage.cost === "number" ? usage.cost : undefined;
@@ -2911,7 +3102,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2911
3102
  // A "timeout" is deliberately left at "submitted": the batch is
2912
3103
  // still running server-side and has already been paid for, so a
2913
3104
  // later collect should claim its result rather than discard it.
2914
- await persistBroadsideRun(broadsideDir, run);
3105
+ await persist();
2915
3106
  }
2916
3107
  }
2917
3108
  }
@@ -2921,7 +3112,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2921
3112
  });
2922
3113
  run.status = terminal ? (resultCount > 0 ? "completed" : "failed") : "partial";
2923
3114
  run.totalCost = totalCost;
2924
- await persistBroadsideRun(broadsideDir, run);
3115
+ await persist();
2925
3116
  await writeFile(join(runDir, "run-meta.json"), `${JSON.stringify({
2926
3117
  experimental: true,
2927
3118
  method: "Broad-Side (OpenRouter Batch API)",
@@ -2953,6 +3144,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2953
3144
  resultCount,
2954
3145
  truncatedCount,
2955
3146
  retriedCount,
3147
+ ...(retryElsewhere && { retryElsewhere: true }),
2956
3148
  lensOutcomes,
2957
3149
  synthesis: run.synthesis,
2958
3150
  triage: run.triage,
@@ -3220,9 +3412,24 @@ export function collectResultText(result) {
3220
3412
  if (result.retriedCount > 0) {
3221
3413
  lines.push(` ↻ ${result.retriedCount} truncated result(s) recovered by re-submission with a doubled output cap.`);
3222
3414
  }
3415
+ if (result.retryElsewhere) {
3416
+ lines.push(" ↻ The truncation retry is in flight in another collect on this run; collect again for its result.");
3417
+ }
3223
3418
  if (result.truncatedCount > 0) {
3224
3419
  lines.push(` ⚠ ${result.truncatedCount} result(s) still truncated after retry — their modules are unscouted, not clean.`);
3225
3420
  }
3421
+ // A pass still in flight or retired must appear: a run reported
3422
+ // "completed" with no synthesis line read as "no synthesis was run",
3423
+ // when the batch was running and a later collect would have claimed it
3424
+ // (0.22.1 live run — the collect's wait ran out during the pass).
3425
+ const passInFlight = (kind, entry) => {
3426
+ if (entry.status === "submitted") {
3427
+ lines.push(` ${kind}: ${entry.batchId ? "still running" : "in flight in another collect"} — collect again for its result.`);
3428
+ }
3429
+ else if (entry.status === "failed") {
3430
+ lines.push(` ${kind}: failed${entry.error ? ` — ${explainBatchError(entry.error)}` : ""}`);
3431
+ }
3432
+ };
3226
3433
  if (result.synthesis.status === "completed") {
3227
3434
  lines.push(` synthesis: completed, $${(result.synthesis.cost ?? 0).toFixed(6)}`);
3228
3435
  if (result.topFindings.length > 0) {
@@ -3232,6 +3439,9 @@ export function collectResultText(result) {
3232
3439
  }
3233
3440
  }
3234
3441
  }
3442
+ else {
3443
+ passInFlight("synthesis", result.synthesis);
3444
+ }
3235
3445
  if (result.triage.status === "completed") {
3236
3446
  lines.push(` triage: completed, $${(result.triage.cost ?? 0).toFixed(6)}`);
3237
3447
  if (result.topTriageItems.length > 0) {
@@ -3242,8 +3452,8 @@ export function collectResultText(result) {
3242
3452
  }
3243
3453
  }
3244
3454
  }
3245
- else if (result.triage.status === "failed") {
3246
- lines.push(" triage: failed");
3455
+ else {
3456
+ passInFlight("triage", result.triage);
3247
3457
  }
3248
3458
  lines.push("", "Disclaimer: Broad-Side findings are unverified scouting signals from a batch model, not validated claims.");
3249
3459
  return lines.join("\n");
@@ -1151,6 +1151,7 @@ export async function handleBroadside(args) {
1151
1151
  includeSynthesis,
1152
1152
  includeTriage,
1153
1153
  retryTruncated,
1154
+ signal: serverLifetime?.signal,
1154
1155
  // One line per *change* of a lens's status. Every poll used to
1155
1156
  // append a line, so a four-minute wait returned twenty-six
1156
1157
  // "in_progress (0/1)" lines before the result (0.22.0 live run).
@@ -1179,6 +1180,7 @@ export async function handleBroadside(args) {
1179
1180
  includeSynthesis,
1180
1181
  includeTriage,
1181
1182
  retryTruncated,
1183
+ signal: serverLifetime?.signal,
1182
1184
  ...(runId && { runId }),
1183
1185
  }).catch((error) => {
1184
1186
  throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
@@ -1518,7 +1520,7 @@ const TOOLS = [
1518
1520
  },
1519
1521
  wait_seconds: {
1520
1522
  type: "number",
1521
- description: "For submit: after submitting, poll up to this many seconds before returning. For collect: poll up to this many seconds before returning with partial state. 0 polls each in-flight batch once and returns without waiting. Falls back to wait_seconds in .codecarto/broadside/config.yaml (default 0).",
1523
+ description: "For submit: after submitting, poll up to this many seconds before returning. For collect: poll up to this many seconds before returning with partial state. 0 polls each in-flight batch once and returns without waiting. Falls back to wait_seconds in .codecarto/broadside/config.yaml (default 0). The wait is also bounded by the host's own tool-call timeout: if the host gives up first, the server stops polling and submits nothing further, the batches keep running server-side, and the next collect claims them — so prefer submit, then collect later, over a wait longer than the host allows.",
1522
1524
  },
1523
1525
  include_synthesis: {
1524
1526
  type: "boolean",
@@ -1617,8 +1619,27 @@ export function buildServer() {
1617
1619
  });
1618
1620
  return server;
1619
1621
  }
1622
+ /**
1623
+ * Fires when the stdio client goes away, so a Broad-Side wait that outlived
1624
+ * the request that asked for it stops polling and submits nothing further
1625
+ * (#322). Batches already accepted keep running server-side; the next collect
1626
+ * claims them. Set only by {@link startStdioServer}; handlers driven directly
1627
+ * (tests, in-process callers) see no signal.
1628
+ */
1629
+ let serverLifetime = null;
1620
1630
  export async function startStdioServer() {
1621
1631
  const server = buildServer();
1622
1632
  const transport = new StdioServerTransport();
1633
+ serverLifetime = new AbortController();
1634
+ const lifetime = serverLifetime;
1635
+ server.onclose = () => lifetime.abort();
1636
+ // The SDK's stdio transport listens for stdin `data` and `error` only — it
1637
+ // never sees the end of the stream — so a client that exits mid-request
1638
+ // leaves the server polling with nobody to answer to (#322, observed: a
1639
+ // server outlived its client by fifteen minutes and submitted two paid
1640
+ // post-passes on its own). End of stdin is the client going away.
1641
+ const gone = () => lifetime.abort();
1642
+ process.stdin.once("end", gone);
1643
+ process.stdin.once("close", gone);
1623
1644
  await server.connect(transport);
1624
1645
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.22.1",
3
+ "version": "0.22.2",
4
4
  "mcpName": "io.github.HuginnIndustries/codecartographer",
5
5
  "description": "Turn an unfamiliar codebase into a validated reimplementation spec, then synthesize confirmed specs and a product vision into a traceable plan.",
6
6
  "type": "module",