@open-cr-agent/core 0.1.0 → 0.1.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.
@@ -1,5 +1,8 @@
1
1
  import type { Usage } from "../contracts.js";
2
2
  export declare const REVIEW_BUDGET_SHARE = 0.8;
3
+ export declare class SpendLimitReached extends Error {
4
+ constructor(maxCostUsd: number);
5
+ }
3
6
  export interface SpendTracker {
4
7
  reviewExhausted(): boolean;
5
8
  exhausted(): boolean;
@@ -2,9 +2,17 @@
2
2
  // and Judge, so the run that hits the limit still gets its findings checked.
3
3
  // Those stages are one short call per file with findings plus one top-tier
4
4
  // call, while every agent step resends a task's whole conversation, so a fifth
5
- // covers them in typical runs. The reserve also absorbs review tasks that were
6
- // already running when their share ran out.
5
+ // covers them in typical runs. Review tasks still running when their share
6
+ // runs out are stopped; what they spend while stopping (the step in flight,
7
+ // and whatever the runtime had not reported yet) comes out of the reserve.
7
8
  export const REVIEW_BUDGET_SHARE = 0.8;
9
+ // Why running review tasks were stopped.
10
+ export class SpendLimitReached extends Error {
11
+ constructor(maxCostUsd) {
12
+ super(`stopped at the spend limit of $${maxCostUsd}`);
13
+ this.name = "SpendLimitReached";
14
+ }
15
+ }
8
16
  export function spendTracker(maxCostUsd, initial) {
9
17
  let spent = initial.reduce((sum, u) => sum + u.costUsd, 0);
10
18
  const over = (limit) => maxCostUsd !== undefined && spent >= limit;
@@ -19,6 +19,7 @@ export interface ExecuteOptions {
19
19
  relocate?: AnchorContext["relocate"] | undefined;
20
20
  plans?: Map<string, Promise<PlannedBundle>>;
21
21
  emit: (event: ReviewEvent) => void;
22
+ onUsage?: ((usage: Usage) => void) | undefined;
22
23
  signal: AbortSignal;
23
24
  }
24
25
  type PlannedBundle = Awaited<ReturnType<typeof planBundle>>;
@@ -43,6 +43,8 @@ export async function runJob(job, plan, options) {
43
43
  // The sample that made the call pays for it and reports its warning.
44
44
  if (!shared) {
45
45
  extraUsage.push(...planned.usage);
46
+ for (const usage of planned.usage)
47
+ options.onUsage?.(usage);
46
48
  if (planned.warning)
47
49
  extraWarnings.push(planned.warning);
48
50
  }
@@ -66,6 +68,7 @@ export async function runJob(job, plan, options) {
66
68
  timeoutMs: options.taskTimeoutMs,
67
69
  }, options.signal, {
68
70
  onProgress: (message) => emit({ type: "task_progress", taskId: job.taskId, message }),
71
+ onUsage: options.onUsage,
69
72
  category: job.reviewer.category,
70
73
  abortGraceMs: options.abortGraceMs,
71
74
  });
@@ -6,7 +6,7 @@ import { priorCodePresence } from "../rereview/presence.js";
6
6
  import { reconcile, stillOpen } from "../rereview/reconcile.js";
7
7
  import { correctnessReviewer } from "../review/reviewers/correctness.js";
8
8
  import { markUnchecked, verifyFindings } from "../verify/verify.js";
9
- import { spendTracker } from "./budget.js";
9
+ import { SpendLimitReached, spendTracker } from "./budget.js";
10
10
  import { runJob } from "./execute.js";
11
11
  import { dedupeFindings } from "./findings.js";
12
12
  import { DEFAULT_MAX_TASKS, planTasks } from "./matrix.js";
@@ -52,6 +52,15 @@ export async function runReview(options) {
52
52
  relocationUsage.push(u);
53
53
  budget.add(u);
54
54
  }));
