@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.
- package/README.md +39 -36
- package/app/api/fresh-start/route.ts +9 -5
- package/app/api/usage/route.ts +4 -2
- package/components/Chat/ChatPanel.tsx +5 -5
- package/components/Chat/StaleChip.tsx +7 -6
- package/components/Usage/UsageDialog.tsx +31 -2
- package/components/Workspaces/FreshStartForm.tsx +13 -13
- package/data/usage/index.ts +2 -0
- package/lib/agents/launch.ts +2 -0
- package/lib/chat/refresh.ts +31 -13
- package/lib/chat/stale.ts +25 -16
- package/lib/chat/worker/host.ts +57 -22
- package/lib/db/migrations.ts +42 -0
- package/lib/orchestrator/armed.ts +1 -2
- package/lib/orchestrator/brief.ts +3 -3
- package/lib/orchestrator/claude-cli.ts +5 -0
- package/lib/orchestrator/diff.ts +1 -1
- package/lib/orchestrator/external-gates.ts +8 -43
- package/lib/orchestrator/gates.ts +10 -10
- package/lib/orchestrator/pr-wake.ts +2 -2
- package/lib/orchestrator/rebase.ts +6 -45
- package/lib/orchestrator/review.ts +18 -5
- package/lib/orchestrator/scope.ts +9 -0
- package/lib/orchestrator/signoff.ts +13 -8
- package/lib/session-audit/render.ts +1 -1
- package/lib/stacks/land.ts +23 -16
- package/lib/tasks/brief.ts +3 -14
- package/lib/tasks/code-review.ts +69 -107
- package/lib/tasks/gh.ts +3 -2
- package/lib/tasks/index.ts +1 -1
- package/lib/tasks/state.ts +4 -4
- package/lib/usage/check-runs.ts +167 -0
- package/lib/usage/report.ts +25 -0
- package/lib/usage/turns.ts +28 -0
- package/package.json +1 -1
- package/scripts/check-code-review.ts +0 -21
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { MERGED_AS, type MergeMethod } from "../tasks/merge-policy";
|
|
11
11
|
import { prFor } from "../tasks/session";
|
|
12
|
-
import {
|
|
12
|
+
import { codeReviewGate, signOffTask } from "../tasks";
|
|
13
13
|
import { signOffRefusal } from "../stacks/guard";
|
|
14
14
|
import { getStack, landInBackground } from "../stacks";
|
|
15
15
|
import { LAND_DEFAULTS } from "../stacks/land";
|
|
@@ -91,11 +91,12 @@ export async function judge(
|
|
|
91
91
|
fromCard: !!task.lh_card_id,
|
|
92
92
|
scope: getCheck(task.id, sha, "scope"),
|
|
93
93
|
stackRefusal: landing ? null : signOffRefusal(task.id),
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
codeReview: codeReviewGate({
|
|
95
|
+
head: sha,
|
|
96
|
+
attribution: pr.attribution,
|
|
97
|
+
review: (at) => getCheck(task.id, at, "review"),
|
|
98
|
+
restacked: restackedOf(task),
|
|
99
|
+
}),
|
|
99
100
|
}),
|
|
100
101
|
opts
|
|
101
102
|
);
|
|
@@ -129,7 +130,8 @@ export async function gateVerdict(
|
|
|
129
130
|
const held = holdingEscalations(work.id);
|
|
130
131
|
// Saad approved merging this exact commit. On an ask the orchestrator
|
|
131
132
|
// raised (ask_saad with its task and sha), held or not, it merges once
|
|
132
|
-
// whatever the gates say, but only
|
|
133
|
+
// whatever the gates say, but only once an independent review of that
|
|
134
|
+
// commit finished (its findings are his to override, not that it ran) and
|
|
133
135
|
// its stack parent merged: the orchestrator wrote that ask, and no answer
|
|
134
136
|
// to it stands in for those. An approval of the gates' own ask on a held
|
|
135
137
|
// task merges as it always has (heldVerdict).
|
|
@@ -139,7 +141,10 @@ export async function gateVerdict(
|
|
|
139
141
|
(approval.raised_by === "orchestrator" || !held.length)
|
|
140
142
|
) {
|
|
141
143
|
const i = await inputs();
|
|
142
|
-
const refusal =
|
|
144
|
+
const refusal =
|
|
145
|
+
i.codeReview.refusal ??
|
|
146
|
+
(i.codeReview.ran ? null : i.codeReview.wait) ??
|
|
147
|
+
i.stackRefusal;
|
|
143
148
|
if (refusal)
|
|
144
149
|
return no(
|
|
145
150
|
`${work.name} (PR #${pr.number} at ${short(sha)}): Saad approved this commit, but ${refusal}`
|
|
@@ -427,7 +427,7 @@ export function renderSummary(
|
|
|
427
427
|
)
|
|
428
428
|
);
|
|
429
429
|
out.push(
|
|
430
|
-
"\nThe reviewer the orchestrator runs (`review`) uses `claude -p --no-session-persistence` and leaves no transcript;
|
|
430
|
+
"\nThe reviewer the orchestrator runs (`review`) uses `claude -p --no-session-persistence` and leaves no transcript; each run's tokens are recorded in `check_runs` and shown in Usage."
|
|
431
431
|
);
|
|
432
432
|
return out.join("\n") + "\n";
|
|
433
433
|
}
|
package/lib/stacks/land.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Land a stack: merge every PR bottom-up, ported from dispatch's `land`.
|
|
3
|
-
* A preflight checks EVERY PR before anything merges: CI, and a
|
|
4
|
-
*
|
|
3
|
+
* A preflight checks EVERY PR before anything merges: CI, and a passing
|
|
4
|
+
* independent review of its head commit (tasks/code-review.ts). Each sign-off restacks
|
|
5
5
|
* what sits on it, so a restacked PR's checks are waited out again (its green
|
|
6
6
|
* was against the parent's branch). It stops at the first failure and says
|
|
7
7
|
* what landed. A chat item's agent is stopped between turns before anything
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
codeReviewRefusal,
|
|
18
18
|
type TaskPR,
|
|
19
19
|
} from "../tasks";
|
|
20
|
+
import type { ReviewState } from "../tasks/code-review";
|
|
21
|
+
import { getCheck } from "../orchestrator/checks";
|
|
20
22
|
import { itemName } from "./guard";
|
|
21
23
|
import { refreshItems } from "./tick";
|
|
22
24
|
import { outputOf } from "./git";
|
|
@@ -34,6 +36,8 @@ export interface LandDeps {
|
|
|
34
36
|
signOff: (sessionId: string, head?: string) => Promise<void>;
|
|
35
37
|
beforeMerge?: MergeGate;
|
|
36
38
|
prOf: (session: Session) => Promise<TaskPR | null>;
|
|
39
|
+
// The independent review of a task at a commit.
|
|
40
|
+
reviewOf: (sessionId: string, sha: string) => ReviewState | null;
|
|
37
41
|
sleep: (ms: number) => Promise<void>;
|
|
38
42
|
refresh: (stackId: string) => Promise<void>;
|
|
39
43
|
waitMs: number;
|
|
@@ -52,6 +56,7 @@ export const LAND_DEFAULTS: LandDeps = {
|
|
|
52
56
|
await signOffTask(id, { wait: true, head });
|
|
53
57
|
},
|
|
54
58
|
prOf: (s) => taskPR(s, true),
|
|
59
|
+
reviewOf: (id, sha) => getCheck(id, sha, "review"),
|
|
55
60
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
56
61
|
refresh: refreshItems,
|
|
57
62
|
waitMs: 45 * 60_000,
|
|
@@ -90,8 +95,20 @@ async function waitGreen(
|
|
|
90
95
|
}
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
// Why an item can't merge on its review: none passed of its head, or of
|
|
99
|
+
// the head the task pushed before a restack moved it.
|
|
100
|
+
function unreviewed(item: StackItemRow, pr: TaskPR, deps: LandDeps) {
|
|
101
|
+
return codeReviewRefusal({
|
|
102
|
+
head: pr.head,
|
|
103
|
+
attribution: pr.attribution,
|
|
104
|
+
review: (sha) =>
|
|
105
|
+
item.session_id ? deps.reviewOf(item.session_id, sha) : null,
|
|
106
|
+
restacked: { from: item.restacked_from, to: item.restacked_to },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
// Without the orchestrator's gate: the item's head right before merging,
|
|
94
|
-
// only if
|
|
111
|
+
// only if a passing review covers that commit. A push since the
|
|
95
112
|
// preflight needs a new review; the merge is pinned to this head.
|
|
96
113
|
async function reviewedHead(
|
|
97
114
|
item: StackItemRow,
|
|
@@ -100,12 +117,7 @@ async function reviewedHead(
|
|
|
100
117
|
const now = q.item(db, item.id) ?? item;
|
|
101
118
|
const session = sessionOf(now.session_id);
|
|
102
119
|
const pr = session ? await deps.prOf(session) : null;
|
|
103
|
-
const refusal = pr
|
|
104
|
-
? codeReviewRefusal(pr.codeReview, pr.head, {
|
|
105
|
-
from: now.restacked_from,
|
|
106
|
-
to: now.restacked_to,
|
|
107
|
-
})
|
|
108
|
-
: "its PR is gone";
|
|
120
|
+
const refusal = pr ? unreviewed(now, pr, deps) : "its PR is gone";
|
|
109
121
|
if (refusal) throw new Stop(`stopped at ${itemName(now)}: ${refusal}`);
|
|
110
122
|
return pr!.head!;
|
|
111
123
|
}
|
|
@@ -157,14 +169,9 @@ export async function landStack(
|
|
|
157
169
|
const pr =
|
|
158
170
|
session && item.status === "pr" ? await deps.prOf(session) : null;
|
|
159
171
|
const verdict = canSignOff(pr);
|
|
160
|
-
const unreviewed
|
|
161
|
-
pr &&
|
|
162
|
-
codeReviewRefusal(pr.codeReview, pr.head, {
|
|
163
|
-
from: item.restacked_from,
|
|
164
|
-
to: item.restacked_to,
|
|
165
|
-
});
|
|
172
|
+
const refusal = pr && unreviewed(item, pr, deps);
|
|
166
173
|
if (!verdict.ok) missing.push(`${itemName(item)}: ${verdict.reason}`);
|
|
167
|
-
else if (
|
|
174
|
+
else if (refusal) missing.push(`${itemName(item)}: ${refusal}`);
|
|
168
175
|
else if (item.error) missing.push(`${itemName(item)}: ${item.error}`);
|
|
169
176
|
hadChecks.set(item.id, pr?.checks !== "none");
|
|
170
177
|
}
|
package/lib/tasks/brief.ts
CHANGED
|
@@ -16,18 +16,9 @@ function stackedLines(baseBranch: string, stack: StackedOn): string {
|
|
|
16
16
|
return `
|
|
17
17
|
- This task is STACKED on ${stack.name} (${on}), which has not merged. Its commits are already in your branch and are not yours: don't change or revert them.
|
|
18
18
|
- Open your pull request against ${baseBranch} (\`gh pr create --base ${baseBranch}\`) so its diff is only your commits, and start the body with the line "Stacked on ${on} (${stack.name})".
|
|
19
|
-
- Never merge or rebase onto the default branch yourself. When the parent merges, AgentOS rebases your branch and retargets your PR, then tells you; re-run your checks when it does.
|
|
19
|
+
- Never merge or rebase onto the default branch yourself. When the parent merges, AgentOS rebases your branch and retargets your PR, then tells you; re-run your checks when it does. AgentOS reviews your PR itself; a review of your head still counts for the head its restack left, and a push after that gets a new one.${also}`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
// What the PR body's review section looks like. lib/tasks/code-review.ts
|
|
23
|
-
// parses it, and merges refuse a PR without one for its head commit.
|
|
24
|
-
export const CODE_REVIEW_SECTION = `
|
|
25
|
-
## Code review
|
|
26
|
-
Reviewed: <the full sha of the commit you reviewed, which is the PR's head>
|
|
27
|
-
Agents: <the review agents that ran>
|
|
28
|
-
Fixed: <each finding fixed, one line each, or "none">
|
|
29
|
-
Deferred: <each finding not fixed and why, or "none">`;
|
|
30
|
-
|
|
31
22
|
// The system brief every task agent runs under. It finishes by opening a PR
|
|
32
23
|
// and stopping: merging is the human's sign-off, never the agent's.
|
|
33
24
|
export function buildTaskBrief(opts: {
|
|
@@ -39,10 +30,8 @@ export function buildTaskBrief(opts: {
|
|
|
39
30
|
|
|
40
31
|
- Branch: ${opts.branch} (based on ${opts.baseBranch}). Stay in this directory and on this branch.
|
|
41
32
|
- Do the task fully. Run the project's own checks (typecheck, tests, lint) when they exist and keep them passing.
|
|
42
|
-
- When the work is done, commit with a conventional commit message,
|
|
43
|
-
-
|
|
44
|
-
${CODE_REVIEW_SECTION}
|
|
45
|
-
Merges are refused without it, and when its "Reviewed:" commit is not the PR's head: if you push again, review again and update the body (\`gh pr edit --body-file\`). Then stop and wait.
|
|
33
|
+
- When the work is done, commit with a conventional commit message, run \`git push -u origin ${opts.branch}\` and open a pull request against ${opts.baseBranch} with \`gh pr create\`, using a clear title and a short body that says what changed and how you verified it. Then stop and wait.
|
|
34
|
+
- You don't need to review it yourself: AgentOS runs an independent review of the PR's exact head commit before it can merge, and the orchestrator sends you any blocking findings to fix. For a risky change you may still run \`/do-code-review\` (or \`/code-review\`) before the PR.
|
|
46
35
|
- Never merge the pull request, never push to ${opts.baseBranch}, and never delete branches or worktrees. A human reviews and merges.${opts.stack ? stackedLines(opts.baseBranch, opts.stack) : ""}
|
|
47
36
|
- Write scratch files (PR bodies, review notes, logs, test output) outside the worktree, in your scratchpad or \$TMPDIR. Anything uncommitted left in the worktree keeps it from being removed when the task is done.
|
|
48
37
|
- Do not add any AI or assistant attribution to commits or the pull request.
|
package/lib/tasks/code-review.ts
CHANGED
|
@@ -1,27 +1,13 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
export interface CodeReviewSection {
|
|
7
|
-
// The commit the review covered, as written in the section.
|
|
8
|
-
sha: string | null;
|
|
9
|
-
// A "Restacked: <from> -> <to>" line AgentOS wrote after rebasing the
|
|
10
|
-
// branch itself. Only CI reads it: AgentOS's own gates go by what it
|
|
11
|
-
// recorded, never by what a body says.
|
|
12
|
-
restacked?: Restacked;
|
|
13
|
-
// What kind of AI attribution the body carries, when it does: a fixed
|
|
14
|
-
// label, never the body's own text, which goes into gate messages.
|
|
15
|
-
attribution?: AttributionKind;
|
|
16
|
-
}
|
|
1
|
+
// The code-review gate, the same for every PR: a passing independent
|
|
2
|
+
// review (lib/orchestrator/review) of its exact head, and a body with no AI
|
|
3
|
+
// attribution. Tasks don't write a review section in the body any more: the
|
|
4
|
+
// orchestrator's review is the one review.
|
|
17
5
|
|
|
18
6
|
// Matched one line at a time, never across lines: the body is written by
|
|
19
7
|
// the task, and a regex that backtracks over it would block the server.
|
|
20
8
|
// Only what renders counts: fenced code and HTML comments are skipped, so an
|
|
21
|
-
// example
|
|
9
|
+
// example isn't read as the real thing.
|
|
22
10
|
const MAX_BODY = 65_536;
|
|
23
|
-
const HEADING = /^ {0,3}(#{1,6}) *code review *:?$/i;
|
|
24
|
-
const ANY_HEADING = /^ {0,3}(#{1,6}) +\S/;
|
|
25
11
|
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
26
12
|
// AI attribution ("Co-Authored-By: Claude", "Generated with [Claude Code]").
|
|
27
13
|
// The same rule as the commit-msg hook's ATTRIBUTION_ERE in
|
|
@@ -45,10 +31,6 @@ function attributionKind(line: string): AttributionKind | undefined {
|
|
|
45
31
|
if (/^claude-session:/i.test(line)) return "a Claude-Session link";
|
|
46
32
|
return 'a "Generated with" AI footer';
|
|
47
33
|
}
|
|
48
|
-
const REVIEWED =
|
|
49
|
-
/^[>*_ -]*reviewed(?: (?:commit|sha|at))?[*_ ]*:[*_` ]*([0-9a-f]{12,40})\b/i;
|
|
50
|
-
const RESTACKED =
|
|
51
|
-
/^[>*_ -]*restacked[*_ ]*:[*_` ]*([0-9a-f]{40})[*_` ]*(?:->|→)[*_` ]*([0-9a-f]{40})\b/i;
|
|
52
34
|
|
|
53
35
|
export function renderedLines(body: string): string[] {
|
|
54
36
|
let text = body.slice(0, MAX_BODY);
|
|
@@ -70,45 +52,7 @@ export function renderedLines(body: string): string[] {
|
|
|
70
52
|
return lines;
|
|
71
53
|
}
|
|
72
54
|
|
|
73
|
-
// The
|
|
74
|
-
// none does); null when the body has none. A section runs to the next
|
|
75
|
-
// heading of its level or higher, so its own subheadings stay inside it.
|
|
76
|
-
export function parseCodeReview(
|
|
77
|
-
body: string | null | undefined
|
|
78
|
-
): CodeReviewSection | null {
|
|
79
|
-
if (!body) return null;
|
|
80
|
-
const sections: CodeReviewSection[] = [];
|
|
81
|
-
let level = 0;
|
|
82
|
-
let attribution: AttributionKind | undefined;
|
|
83
|
-
for (const line of renderedLines(body)) {
|
|
84
|
-
attribution ??= attributionKind(line.trimStart());
|
|
85
|
-
const heading = HEADING.exec(line);
|
|
86
|
-
if (heading) {
|
|
87
|
-
level = heading[1].length;
|
|
88
|
-
sections.push({ sha: null });
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
const other = ANY_HEADING.exec(line);
|
|
92
|
-
if (other && other[1].length <= level) level = 0;
|
|
93
|
-
const current = sections.at(-1);
|
|
94
|
-
if (!level || !current) continue;
|
|
95
|
-
if (current.sha) {
|
|
96
|
-
const moved = RESTACKED.exec(line);
|
|
97
|
-
if (moved)
|
|
98
|
-
current.restacked = {
|
|
99
|
-
from: moved[1].toLowerCase(),
|
|
100
|
-
to: moved[2].toLowerCase(),
|
|
101
|
-
};
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
const sha = REVIEWED.exec(line)?.[1];
|
|
105
|
-
if (sha) current.sha = sha.toLowerCase();
|
|
106
|
-
}
|
|
107
|
-
const found = sections.findLast((s) => s.sha) ?? sections.at(-1) ?? null;
|
|
108
|
-
return found && attribution ? { ...found, attribution } : found;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// The AI attribution a body carries anywhere, Code review section or not.
|
|
55
|
+
// The AI attribution a body carries anywhere.
|
|
112
56
|
export function attributionIn(
|
|
113
57
|
body: string | null | undefined
|
|
114
58
|
): AttributionKind | undefined {
|
|
@@ -119,57 +63,75 @@ export function attributionIn(
|
|
|
119
63
|
}
|
|
120
64
|
}
|
|
121
65
|
|
|
122
|
-
// A branch AgentOS rebased itself (a stack restack
|
|
123
|
-
//
|
|
66
|
+
// A branch AgentOS rebased itself (a stack restack, or the automatic
|
|
67
|
+
// rebase onto its base): the head the task pushed, and the head AgentOS
|
|
68
|
+
// left.
|
|
124
69
|
export interface Restacked {
|
|
125
70
|
from: string | null;
|
|
126
71
|
to: string | null;
|
|
127
72
|
}
|
|
128
73
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
export
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (!section) return `the PR body has no Code review section: ${fix}`;
|
|
143
|
-
if (section.attribution)
|
|
144
|
-
return `the PR body carries AI attribution (${section.attribution}): remove it with \`gh pr edit --body-file\``;
|
|
145
|
-
if (!section.sha)
|
|
146
|
-
return `the PR's Code review section doesn't name the reviewed commit ("Reviewed: <sha>"): ${fix}`;
|
|
147
|
-
const covers = (sha: string | null | undefined) =>
|
|
148
|
-
!!sha && sha.toLowerCase().startsWith(section.sha!);
|
|
149
|
-
if (covers(head)) return null;
|
|
150
|
-
if (restacked?.to === head && covers(restacked.from)) return null;
|
|
151
|
-
return `the PR's code review covers ${section.sha.slice(0, 7)}, not its head ${head.slice(0, 7)}: ${fix}`;
|
|
74
|
+
export interface ReviewState {
|
|
75
|
+
sha: string;
|
|
76
|
+
status: "running" | "pass" | "block" | "error";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CodeReviewGate {
|
|
80
|
+
// Fails the gate: counts against the task.
|
|
81
|
+
refusal: string | null;
|
|
82
|
+
// Not covered yet, but a review will cover it.
|
|
83
|
+
wait: string | null;
|
|
84
|
+
// A review covering the head finished, passing or not. Saad's approval
|
|
85
|
+
// of the commit overrides what it found, never whether one ran.
|
|
86
|
+
ran?: boolean;
|
|
152
87
|
}
|
|
153
88
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
89
|
+
const short = (sha: string) => sha.slice(0, 7);
|
|
90
|
+
|
|
91
|
+
// Fails closed: an unread body or an unknown head refuses. A review of the
|
|
92
|
+
// head the task pushed still counts after AgentOS restacked or rebased it
|
|
93
|
+
// cleanly, but only while the head is exactly the one AgentOS left: a push
|
|
94
|
+
// by the task after that needs a new review.
|
|
95
|
+
export function codeReviewGate(opts: {
|
|
96
|
+
head: string | undefined;
|
|
97
|
+
// The AI attribution in the PR body (attributionIn): null for none,
|
|
98
|
+
// undefined when the body couldn't be read.
|
|
99
|
+
attribution: AttributionKind | null | undefined;
|
|
100
|
+
review: (sha: string) => ReviewState | null;
|
|
101
|
+
restacked?: Restacked | null;
|
|
102
|
+
}): CodeReviewGate {
|
|
103
|
+
const { head, restacked } = opts;
|
|
104
|
+
const no = (refusal: string) => ({ refusal, wait: null });
|
|
105
|
+
const { attribution } = opts;
|
|
106
|
+
if (attribution === undefined) return no("the PR body couldn't be read");
|
|
107
|
+
if (!head) return no("the PR's head commit is unknown");
|
|
108
|
+
if (attribution)
|
|
109
|
+
return no(
|
|
110
|
+
`the PR body carries AI attribution (${attribution}): remove it with \`gh pr edit --body-file\``
|
|
111
|
+
);
|
|
112
|
+
// The reviews that cover the head: its own, and the one of the head the
|
|
113
|
+
// task pushed while the head is the one AgentOS left.
|
|
114
|
+
const covering = [
|
|
115
|
+
opts.review(head),
|
|
116
|
+
restacked?.to === head && restacked.from
|
|
117
|
+
? opts.review(restacked.from)
|
|
118
|
+
: null,
|
|
119
|
+
];
|
|
120
|
+
if (covering.some((r) => r?.status === "pass"))
|
|
121
|
+
return { refusal: null, wait: null, ran: true };
|
|
122
|
+
const now = covering[0];
|
|
123
|
+
return {
|
|
124
|
+
refusal: null,
|
|
125
|
+
wait: `no passing independent review of ${short(head)}${now?.status === "running" ? " yet (it's running)" : now?.status === "block" ? " (it has blocking findings)" : ": call review"}`,
|
|
126
|
+
ran: covering.some((r) => r?.status === "block"),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The gate as one reason, for paths that only merge or refuse (a stack
|
|
131
|
+
// landing, an approved merge): a wait refuses there too.
|
|
132
|
+
export function codeReviewRefusal(
|
|
133
|
+
opts: Parameters<typeof codeReviewGate>[0]
|
|
162
134
|
): string | null {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
const lines = body.split("\n");
|
|
166
|
-
const at = lines.findLastIndex(
|
|
167
|
-
(l) =>
|
|
168
|
-
REVIEWED.exec(l.replace(/\t/g, " "))?.[1]?.toLowerCase() === section.sha
|
|
169
|
-
);
|
|
170
|
-
if (at < 0) return null;
|
|
171
|
-
const line = `Restacked: ${from} -> ${to} (AgentOS rebased the reviewed commit onto its base; the PR's own changes are the ones reviewed)`;
|
|
172
|
-
const next = lines[at + 1]?.replace(/\t/g, " ") ?? "";
|
|
173
|
-
lines.splice(at + 1, RESTACKED.test(next) ? 1 : 0, line);
|
|
174
|
-
return lines.join("\n");
|
|
135
|
+
const gate = codeReviewGate(opts);
|
|
136
|
+
return gate.refusal ?? gate.wait;
|
|
175
137
|
}
|
package/lib/tasks/gh.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile } from "child_process";
|
|
2
2
|
import { promisify } from "util";
|
|
3
3
|
import { git } from "../git-exec";
|
|
4
|
-
import {
|
|
4
|
+
import { attributionIn } from "./code-review";
|
|
5
5
|
import { parseScopeChange } from "./scope-change";
|
|
6
6
|
import { assertGhAllowed, noteGhFailure, noteGhSuccess } from "./gh-limit";
|
|
7
7
|
import {
|
|
@@ -114,7 +114,8 @@ export async function toTaskPR(repoDir: string, pr: ListedPR): Promise<TaskPR> {
|
|
|
114
114
|
failing: failingCheck(rollup),
|
|
115
115
|
failingUrl: failingUrl(rollup),
|
|
116
116
|
checkCount: (pr.statusCheckRollup ?? []).length,
|
|
117
|
-
|
|
117
|
+
attribution:
|
|
118
|
+
pr.body === undefined ? undefined : (attributionIn(pr.body) ?? null),
|
|
118
119
|
scopeChange: parseScopeChange(pr.body),
|
|
119
120
|
...(pr.mergeable && { mergeable: pr.mergeable }),
|
|
120
121
|
};
|
package/lib/tasks/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { chatStateNow } from "../chat/runner";
|
|
|
49
49
|
import { saidSinceLastMessage } from "../chat/store";
|
|
50
50
|
|
|
51
51
|
export * from "./state";
|
|
52
|
-
export {
|
|
52
|
+
export { codeReviewGate, codeReviewRefusal } from "./code-review";
|
|
53
53
|
export { signOffTask, dropTask, signingOff, mergeSettled } from "./finish";
|
|
54
54
|
export { prFor as taskPR } from "./session";
|
|
55
55
|
export { moveTask } from "./move-flow";
|
package/lib/tasks/state.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AttributionKind } from "./code-review";
|
|
2
2
|
|
|
3
3
|
export type TaskStatus =
|
|
4
4
|
| "running"
|
|
@@ -22,9 +22,9 @@ export interface TaskPR {
|
|
|
22
22
|
failingUrl?: string | null;
|
|
23
23
|
// How many checks are registered on the head.
|
|
24
24
|
checkCount?: number;
|
|
25
|
-
// The body
|
|
26
|
-
// when the body wasn't read.
|
|
27
|
-
|
|
25
|
+
// The AI attribution the body carries (tasks/code-review.ts): null for
|
|
26
|
+
// none, undefined when the body wasn't read.
|
|
27
|
+
attribution?: AttributionKind | null;
|
|
28
28
|
// The body's "Scope change" note (tasks/scope-change.ts), when it has one.
|
|
29
29
|
scopeChange?: string | null;
|
|
30
30
|
// GitHub's word on merging it into its base: CONFLICTING once the base
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the orchestrator's one-shot checks cost: each `claude -p` run of the
|
|
3
|
+
* independent reviewer and the scope check, read from its JSON result.
|
|
4
|
+
* They run with --no-session-persistence and leave no transcript, so this
|
|
5
|
+
* is the only record of their tokens.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db } from "../db";
|
|
9
|
+
import { formatTokens } from "../chat/context";
|
|
10
|
+
|
|
11
|
+
export interface RunUsage {
|
|
12
|
+
model: string | null;
|
|
13
|
+
inputTokens: number;
|
|
14
|
+
cacheWriteTokens: number;
|
|
15
|
+
cacheReadTokens: number;
|
|
16
|
+
outputTokens: number;
|
|
17
|
+
costUsd: number;
|
|
18
|
+
durationMs: number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ModelUsage {
|
|
22
|
+
inputTokens?: number;
|
|
23
|
+
outputTokens?: number;
|
|
24
|
+
cacheReadInputTokens?: number;
|
|
25
|
+
cacheCreationInputTokens?: number;
|
|
26
|
+
costUSD?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const n = (v: unknown) => (typeof v === "number" && v > 0 ? v : 0);
|
|
30
|
+
|
|
31
|
+
// The usage in a `claude -p --output-format json` result, or null when it
|
|
32
|
+
// carries none. Per-model usage covers every model the run used; the model
|
|
33
|
+
// named is the one that cost the most.
|
|
34
|
+
export function runUsageOf(result: unknown): RunUsage | null {
|
|
35
|
+
if (!result || typeof result !== "object") return null;
|
|
36
|
+
const r = result as {
|
|
37
|
+
usage?: {
|
|
38
|
+
input_tokens?: number;
|
|
39
|
+
output_tokens?: number;
|
|
40
|
+
cache_read_input_tokens?: number;
|
|
41
|
+
cache_creation_input_tokens?: number;
|
|
42
|
+
};
|
|
43
|
+
modelUsage?: Record<string, ModelUsage>;
|
|
44
|
+
total_cost_usd?: number;
|
|
45
|
+
duration_ms?: number;
|
|
46
|
+
};
|
|
47
|
+
const models = Object.entries(r.modelUsage ?? {});
|
|
48
|
+
if (!r.usage && !models.length) return null;
|
|
49
|
+
const durationMs = n(r.duration_ms) || null;
|
|
50
|
+
if (models.length) {
|
|
51
|
+
const sum = (k: keyof ModelUsage) =>
|
|
52
|
+
models.reduce((t, [, m]) => t + n(m[k]), 0);
|
|
53
|
+
const [model] = models.reduce((a, b) =>
|
|
54
|
+
n(b[1].costUSD) > n(a[1].costUSD) ? b : a
|
|
55
|
+
);
|
|
56
|
+
return {
|
|
57
|
+
model,
|
|
58
|
+
inputTokens: sum("inputTokens"),
|
|
59
|
+
cacheWriteTokens: sum("cacheCreationInputTokens"),
|
|
60
|
+
cacheReadTokens: sum("cacheReadInputTokens"),
|
|
61
|
+
outputTokens: sum("outputTokens"),
|
|
62
|
+
costUsd: n(r.total_cost_usd) || sum("costUSD"),
|
|
63
|
+
durationMs,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const u = r.usage!;
|
|
67
|
+
return {
|
|
68
|
+
model: null,
|
|
69
|
+
inputTokens: n(u.input_tokens),
|
|
70
|
+
cacheWriteTokens: n(u.cache_creation_input_tokens),
|
|
71
|
+
cacheReadTokens: n(u.cache_read_input_tokens),
|
|
72
|
+
outputTokens: n(u.output_tokens),
|
|
73
|
+
costUsd: n(r.total_cost_usd),
|
|
74
|
+
durationMs,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CheckRunTarget {
|
|
79
|
+
workspaceId: string;
|
|
80
|
+
// The task's session id, or an external PR's id.
|
|
81
|
+
sessionId: string;
|
|
82
|
+
// How the Usage page names it: "task add-a", "PR o/r#5".
|
|
83
|
+
label: string;
|
|
84
|
+
prNumber: number | null;
|
|
85
|
+
sha: string;
|
|
86
|
+
kind: "review" | "scope";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function recordCheckRun(
|
|
90
|
+
target: CheckRunTarget,
|
|
91
|
+
u: RunUsage,
|
|
92
|
+
at = Date.now()
|
|
93
|
+
): void {
|
|
94
|
+
db.prepare(
|
|
95
|
+
`INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model,
|
|
96
|
+
input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, cost_usd, duration_ms, at)
|
|
97
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
98
|
+
).run(
|
|
99
|
+
target.workspaceId,
|
|
100
|
+
target.sessionId,
|
|
101
|
+
target.label,
|
|
102
|
+
target.prNumber,
|
|
103
|
+
target.sha,
|
|
104
|
+
target.kind,
|
|
105
|
+
u.model,
|
|
106
|
+
u.inputTokens,
|
|
107
|
+
u.cacheWriteTokens,
|
|
108
|
+
u.cacheReadTokens,
|
|
109
|
+
u.outputTokens,
|
|
110
|
+
u.costUsd,
|
|
111
|
+
u.durationMs,
|
|
112
|
+
at
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A recorder for one check, that never lets a write break the check.
|
|
117
|
+
export function checkRunRecorder(target: CheckRunTarget) {
|
|
118
|
+
return (u: RunUsage) => {
|
|
119
|
+
try {
|
|
120
|
+
recordCheckRun(target, u);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error(`[check-runs] recording ${target.label}:`, e);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface CheckRunTotals {
|
|
128
|
+
runs: number;
|
|
129
|
+
tokens: number;
|
|
130
|
+
costUsd: number;
|
|
131
|
+
durationMs: number;
|
|
132
|
+
models: string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Everything the checks of one commit cost, every part and retry.
|
|
136
|
+
export function checkRunTotals(
|
|
137
|
+
sessionId: string,
|
|
138
|
+
sha: string
|
|
139
|
+
): CheckRunTotals | null {
|
|
140
|
+
const row = db
|
|
141
|
+
.prepare(
|
|
142
|
+
`SELECT count(*) AS runs,
|
|
143
|
+
coalesce(sum(input_tokens + cache_write_tokens + cache_read_tokens + output_tokens), 0) AS tokens,
|
|
144
|
+
coalesce(sum(cost_usd), 0) AS costUsd,
|
|
145
|
+
coalesce(sum(duration_ms), 0) AS durationMs,
|
|
146
|
+
group_concat(DISTINCT model) AS models
|
|
147
|
+
FROM check_runs WHERE session_id = ? AND sha = ?`
|
|
148
|
+
)
|
|
149
|
+
.get(sessionId, sha) as Omit<CheckRunTotals, "models"> & {
|
|
150
|
+
models: string | null;
|
|
151
|
+
};
|
|
152
|
+
if (!row.runs) return null;
|
|
153
|
+
return { ...row, models: row.models ? row.models.split(",") : [] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// "2 runs · 412k tokens · $0.84 · 3m 10s · claude-opus-5-5"
|
|
157
|
+
export function describeCheckRuns(t: CheckRunTotals): string {
|
|
158
|
+
const s = Math.round(t.durationMs / 1000);
|
|
159
|
+
const time = s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
|
|
160
|
+
return [
|
|
161
|
+
`${t.runs} run${t.runs === 1 ? "" : "s"}`,
|
|
162
|
+
`${formatTokens(t.tokens)} tokens`,
|
|
163
|
+
`$${t.costUsd.toFixed(2)}`,
|
|
164
|
+
time,
|
|
165
|
+
...t.models,
|
|
166
|
+
].join(" · ");
|
|
167
|
+
}
|
package/lib/usage/report.ts
CHANGED
|
@@ -18,3 +18,28 @@ export function usageReport(range: UsageRange, now = Date.now()) {
|
|
|
18
18
|
const names = Object.fromEntries(listWorkspaces().map((w) => [w.id, w.name]));
|
|
19
19
|
return aggregateUsage(rows, range, names, now);
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
// What the orchestrator's reviewer and scope check cost over the range
|
|
23
|
+
// (lib/usage/check-runs), by PR: a "turn" is one run.
|
|
24
|
+
export function checkRunReport(range: UsageRange, now = Date.now()) {
|
|
25
|
+
const rows = db
|
|
26
|
+
.prepare(
|
|
27
|
+
`SELECT session_id, label, pr_number, workspace_id, at, cost_usd, input_tokens,
|
|
28
|
+
output_tokens, cache_read_tokens, cache_write_tokens
|
|
29
|
+
FROM check_runs WHERE at >= ? ORDER BY at`
|
|
30
|
+
)
|
|
31
|
+
.all(rangeStart(range, now)) as (Omit<TurnRow, "session_name"> & {
|
|
32
|
+
label: string;
|
|
33
|
+
pr_number: number | null;
|
|
34
|
+
})[];
|
|
35
|
+
const named: TurnRow[] = rows.map(({ label, pr_number, ...r }) => ({
|
|
36
|
+
...r,
|
|
37
|
+
// An external PR's label already names it.
|
|
38
|
+
session_name:
|
|
39
|
+
pr_number === null || label.startsWith("PR ")
|
|
40
|
+
? label
|
|
41
|
+
: `${label} · PR #${pr_number}`,
|
|
42
|
+
}));
|
|
43
|
+
const names = Object.fromEntries(listWorkspaces().map((w) => [w.id, w.name]));
|
|
44
|
+
return aggregateUsage(named, range, names, now);
|
|
45
|
+
}
|