@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
package/lib/chat/worker/host.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
131
|
-
// at the front of the queue until it ends
|
|
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
|
-
|
|
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 (
|
|
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
|
|
667
|
-
//
|
|
668
|
-
// the
|
|
669
|
-
// what the composer showed; "auto"
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
712
|
-
|
|
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().
|
|
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
|
-
|
|
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
|
package/lib/db/migrations.ts
CHANGED
|
@@ -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.
|
|
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:
|
|
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:
|
|
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.
|
|
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);
|
package/lib/orchestrator/diff.ts
CHANGED
|
@@ -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: /^
|
|
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,
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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,
|
|
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,
|
|
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
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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).
|
|
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
|
|
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
|
|
56
|
-
|
|
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
|
}
|