@saadnvd1/agent-os 0.3.470 → 0.3.471

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.
@@ -29,7 +29,11 @@ import {
29
29
  } from "../store";
30
30
  import { claimNext, enqueue, listQueue, moveToFront } from "../queued";
31
31
  import { isHeld } from "../hold";
32
- import { recordTurn, startingTotals } from "../../usage/turns";
32
+ import {
33
+ recordCompaction,
34
+ recordTurn,
35
+ startingTotals,
36
+ } from "../../usage/turns";
33
37
  import {
34
38
  VISUALS_BRIEF,
35
39
  VISUALS_SERVER,
@@ -37,7 +41,7 @@ import {
37
41
  } from "../../artifacts/tools";
38
42
  import type { RoleExtras } from "./extras";
39
43
  import { refreshSettings, refreshedNote } from "../refresh";
40
- import { staleLabel, staleness, type Staleness } from "../stale";
44
+ import { compactionDue, staleLabel, type Staleness } from "../stale";
41
45
  import type { WorkerCommand, WorkerEvent } from "./protocol";
42
46
 
43
47
  // How soon after a stopped turn ends a turn the agent starts on its own
@@ -127,15 +131,17 @@ export class ChatHost {
127
131
  private contextBase: number | null = null;
128
132
  // How full the conversation was at its last turn's end (lib/chat/stale).
129
133
  private context: ChatContext | null = null;
130
- // A compaction run ahead of a message to a stale chat: the message waits
131
- // at the front of the queue until it ends (compactFirst).
134
+ // A compaction run ahead of a message (compactFirst): the message waits
135
+ // at the front of the queue until it ends.
132
136
  private compacting: {
133
- idleMs: number;
137
+ // Set when it ran because the chat was stale.
138
+ idleMs?: number;
134
139
  before: number;
135
140
  done?: boolean;
136
141
  } | null = null;
137
142
  // Its line, until the agent's next measure gives the size after.
138
143
  private compactedLine: Extract<ChatItem, { kind: "compacted" }> | null = null;
144
+ private compactedBy = "threshold";
139
145
  // An orchestrator gone stale starts over rather than compacting.
140
146
  private staleRefresh: Staleness | null = null;
141
147
  readonly done: Promise<void>;
@@ -253,8 +259,10 @@ export class ChatHost {
253
259
  // a failed one printed still shows.
254
260
  if (this.compacting?.done && e.item.kind === "command_output")
255
261
  continue;
256
- if (this.compacting && e.item.kind === "compacted")
257
- e.item = this.compactedItem(e.item);
262
+ if (e.item.kind === "compacted") {
263
+ if (this.compacting) e.item = this.compactedItem(e.item);
264
+ else this.logCompacted(e.item, e.item.trigger ?? "manual");
265
+ }
258
266
  if ("streaming" in e.item && e.item.streaming)
259
267
  this.streaming.set(e.item.id, e.item);
260
268
  else this.streaming.delete(e.item.id);
@@ -663,15 +671,19 @@ export class ChatHost {
663
671
  this.close();
664
672
  }
665
673
 
666
- // A stale chat (lib/chat/stale) is compacted before the message goes in,
667
- // only between turns: the message waits at the front of the queue, and
668
- // the compaction's end sends it. "yes" is the reader's choice, made on
669
- // what the composer showed; "auto" checks for itself.
674
+ // A chat over the compact threshold or stale (lib/chat/stale) is
675
+ // compacted before the message goes in, only between turns: the message
676
+ // waits at the front of the queue, and the compaction's end sends it.
677
+ // "yes" is the reader's choice, made on what the composer showed; "auto"
678
+ // checks for itself.
670
679
  private compactFirst(cmd: Extract<WorkerCommand, { type: "send" }>): boolean {
671
680
  if (!cmd.compact || this.busy() || this.closed || this.handingOver())
672
681
  return false;
673
682
  const stale = this.staleNow(cmd.compact);
674
- if (!stale) return false;
683
+ // An orchestrator starts over at its own threshold (lib/chat/refresh);
684
+ // only one gone stale does it early.
685
+ if (!stale || (this.session.role === "orchestrator" && !stale.stale))
686
+ return false;
675
687
  try {
676
688
  enqueue(this.session.id, cmd);
677
689
  } catch {
@@ -692,13 +704,16 @@ export class ChatHost {
692
704
  this.retireIfDone();
693
705
  return true;
694
706
  }
695
- this.compacting = { idleMs: stale.idleMs, before: stale.tokens };
707
+ this.compacting = {
708
+ idleMs: stale.stale ? stale.idleMs : undefined,
709
+ before: stale.tokens,
710
+ };
696
711
  this.currentTurn = null;
697
712
  this.stopping = null;
698
713
  this.conversation.send("/compact");
699
714
  this.setState("running");
700
715
  console.error(
701
- `${new Date().toISOString()} compacting ${this.session.id} before a message: idle ${Math.round(stale.idleMs / 60_000)}m, ${stale.tokens} tokens`
716
+ `${new Date().toISOString()} compacting ${this.session.id} before a message: ${stale.stale ? `idle ${Math.round(stale.idleMs / 60_000)}m, ` : ""}${stale.tokens} tokens`
702
717
  );
703
718
  return true;
704
719
  }
@@ -707,11 +722,17 @@ export class ChatHost {
707
722
  const context = this.context;
708
723
  // Only an agent that says which cache it uses has one to lose.
709
724
  if (!context?.cacheTtlMs) return null;
710
- if (compact === "yes")
711
- return { idleMs: Date.now() - context.at, tokens: context.usedTokens };
712
- return staleness(context, {
725
+ if (compact === "yes") {
726
+ const idleMs = Date.now() - context.at;
727
+ return {
728
+ idleMs,
729
+ tokens: context.usedTokens,
730
+ stale: idleMs > context.cacheTtlMs,
731
+ };
732
+ }
733
+ return compactionDue(context, {
713
734
  state: this.state,
714
- thresholdTokens: refreshSettings().staleCompactTokens,
735
+ thresholdTokens: refreshSettings().compactTokens,
715
736
  });
716
737
  }
717
738
 
@@ -724,8 +745,9 @@ export class ChatHost {
724
745
  idleMs: this.compacting?.idleMs,
725
746
  before: item.before ?? this.compacting?.before,
726
747
  };
748
+ this.compactedBy = line.idleMs !== undefined ? "stale" : "threshold";
727
749
  this.compactedLine = line.after === undefined ? line : null;
728
- if (line.after !== undefined) this.logCompacted(line);
750
+ if (line.after !== undefined) this.logCompacted(line, this.compactedBy);
729
751
  return line;
730
752
  }
731
753
 
@@ -736,13 +758,26 @@ export class ChatHost {
736
758
  this.compactedLine = null;
737
759
  const settled = { ...line, after: usedTokens };
738
760
  this.record(settled);
739
- this.logCompacted(settled);
761
+ this.logCompacted(settled, this.compactedBy);
740
762
  }
741
763
 
742
- private logCompacted(line: { before?: number; after?: number }): void {
764
+ // Logged, and kept for the usage history (lib/usage/turns).
765
+ private logCompacted(
766
+ line: { before?: number; after?: number },
767
+ trigger: string
768
+ ): void {
743
769
  console.error(
744
- `${new Date().toISOString()} compacted ${this.session.id}: ${line.before ?? "?"} -> ${line.after ?? "?"} tokens`
770
+ `${new Date().toISOString()} compacted ${this.session.id} (${trigger}): ${line.before ?? "?"} -> ${line.after ?? "?"} tokens`
745
771
  );
772
+ try {
773
+ recordCompaction(this.session, {
774
+ trigger,
775
+ before: line.before,
776
+ after: line.after,
777
+ });
778
+ } catch (e) {
779
+ console.error(`recording ${this.session.id}'s compaction:`, e);
780
+ }
746
781
  }
747
782
 
748
783
  // Past its threshold, once: it retires at the next turn boundary, as for
@@ -1358,6 +1358,48 @@ const migrations: Migration[] = [
1358
1358
  `);
1359
1359
  },
1360
1360
  },
1361
+ {
1362
+ id: 60,
1363
+ name: "check_runs_and_compactions",
1364
+ up: (db) => {
1365
+ // What each run of the independent reviewer and the scope check cost,
1366
+ // from its claude -p result: it leaves no transcript, so this is the
1367
+ // only record. And each compaction a chat's agent made, so tokens per
1368
+ // task can be compared before and after the threshold moved.
1369
+ db.exec(`
1370
+ CREATE TABLE IF NOT EXISTS check_runs (
1371
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1372
+ workspace_id TEXT NOT NULL,
1373
+ session_id TEXT NOT NULL,
1374
+ label TEXT NOT NULL,
1375
+ pr_number INTEGER,
1376
+ sha TEXT NOT NULL,
1377
+ kind TEXT NOT NULL,
1378
+ model TEXT,
1379
+ input_tokens INTEGER NOT NULL,
1380
+ cache_write_tokens INTEGER NOT NULL,
1381
+ cache_read_tokens INTEGER NOT NULL,
1382
+ output_tokens INTEGER NOT NULL,
1383
+ cost_usd REAL NOT NULL,
1384
+ duration_ms INTEGER,
1385
+ at INTEGER NOT NULL
1386
+ );
1387
+ CREATE INDEX IF NOT EXISTS idx_check_runs_at ON check_runs(at);
1388
+ CREATE INDEX IF NOT EXISTS idx_check_runs_sha ON check_runs(session_id, sha, kind);
1389
+ CREATE TABLE IF NOT EXISTS chat_compactions (
1390
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1391
+ session_id TEXT NOT NULL,
1392
+ session_name TEXT NOT NULL,
1393
+ workspace_id TEXT,
1394
+ at INTEGER NOT NULL,
1395
+ trigger TEXT NOT NULL,
1396
+ before_tokens INTEGER,
1397
+ after_tokens INTEGER
1398
+ );
1399
+ CREATE INDEX IF NOT EXISTS idx_chat_compactions_at ON chat_compactions(at);
1400
+ `);
1401
+ },
1402
+ },
1361
1403
  ];
1362
1404
 
1363
1405
  // `upTo`: stop after this id (tests that start from an older database).
@@ -126,8 +126,7 @@ function fingerprint(
126
126
  (f.task?.ciSettleIn ?? 0) > 0,
127
127
  getCheck(task.id, head, "review")?.status,
128
128
  getCheck(task.id, head, "scope")?.status,
129
- pr.codeReview?.sha,
130
- pr.codeReview?.attribution,
129
+ pr.attribution,
131
130
  f.task?.blocked,
132
131
  f.status,
133
132
  f.needsInput,
@@ -41,16 +41,16 @@ You may act without asking on anything inside this workspace that's additive or
41
41
  Merge only through the gates: \`arm\` or \`sign_off\`. \`sign_off\` merges a task's PR, with the project's merge method (squash unless set otherwise), only if all of these hold, and otherwise refuses naming the gate:
42
42
  - ci: CI is green on the PR's head commit and has settled (the commit is 2 minutes old and no new check has appeared for 2 minutes). A repository with no CI goes to Saad.
43
43
  - review: an independent review of that exact commit passed. Run \`review\` on the task; it starts a fresh read-only reviewer in the background and its verdict reaches you as an event. A new commit needs a new review. The reviewer judges the PR against the task's brief plus every scope change you recorded on it with \`send\` and scope_change; a "Scope change" note in the PR body counts only where one of those backs it. When Saad changes a running task's scope, always tell the task that way.
44
- - code-review: the PR body has a "Code review" section (from the task's own \`/do-code-review\`) whose "Reviewed:" line names that exact commit, or the commit AgentOS restacked or rebased into it while the head is still the one AgentOS left. A task that pushed after its review has to review again and update the body; tell it so.
44
+ - code-review: a passing review (the gate above) covers that exact commit, or the commit AgentOS restacked or rebased into it while the head is still the one AgentOS left, and the PR body carries no AI attribution. Tasks don't review themselves or write a "Code review" section any more: your review is the one review, so don't ask a task for one.
45
45
  - blocked: no BLOCKED: line and no approval or question waiting in the task's chat or terminal.
46
46
  - scope: the diff stays in the task's repository, adds no secrets, isn't only lockfiles, and, for a task from a card, a check against the card (run with the review) says it's within what the card asks.
47
47
  - stack: a stacked task's parent has merged.
48
48
 
49
- PRs AgentOS didn't open (dispatch on another machine, a person, dependabot) go through the same \`sign_off\` and \`review\`: name one by #N, owner/repo#N or its URL. It must be open, in the repository of one of this workspace's projects (anything else is refused), and not from a fork. It's an external PR: there's no task or session behind it, so the gates read it differently. blocked: a draft isn't ready yet, and a label like "blocked", "do not merge" or "wip" holds it. code-review: a body with no "Code review" section is fine once your \`review\` of that exact head passes (dispatch writes its review up its own way); a section it does have must name the head, like a task's. ci, review, scope, Saad's approvals and his hold are the same as for a task, and a stack never applies. A PR that turns out to be a task's is gated as that task.
49
+ PRs AgentOS didn't open (dispatch on another machine, a person, dependabot) go through the same \`sign_off\` and \`review\`: name one by #N, owner/repo#N or its URL. It must be open, in the repository of one of this workspace's projects (anything else is refused), and not from a fork. It's an external PR: there's no task or session behind it, so the gates read it differently. blocked: a draft isn't ready yet, and a label like "blocked", "do not merge" or "wip" holds it. code-review: your \`review\` of that exact head is what counts, as for a task; a "Code review" section in its body is ignored. ci, review, scope, Saad's approvals and his hold are the same as for a task, and a stack never applies. A PR that turns out to be a task's is gated as that task.
50
50
 
51
51
  Arm a task once its work is what you want merged, rather than calling \`sign_off\` and waiting: \`arm\` makes AgentOS merge it the moment every gate passes at its head (it starts the review itself once CI is green), and you hear one event, "merged" or the failure. A failure counts like sign_off's, once per head, and the task stays armed for the fix; one that goes to Saad disarms it, as does an open ask_saad about that task (it merges on his answer). Don't poll an armed task with sign_off. \`disarm\` takes it back.
52
52
 
53
- When the base moves and a task's PR conflicts with it, AgentOS rebases it onto the base by itself if that's clean and its agent is idle with nothing uncommitted, pushes only the task's own branch, and tells you with one event. The task's Code review section still covers the rebased head, and your review of the old head carries over when the PR's own patch is unchanged (a fresh one starts otherwise). If it conflicts, the task is asked once to rebase and you get one event; don't send it the same request.
53
+ When the base moves and a task's PR conflicts with it, AgentOS rebases it onto the base by itself if that's clean and its agent is idle with nothing uncommitted, pushes only the task's own branch, and tells you with one event. Your review of the old head carries over when the PR's own patch is unchanged (a fresh one starts otherwise). If it conflicts, the task is asked once to rebase and you get one event; don't send it the same request.
54
54
 
55
55
  Tasks are woken by AgentOS itself when a check fails on their PR's head (with the failing step and the log around the error) and when a collaborator reviews or comments on their PR, and you get one "task X woken: ..." line for each. Don't message a task about the same failure or review; act only if it doesn't fix it.
56
56
 
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { spawn } from "child_process";
12
+ import { runUsageOf, type RunUsage } from "../usage/check-runs";
12
13
 
13
14
  export interface ClaudeRun {
14
15
  cwd: string;
@@ -22,6 +23,8 @@ export interface ClaudeRun {
22
23
  timeoutMs?: number;
23
24
  // A model alias or id; the review model (or the CLI's default) otherwise.
24
25
  model?: string;
26
+ // What the run cost, from its result, even when it failed.
27
+ onUsage?: (usage: RunUsage) => void;
25
28
  }
26
29
 
27
30
  export type ClaudeRunner = (run: ClaudeRun) => Promise<unknown>;
@@ -115,6 +118,8 @@ export const runClaude: ClaudeRunner = (run) =>
115
118
  result?: string;
116
119
  structured_output?: unknown;
117
120
  };
121
+ const usage = runUsageOf(result);
122
+ if (usage) run.onUsage?.(usage);
118
123
  if (result.is_error || result.structured_output === undefined)
119
124
  throw new Error(result.result || "No answer");
120
125
  resolve(result.structured_output);
@@ -93,7 +93,7 @@ const SENSITIVE: { why: string; test: RegExp }[] = [
93
93
  // What merges check a PR's code review with: a change that weakens it
94
94
  // must not merge through it.
95
95
  why: "the code review gate",
96
- test: /^(lib\/tasks\/code-review\.ts|scripts\/check-code-review\.ts)$/i,
96
+ test: /^lib\/tasks\/code-review\.ts$/i,
97
97
  },
98
98
  {
99
99
  why: "secrets handling",
@@ -12,11 +12,11 @@
12
12
 
13
13
  import { MERGED_AS, mergePolicy } from "../tasks/merge-policy";
14
14
  import { mergePR } from "../tasks/merge-pr";
15
- import { attributionIn, codeReviewRefusal } from "../tasks/code-review";
15
+ import { attributionIn, codeReviewGate } from "../tasks/code-review";
16
16
  import { run } from "../tasks/gh";
17
17
  import { refundApproval, spendApproval } from "./ask-approvals";
18
18
  import { resolveAsks } from "./asks";
19
- import { clearStaleRunning, getCheck, type CheckRow } from "./checks";
19
+ import { clearStaleRunning, getCheck } from "./checks";
20
20
  import { gateTarget, type ExternalPR } from "./external-pr";
21
21
  import { holdingEscalations } from "./gates";
22
22
  import { addNote } from "./notes";
@@ -39,44 +39,6 @@ const refsOf = (ext: ExternalPR) => ({
39
39
  branch_name: ext.branch,
40
40
  });
41
41
 
42
- // The code-review gate: a section in the body must cover the head like a
43
- // task's; with none, the independent review of the head stands in.
44
- export function externalCodeReview(
45
- ext: Pick<ExternalPR, "name" | "body" | "pr">,
46
- sha: string,
47
- review: CheckRow | null
48
- ): { refusal: string | null; wait: string | null } {
49
- const attribution = attributionIn(ext.body);
50
- if (attribution)
51
- return {
52
- refusal: `the PR body carries AI attribution (${attribution}): remove it with \`gh pr edit --body-file\``,
53
- wait: null,
54
- };
55
- const section = ext.pr.codeReview;
56
- if (section !== null)
57
- return {
58
- refusal: codeReviewRefusal(
59
- section,
60
- sha,
61
- null,
62
- `${ext.name}'s "Code review" section has to name ${short(sha)} on a "Reviewed:" line, or come out of the body`
63
- ),
64
- wait: null,
65
- };
66
- const none = `${ext.name}'s body has no "Code review" section; for a PR AgentOS didn't open, the orchestrator's own review of ${short(sha)} stands in for it`;
67
- if (review?.sha === sha && review.status === "pass")
68
- return { refusal: null, wait: null };
69
- return {
70
- refusal: null,
71
- wait:
72
- review?.sha === sha && review.status === "running"
73
- ? `${none}, and it's still running`
74
- : review?.sha === sha && review.status === "block"
75
- ? `${none}, and it has blocking findings`
76
- : `${none}: call review`,
77
- };
78
- }
79
-
80
42
  export async function judgeExternal(
81
43
  workspaceId: string,
82
44
  ext: ExternalPR,
@@ -102,7 +64,6 @@ export async function judgeExternal(
102
64
  },
103
65
  async () => {
104
66
  const review = getCheck(ext.id, sha, "review");
105
- const code = externalCodeReview(ext, sha, review);
106
67
  const label = ext.labels.find((l) => BLOCKED_LABEL.test(l));
107
68
  return {
108
69
  checks: pr.checks,
@@ -124,8 +85,12 @@ export async function judgeExternal(
124
85
  fromCard: false,
125
86
  scope: null,
126
87
  stackRefusal: null,
127
- codeReviewRefusal: code.refusal,
128
- codeReviewWait: code.wait,
88
+ // The same gate as a task's: the review of this exact head.
89
+ codeReview: codeReviewGate({
90
+ head: sha,
91
+ attribution: attributionIn(ext.body) ?? null,
92
+ review: (at) => getCheck(ext.id, at, "review"),
93
+ }),
129
94
  };
130
95
  },
131
96
  opts
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { db } from "../db";
14
+ import type { CodeReviewGate } from "../tasks/code-review";
14
15
  import type { ChecksVerdict } from "../tasks/state";
15
16
  import type { CheckRow } from "./checks";
16
17
  import { APPROVAL_GATES, mergeApprovalsOn } from "./merge-approvals";
@@ -45,11 +46,9 @@ export interface GateInput {
45
46
  fromCard: boolean;
46
47
  scope: CheckRow | null;
47
48
  stackRefusal: string | null;
48
- // Why the PR body's Code review section doesn't cover this commit.
49
- codeReviewRefusal: string | null;
50
- // Why it isn't covered yet but will be (an external PR whose own review
51
- // of this commit stands in for the section).
52
- codeReviewWait?: string | null;
49
+ // Whether a passing independent review covers this commit, and the body
50
+ // carries no AI attribution (tasks/code-review.ts).
51
+ codeReview: CodeReviewGate;
53
52
  // A PR no task owns has no session to read: a draft isn't ready yet, and
54
53
  // a label saying it's blocked holds it.
55
54
  prDraft?: string | null;
@@ -147,11 +146,12 @@ export function evaluateGates(i: GateInput): GateOutcome[] {
147
146
  const stack: GateOutcome = i.stackRefusal
148
147
  ? { gate: "stack", state: "fail", reason: i.stackRefusal }
149
148
  : { gate: "stack", state: "pass" };
150
- const codeReview: GateOutcome = i.codeReviewRefusal
151
- ? { gate: "code-review", state: "fail", reason: i.codeReviewRefusal }
152
- : i.codeReviewWait
153
- ? { gate: "code-review", state: "wait", reason: i.codeReviewWait }
154
- : { gate: "code-review", state: "pass" };
149
+ // Its wait is the review gate's own (no passing review of this exact
150
+ // head), which already holds the merge: only paths without that gate (an
151
+ // approval, a stack's land) read it.
152
+ const codeReview: GateOutcome = i.codeReview.refusal
153
+ ? { gate: "code-review", state: "fail", reason: i.codeReview.refusal }
154
+ : { gate: "code-review", state: "pass" };
155
155
  return [
156
156
  ciGate(i),
157
157
  checkGate("review", i.review, i.sha),
@@ -272,7 +272,7 @@ export function checkMessage(
272
272
  : pr.failingUrl
273
273
  ? `\n\nIts details: ${untrusted("CI", pr.failingUrl)}`
274
274
  : "";
275
- return `CI check ${untrusted("CI", pr.failing ?? "")} failed on your PR #${pr.number} at ${short(pr.head)}${step}.${tail}\n\n${UNTRUSTED_RULE}\n\nFix the root cause (not the check), re-run your checks, push, then run /do-code-review again and update the Reviewed: line in the PR body for the new head.`;
275
+ return `CI check ${untrusted("CI", pr.failing ?? "")} failed on your PR #${pr.number} at ${short(pr.head)}${step}.${tail}\n\n${UNTRUSTED_RULE}\n\nFix the root cause (not the check), re-run your checks, then push.`;
276
276
  }
277
277
 
278
278
  // True once the PR's activity at this fingerprint has been handled.
@@ -374,7 +374,7 @@ export function reviewMessage(pr: TaskPR, events: PREvent[]): string {
374
374
  : "";
375
375
  return `- ${e.author} ${what}${body}${notes}${more}`;
376
376
  });
377
- return `New review activity on your PR #${pr.number}:\n${items.join("\n")}\n\n${UNTRUSTED_RULE} Address each point: fix what's right, re-run your checks, push, then run /do-code-review again and update the Reviewed: line for the new head. Where you disagree, reply on the PR (\`gh pr comment ${pr.number}\`) saying why.`;
377
+ return `New review activity on your PR #${pr.number}:\n${items.join("\n")}\n\n${UNTRUSTED_RULE} Address each point: fix what's right, re-run your checks, then push. Where you disagree, reply on the PR (\`gh pr comment ${pr.number}\`) saying why.`;
378
378
  }
379
379
 
380
380
  // Tests only.
@@ -8,11 +8,10 @@
8
8
  * only while its agent is idle with nothing uncommitted, and pushed to the
9
9
  * task's own branch with a lease on the commit fetched (the restack's code,
10
10
  * stacks/git.ts). The base itself and every other branch are never written.
11
- * The heads are recorded so the task's Code review section still covers
12
- * the new head (tasks/code-review.ts), and the section gets a "Restacked:"
13
- * line for CI. When the PR's own patch is unchanged (same `git patch-id`
14
- * against the base before and after), the orchestrator's review of the old
15
- * head carries over; otherwise a fresh one starts.
11
+ * The heads are recorded, and when the PR's own patch is unchanged (same
12
+ * `git patch-id` against the base before and after), the orchestrator's
13
+ * review of the old head carries over to the new one (tasks/code-review.ts);
14
+ * otherwise a fresh one starts.
16
15
  *
17
16
  * Not clean, or not rebasable (uncommitted work, commits only on origin):
18
17
  * the task is asked once, per head, to rebase by hand. The orchestrator
@@ -28,7 +27,6 @@ import { hostLink } from "../hosts/remote-api";
28
27
  import type { TaskPR } from "../tasks/state";
29
28
  import { run } from "../tasks/gh";
30
29
  import { git as runGit, gitFetch, revParse } from "../git-exec";
31
- import { withRestackedLine } from "../tasks/code-review";
32
30
  import { outputOf, type Runner } from "../stacks/git";
33
31
  import { restackedHeads, stackNotice } from "../stacks/restack";
34
32
  import { getCheck, putCheck } from "./checks";
@@ -53,37 +51,6 @@ export interface RebaseDeps {
53
51
  pr: TaskPR,
54
52
  sha: string
55
53
  ) => Promise<unknown>;
56
- // Add the Restacked line to the PR body's Code review section.
57
- editBody: (
58
- repo: string,
59
- pr: number,
60
- from: string,
61
- to: string
62
- ) => Promise<void>;
63
- }
64
-
65
- async function editBody(repo: string, pr: number, from: string, to: string) {
66
- const body = await run(
67
- "gh",
68
- ["pr", "view", String(pr), "--json", "body", "--jq", ".body"],
69
- repo,
70
- 15000
71
- );
72
- const next = withRestackedLine(body.replace(/\n$/, ""), from, to);
73
- if (!next) return;
74
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aos-body-"));
75
- const file = path.join(dir, "body.md");
76
- try {
77
- fs.writeFileSync(file, next);
78
- await run(
79
- "gh",
80
- ["pr", "edit", String(pr), "--body-file", file],
81
- repo,
82
- 30000
83
- );
84
- } finally {
85
- fs.rmSync(dir, { recursive: true, force: true });
86
- }
87
54
  }
88
55
 
89
56
  const defaults: RebaseDeps = {
@@ -94,7 +61,6 @@ const defaults: RebaseDeps = {
94
61
  idle: async (task) => (await statusOf(task)).status === "idle",
95
62
  review: (w, task, pr, sha) =>
96
63
  reviewAt(w, taskReviewTarget(task, { ...pr, head: sha }), sha),
97
- editBody,
98
64
  };
99
65
 
100
66
  // The (head, base tip) pairs already rebased or found not to need it, so a
@@ -263,11 +229,6 @@ async function rebaseOne(
263
229
  deps
264
230
  );
265
231
  }
266
- if (heads.restacked_from && heads.restacked_to)
267
- await deps
268
- .editBody(repo, pr.number, heads.restacked_from, heads.restacked_to)
269
- .catch(() => {});
270
-
271
232
  // The task's worktree follows while it's still idle and untouched;
272
233
  // otherwise the task is told how to catch up.
273
234
  const follows =
@@ -277,7 +238,7 @@ async function rebaseOne(
277
238
  if (!follows)
278
239
  await deps.send(
279
240
  task.id,
280
- `AgentOS rebased your branch ${task.branch_name} onto ${branch} on origin (now ${short(to)}), because PR #${pr.number} conflicted with it. Your worktree is still on the old head: before changing anything, run \`git fetch origin && git reset --keep origin/${task.branch_name}\` (commit your work first). Your Code review section still covers it.`
241
+ `AgentOS rebased your branch ${task.branch_name} onto ${branch} on origin (now ${short(to)}), because PR #${pr.number} conflicted with it. Your worktree is still on the old head: before changing anything, run \`git fetch origin && git reset --keep origin/${task.branch_name}\` (commit your work first). Its review carries over when its own patch didn't change.`
281
242
  );
282
243
  const reviewed = await carryReview(workspaceId, task, pr, onto, to, deps);
283
244
  queueEvent(
@@ -422,6 +383,6 @@ async function askToRebase(
422
383
  );
423
384
  await deps.send(
424
385
  task.id,
425
- `Your PR #${pr.number} conflicts with ${base}, and AgentOS couldn't rebase it for you: ${why.slice(0, 300)}. At your next stopping point: commit your work, run \`git fetch origin && git rebase origin/${base}\`, resolve the conflicts, re-run your checks, push with \`git push --force-with-lease\`, then run /do-code-review again and update the Code review section for the new head.`
386
+ `Your PR #${pr.number} conflicts with ${base}, and AgentOS couldn't rebase it for you: ${why.slice(0, 300)}. At your next stopping point: commit your work, run \`git fetch origin && git rebase origin/${base}\`, resolve the conflicts, re-run your checks, push with \`git push --force-with-lease\`. The orchestrator reviews the new head.`
426
387
  );
427
388
  }
@@ -38,6 +38,11 @@ import {
38
38
  toVerdict,
39
39
  } from "./review-prompt";
40
40
  import { checkScope } from "./scope";
41
+ import {
42
+ checkRunRecorder,
43
+ checkRunTotals,
44
+ describeCheckRuns,
45
+ } from "../usage/check-runs";
41
46
  import { workspaceTask } from "./targets";
42
47
  import { untrusted } from "./untrusted";
43
48
 
@@ -52,10 +57,8 @@ export function describeReview(row: CheckRow): string {
52
57
  if (row.status === "running") return `The review of ${at} is still running.`;
53
58
  if (row.status === "error")
54
59
  return `The review of ${at} couldn't run: ${row.detail}`;
55
- const head =
56
- row.status === "pass"
57
- ? `Review of ${at}: pass.`
58
- : `Review of ${at}: blocking findings.`;
60
+ const cost = checkRunTotals(row.session_id, row.sha);
61
+ const head = `${row.status === "pass" ? `Review of ${at}: pass.` : `Review of ${at}: blocking findings.`}${cost ? ` (${describeCheckRuns(cost)})` : ""}`;
59
62
  return row.detail ? `${head}\n${untrusted("reviewer", row.detail)}` : head;
60
63
  }
61
64
 
@@ -115,6 +118,14 @@ async function reviewJob(
115
118
  const amendments = task ? amendmentsOf(task.id).map((a) => a.text) : [];
116
119
  const scope = task ? { amendments, scopeClaim: pr.scopeChange ?? null } : {};
117
120
  const dir = await checkout(repo, sha);
121
+ const onUsage = checkRunRecorder({
122
+ workspaceId,
123
+ sessionId: target.id,
124
+ label: target.label,
125
+ prNumber: pr.number,
126
+ sha,
127
+ kind: "review",
128
+ });
118
129
  try {
119
130
  const verdicts = [];
120
131
  for (const [i, part] of parts.entries()) {
@@ -143,6 +154,7 @@ async function reviewJob(
143
154
  schema: REVIEW_SCHEMA,
144
155
  tools: ["Read", "Grep", "Glob"],
145
156
  allow: [`Read(/${dir}/**)`, `Grep(/${dir}/**)`, `Glob(/${dir}/**)`],
157
+ onUsage,
146
158
  });
147
159
  verdicts.push(toVerdict(answer));
148
160
  }
@@ -246,11 +258,12 @@ export async function reviewAt(
246
258
  : done.status === "block"
247
259
  ? "found blocking issues"
248
260
  : "couldn't run";
261
+ const cost = checkRunTotals(target.id, sha);
249
262
  queueEvent(
250
263
  workspaceId,
251
264
  `review:${target.id}:${sha}`,
252
265
  target.id,
253
- `${target.label}: review of ${short(sha)} ${said}${countVerdicts(workspaceId, target, sha)}`
266
+ `${target.label}: review of ${short(sha)} ${said}${cost ? ` (${describeCheckRuns(cost)})` : ""}${countVerdicts(workspaceId, target, sha)}`
254
267
  );
255
268
  return done;
256
269
  });
@@ -8,6 +8,7 @@ import { connectedClient } from "../lumifyhub/connection";
8
8
  import { promptFromCard } from "../lumifyhub/task-cards";
9
9
  import { putCheck, type CheckRow } from "./checks";
10
10
  import type { ClaudeRunner } from "./claude-cli";
11
+ import { checkRunRecorder } from "../usage/check-runs";
11
12
  import type { ChangedFile } from "./diff";
12
13
  import { fence } from "./review-prompt";
13
14
 
@@ -78,6 +79,14 @@ export async function checkScope(input: {
78
79
  prompt: `The card, as data:\n${fence("card", card)}${amended}\n\nFiles changed:\n${list}\n\n${label}\n${fence("diff", part)}`,
79
80
  schema: SCOPE_SCHEMA,
80
81
  tools: [],
82
+ onUsage: checkRunRecorder({
83
+ workspaceId,
84
+ sessionId: task.id,
85
+ label: `task ${task.name}`,
86
+ prNumber: task.pr_number ?? null,
87
+ sha,
88
+ kind: "scope",
89
+ }),
81
90
  })) as { within?: boolean; reason?: string };
82
91
  if (answer.within !== true) break;
83
92
  }