55
+ // Tasks report spend while they run, so the one that uses up the review
56
+ // share stops every task still running, not only the ones not yet started.
57
+ const spendLimit = new AbortController();
58
+ const spend = (usage) => {
59
+ budget.add(usage);
60
+ if (budget.reviewExhausted() && !spendLimit.signal.aborted) {
61
+ spendLimit.abort(new SpendLimitReached(options.maxCostUsd ?? 0));
62
+ }
63
+ };
55
64
  const execute = {
56
65
  runtime: options.runtime,
57
66
  taskTimeoutMs: options.taskTimeoutMs ?? DEFAULTS.taskTimeoutMs,
@@ -62,7 +71,8 @@ export async function runReview(options) {
62
71
  ultra: options.ultra === true,
63
72
  plans: new Map(),
64
73
  emit,
65
- signal,
74
+ onUsage: spend,
75
+ signal: AbortSignal.any([signal, spendLimit.signal]),
66
76
  };
67
77
  const results = await mapWithConcurrency(matrix.cells, options.concurrency ?? DEFAULTS.concurrency, async (cell) => {
68
78
  // Once the run is cancelled or timed out, remaining cells are not started.
@@ -71,9 +81,7 @@ export async function runReview(options) {
71
81
  if (budget.reviewExhausted()) {
72
82
  return skipCell(cell, `spend limit of $${options.maxCostUsd} reached`, emit);
73
83
  }
74
- const result = await runJob(cell, plan, execute);
75
- budget.add(result.usage);
76
- return result;
84
+ return runJob(cell, plan, execute);
77
85
  });
78
86
  // Memory and the previous review filter first, so Verify and Judge are not
79
87
  // paid for findings that will not be reported, and a person's dismissal
@@ -10,6 +10,7 @@ export interface TaskResult {
10
10
  }
11
11
  export interface TaskCallbacks {
12
12
  onProgress(message: string): void;
13
+ onUsage?: ((usage: Usage) => void) | undefined;
13
14
  category: string;
14
15
  abortGraceMs?: number | undefined;
15
16
  }
@@ -1,5 +1,6 @@
1
1
  import { reportedFindingSchema } from "../domain.js";
2
2
  import { errorMessage } from "../errors.js";
3
+ import { SpendLimitReached } from "./budget.js";
3
4
  import { addUsage, emptyUsage } from "./usage.js";
4
5
  export async function executeTask(runtime, spec, runSignal, callbacks) {
5
6
  const timeout = AbortSignal.timeout(spec.timeoutMs);
@@ -30,9 +31,20 @@ export async function executeTask(runtime, spec, runSignal, callbacks) {
30
31
  if (signal.aborted) {
31
32
  const timedOut = timeout.aborted && !runSignal.aborted;
32
33
  result.status = timedOut ? "timed_out" : "cancelled";
33
- result.error = timedOut ? `timed out after ${spec.timeoutMs}ms` : "run cancelled";
34
- if (iterator)
35
- await collectAfterAbort(iterator, pending, result, callbacks);
34
+ result.error = timedOut
35
+ ? `timed out after ${spec.timeoutMs}ms`
36
+ : runSignal.reason instanceof SpendLimitReached
37
+ ? runSignal.reason.message
38
+ : "run cancelled";
39
+ const ending = iterator
40
+ ? await collectAfterAbort(iterator, pending, result, callbacks)
41
+ : undefined;
42
+ // The task's last usage report crossed the limit, and it finished
43
+ // anyway: its review is complete.
44
+ if (ending === "done" && runSignal.reason instanceof SpendLimitReached) {
45
+ result.status = "completed";
46
+ delete result.error;
47
+ }
36
48
  void iterator?.return?.();
37
49
  }
38
50
  else {
@@ -54,18 +66,19 @@ async function collectAfterAbort(iterator, pending, result, callbacks) {
54
66
  for (;;) {
55
67
  const event = await untilAborted(next, grace);
56
68
  if (event.done)
57
- return;
69
+ return undefined;
58
70
  const { value } = event;
59
71
  if (value.type === "usage" || value.type === "finding")
60
72
  handle(value, result, callbacks);
61
73
  if (value.type === "done" || value.type === "error")
62
- return;
74
+ return value.type;
63
75
  next = iterator.next();
64
76
  }
65
77
  }
66
78
  catch {
67
79
  // The runtime did not finish within the grace period, or failed: keep
68
80
  // what arrived.
81
+ return undefined;
69
82
  }
70
83
  }
71
84
  function handle(event, result, callbacks) {
@@ -88,9 +101,12 @@ function handle(event, result, callbacks) {
88
101
  }
89
102
  return false;
90
103
  }
91
- case "usage":
92
- result.usage = addUsage(result.usage, event);
104
+ case "usage": {
105
+ const { type: _type, taskId: _taskId, ...usage } = event;
106
+ result.usage = addUsage(result.usage, usage);
107
+ callbacks.onUsage?.(usage);
93
108
  return false;
109
+ }
94
110
  case "done":
95
111
  return true;
96
112
  case "error":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/core",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Domain types, review pipeline stages and plugin contracts for Open-CR-Agent",
5
5
  "keywords": [
6
6
  "code-review",