@saadnvd1/agent-os 0.3.470 → 0.3.472
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 +139 -36
- package/app/api/fresh-start/route.ts +9 -5
- package/app/api/models/route.ts +34 -0
- package/app/api/models/test/route.ts +17 -0
- package/app/api/sessions/[id]/summarize/route.ts +4 -1
- package/app/api/tasks/[id]/model/route.ts +23 -0
- package/app/api/tasks/route.ts +5 -1
- package/app/api/usage/compare/route.ts +21 -0
- package/app/api/usage/route.ts +8 -3
- package/app/api/workspaces/[id]/models/route.ts +27 -0
- package/components/Chat/ChatPanel.tsx +5 -5
- package/components/Chat/StaleChip.tsx +7 -6
- package/components/CommandPalette/useAppCommands.ts +19 -1
- package/components/Draft/DraftChips.tsx +28 -0
- package/components/Draft/DraftPanel.tsx +1 -1
- package/components/Models/ModelSelect.tsx +67 -0
- package/components/Models/ModelsSection.tsx +199 -0
- package/components/Models/ProviderCard.tsx +271 -0
- package/components/SessionList/SidebarHeader.tsx +5 -1
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +19 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Tasks/ResumeOnModel.tsx +72 -0
- package/components/Tasks/TaskRow.tsx +4 -0
- package/components/Usage/CompareView.tsx +211 -0
- package/components/Usage/ModelSpend.tsx +133 -0
- package/components/Usage/UsageDialog.tsx +69 -5
- package/components/Usage/format.ts +9 -0
- package/components/Workspaces/FreshStartForm.tsx +13 -13
- package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/models/index.ts +125 -0
- package/data/usage/index.ts +5 -0
- package/lib/agents/launch.ts +13 -2
- package/lib/chat/context.ts +1 -1
- package/lib/chat/refresh.ts +31 -13
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/stale.ts +25 -16
- package/lib/chat/worker/extras.ts +12 -0
- package/lib/chat/worker/host.ts +70 -24
- package/lib/chat/worker/main.ts +5 -2
- package/lib/db/migrations.ts +70 -0
- package/lib/db/types.ts +6 -0
- package/lib/drafts.ts +3 -0
- package/lib/models/config.ts +218 -0
- package/lib/models/launch.ts +93 -0
- package/lib/models/plan-step.ts +94 -0
- package/lib/models/providers.ts +163 -0
- package/lib/models/resolve.ts +246 -0
- package/lib/models/resume.ts +99 -0
- package/lib/models/roles.ts +98 -0
- package/lib/models/secrets.ts +86 -0
- package/lib/models/session-model.ts +40 -0
- package/lib/models/test-provider.ts +116 -0
- package/lib/orchestrator/act.ts +13 -1
- package/lib/orchestrator/armed.ts +1 -2
- package/lib/orchestrator/brief.ts +5 -4
- package/lib/orchestrator/claude-cli.ts +48 -14
- 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/home.ts +2 -2
- package/lib/orchestrator/pr-wake.ts +2 -2
- package/lib/orchestrator/rebase.ts +6 -45
- package/lib/orchestrator/review.ts +20 -5
- package/lib/orchestrator/scope.ts +11 -0
- package/lib/orchestrator/serve.ts +13 -0
- package/lib/orchestrator/signoff.ts +13 -8
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +38 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/pr-generation.ts +5 -2
- package/lib/providers/registry.ts +1 -1
- package/lib/session-audit/render.ts +1 -1
- package/lib/session-titles.ts +1 -1
- package/lib/sessions/launch.ts +19 -4
- package/lib/settings-url.ts +1 -0
- 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 +60 -9
- package/lib/tasks/queue.ts +14 -5
- package/lib/tasks/start.ts +7 -2
- package/lib/tasks/state.ts +4 -4
- package/lib/usage/by-model.ts +105 -0
- package/lib/usage/check-runs.ts +170 -0
- package/lib/usage/compare.ts +280 -0
- package/lib/usage/cost.ts +71 -0
- package/lib/usage/report.ts +51 -0
- package/lib/usage/turns.ts +107 -18
- package/package.json +1 -1
- package/stores/usageUi.ts +11 -2
- package/scripts/check-code-review.ts +0 -21
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
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
* drops it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import {
|
|
8
|
+
parseOverride,
|
|
9
|
+
resolveRole,
|
|
10
|
+
sessionModelRef,
|
|
11
|
+
type ModelOverride,
|
|
12
|
+
} from "../models/resolve";
|
|
13
|
+
import { effectiveModels } from "../models/config";
|
|
14
|
+
import { ANTHROPIC } from "../models/roles";
|
|
7
15
|
import { processTable, runsSomething } from "../process-table";
|
|
8
16
|
import { randomUUID } from "crypto";
|
|
9
17
|
import { db, queries, type Session } from "../db";
|
|
@@ -19,7 +27,6 @@ import {
|
|
|
19
27
|
isBranchName,
|
|
20
28
|
slugify,
|
|
21
29
|
} from "../git";
|
|
22
|
-
import { resolveModelForAgent } from "../model-catalog";
|
|
23
30
|
import { getProvider } from "../providers";
|
|
24
31
|
import { statusDetector } from "../status-detector";
|
|
25
32
|
import { buildTaskBrief, type StackedOn } from "./brief";
|
|
@@ -49,7 +56,7 @@ import { chatStateNow } from "../chat/runner";
|
|
|
49
56
|
import { saidSinceLastMessage } from "../chat/store";
|
|
50
57
|
|
|
51
58
|
export * from "./state";
|
|
52
|
-
export {
|
|
59
|
+
export { codeReviewGate, codeReviewRefusal } from "./code-review";
|
|
53
60
|
export { signOffTask, dropTask, signingOff, mergeSettled } from "./finish";
|
|
54
61
|
export { prFor as taskPR } from "./session";
|
|
55
62
|
export { moveTask } from "./move-flow";
|
|
@@ -85,6 +92,10 @@ export interface TaskView {
|
|
|
85
92
|
hostError: string | null;
|
|
86
93
|
// Merges by itself once every gate passes (orchestrator/armed.ts).
|
|
87
94
|
armed?: boolean;
|
|
95
|
+
// What it runs on now, "provider/model", and whether it's still in its
|
|
96
|
+
// plan step (lib/models).
|
|
97
|
+
model?: string;
|
|
98
|
+
planning?: boolean;
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
export async function createTask(opts: {
|
|
@@ -92,7 +103,12 @@ export async function createTask(opts: {
|
|
|
92
103
|
prompt: string;
|
|
93
104
|
// Its name; generated from the prompt when absent.
|
|
94
105
|
name?: string;
|
|
95
|
-
model
|
|
106
|
+
// Its own model: "provider/model" (or an Anthropic alias) for all its
|
|
107
|
+
// roles, or a map of role to one (lib/models). Its workspace's and
|
|
108
|
+
// Settings' role models otherwise.
|
|
109
|
+
model?: ModelOverride;
|
|
110
|
+
// Plan first on the planning model (Settings' default otherwise).
|
|
111
|
+
plan?: boolean;
|
|
96
112
|
// Started from this LumifyHub card, which the task then moves.
|
|
97
113
|
cardId?: string;
|
|
98
114
|
// Stacked: cut from another task's pushed branch, at this exact commit.
|
|
@@ -169,7 +185,7 @@ async function startTask(
|
|
|
169
185
|
id,
|
|
170
186
|
prompt,
|
|
171
187
|
name: opts.name,
|
|
172
|
-
model: opts.model,
|
|
188
|
+
model: typeof opts.model === "string" ? opts.model : undefined,
|
|
173
189
|
baseBranch: opts.baseBranch,
|
|
174
190
|
view,
|
|
175
191
|
});
|
|
@@ -179,6 +195,12 @@ async function startTask(
|
|
|
179
195
|
|
|
180
196
|
if (opts.baseBranch !== undefined && !isBranchName(opts.baseBranch))
|
|
181
197
|
throw new Error(`"${opts.baseBranch}" isn't a branch name`);
|
|
198
|
+
const routing = taskRouting(
|
|
199
|
+
project.workspace_id,
|
|
200
|
+
view,
|
|
201
|
+
opts.model,
|
|
202
|
+
opts.plan
|
|
203
|
+
);
|
|
182
204
|
const projectPath = expandHome(project.working_directory);
|
|
183
205
|
const naming = await nameFor(prompt, projectPath, opts.name);
|
|
184
206
|
const feature = `${slugify(naming.name.split(/\s+/).slice(0, 6).join(" "))}-${id.slice(0, 4)}`;
|
|
@@ -198,10 +220,7 @@ async function startTask(
|
|
|
198
220
|
});
|
|
199
221
|
|
|
200
222
|
const provider = getProvider("claude");
|
|
201
|
-
const model =
|
|
202
|
-
"claude",
|
|
203
|
-
opts.model || project.default_model
|
|
204
|
-
);
|
|
223
|
+
const model = routing.first.model;
|
|
205
224
|
const tmuxName = `${provider.id}-${id}`;
|
|
206
225
|
|
|
207
226
|
queries
|
|
@@ -225,7 +244,8 @@ async function startTask(
|
|
|
225
244
|
.run(wt.worktreePath, wt.branchName, baseBranch, null, id);
|
|
226
245
|
// Everything the launch needs is on the row, so a restart can resume it.
|
|
227
246
|
db.prepare(
|
|
228
|
-
`UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view =
|
|
247
|
+
`UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
|
|
248
|
+
model_provider = ?, model_overrides = ?, plan_phase = ? WHERE id = ?`
|
|
229
249
|
).run(
|
|
230
250
|
prompt,
|
|
231
251
|
buildTaskBrief({
|
|
@@ -235,6 +255,9 @@ async function startTask(
|
|
|
235
255
|
}),
|
|
236
256
|
naming.source,
|
|
237
257
|
view,
|
|
258
|
+
routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
|
|
259
|
+
routing.override ? JSON.stringify(routing.override) : null,
|
|
260
|
+
routing.plan ? "planning" : null,
|
|
238
261
|
id
|
|
239
262
|
);
|
|
240
263
|
opts.onCreated?.(id);
|
|
@@ -340,6 +363,8 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
340
363
|
hostName: null,
|
|
341
364
|
hostError: null,
|
|
342
365
|
armed: !!session.merge_armed_at,
|
|
366
|
+
model: sessionModelRef(session),
|
|
367
|
+
planning: session.plan_phase === "planning",
|
|
343
368
|
};
|
|
344
369
|
}
|
|
345
370
|
|
|
@@ -365,3 +390,29 @@ export async function listTasks(): Promise<TaskView[]> {
|
|
|
365
390
|
b.createdAt.localeCompare(a.createdAt)
|
|
366
391
|
);
|
|
367
392
|
}
|
|
393
|
+
|
|
394
|
+
// The model a task starts on, and whether it plans first: only as a chat,
|
|
395
|
+
// where the handoff can follow the end of its plan turn, and only when
|
|
396
|
+
// planning and implementing are on different models. Both roles resolve
|
|
397
|
+
// now, so a bad override or a provider that's off fails the start.
|
|
398
|
+
export function taskRouting(
|
|
399
|
+
workspaceId: string | null,
|
|
400
|
+
view: StartView,
|
|
401
|
+
model: unknown,
|
|
402
|
+
plan?: boolean
|
|
403
|
+
) {
|
|
404
|
+
const override = parseOverride(model);
|
|
405
|
+
const models = effectiveModels();
|
|
406
|
+
const opts = { workspaceId, override };
|
|
407
|
+
const implement = resolveRole("implement", opts, models);
|
|
408
|
+
const planning = resolveRole("plan", opts, models);
|
|
409
|
+
const planStep =
|
|
410
|
+
view === "chat" &&
|
|
411
|
+
(plan ?? models.planStep) &&
|
|
412
|
+
planning.ref !== implement.ref;
|
|
413
|
+
return {
|
|
414
|
+
override,
|
|
415
|
+
plan: planStep,
|
|
416
|
+
first: planStep ? planning : implement,
|
|
417
|
+
};
|
|
418
|
+
}
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* is deleted: a started or removed row stays as history.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { ModelOverride } from "../models/resolve";
|
|
14
15
|
import { randomUUID } from "crypto";
|
|
15
16
|
import { db, type Session } from "../db";
|
|
16
17
|
import { getProject } from "../projects";
|
|
@@ -41,6 +42,8 @@ export interface QueueRow {
|
|
|
41
42
|
prompt: string;
|
|
42
43
|
name: string | null;
|
|
43
44
|
model: string | null;
|
|
45
|
+
// A per-role model map (lib/models), when that's what it was given.
|
|
46
|
+
model_overrides: string | null;
|
|
44
47
|
view: "chat" | "terminal" | null;
|
|
45
48
|
base_branch: string | null;
|
|
46
49
|
host_id: string | null;
|
|
@@ -200,7 +203,7 @@ export interface QueueRequest {
|
|
|
200
203
|
projectId: string;
|
|
201
204
|
prompt: string;
|
|
202
205
|
name?: string;
|
|
203
|
-
model?:
|
|
206
|
+
model?: ModelOverride;
|
|
204
207
|
view?: "chat" | "terminal";
|
|
205
208
|
baseBranch?: string;
|
|
206
209
|
hostId?: string;
|
|
@@ -247,9 +250,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
247
250
|
if (!req.prompt.trim()) throw new Error("Describe the task");
|
|
248
251
|
const id = randomUUID();
|
|
249
252
|
db.prepare(
|
|
250
|
-
`INSERT INTO task_queue (id, project_id, prompt, name, model, view, base_branch,
|
|
253
|
+
`INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, view, base_branch,
|
|
251
254
|
host_id, after_task, after_any, origin_workspace_id, position)
|
|
252
|
-
VALUES (@id, @project_id, @prompt, @name, @model, @view, @base_branch,
|
|
255
|
+
VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @view, @base_branch,
|
|
253
256
|
@host_id, @after_task, @after_any, @origin,
|
|
254
257
|
(SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
|
|
255
258
|
).run({
|
|
@@ -257,7 +260,11 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
257
260
|
project_id: project.id,
|
|
258
261
|
prompt: req.prompt.trim(),
|
|
259
262
|
name: req.name?.trim() || null,
|
|
260
|
-
model: req.model || null,
|
|
263
|
+
model: typeof req.model === "string" ? req.model || null : null,
|
|
264
|
+
model_overrides:
|
|
265
|
+
req.model && typeof req.model === "object"
|
|
266
|
+
? JSON.stringify(req.model)
|
|
267
|
+
: null,
|
|
261
268
|
view: req.view ?? null,
|
|
262
269
|
base_branch: req.baseBranch || null,
|
|
263
270
|
host_id: req.hostId || null,
|
|
@@ -429,7 +436,9 @@ async function startRow(
|
|
|
429
436
|
projectId: r.project_id,
|
|
430
437
|
prompt: r.prompt,
|
|
431
438
|
name: r.name ?? undefined,
|
|
432
|
-
model: r.
|
|
439
|
+
model: r.model_overrides
|
|
440
|
+
? (JSON.parse(r.model_overrides) as ModelOverride)
|
|
441
|
+
: (r.model ?? undefined),
|
|
433
442
|
view: r.view ?? undefined,
|
|
434
443
|
baseBranch: r.base_branch ?? undefined,
|
|
435
444
|
hostId: r.host_id ?? undefined,
|
package/lib/tasks/start.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { deleteQueued, enqueue, listQueue } from "../chat/queued";
|
|
|
23
23
|
import { sendQueuedNow } from "../chat/runner";
|
|
24
24
|
import { hasItem } from "../chat/store";
|
|
25
25
|
import { firstMessageId, launchPending } from "./launch-gate";
|
|
26
|
+
import { planPrompt, preparePlanDir } from "../models/plan-step";
|
|
26
27
|
|
|
27
28
|
export { firstMessageId, launchPending };
|
|
28
29
|
import { expandHome } from "./session";
|
|
@@ -136,7 +137,10 @@ export async function finishTaskStart(
|
|
|
136
137
|
ms: setup.ms,
|
|
137
138
|
error: "Not launched: the task was closed during setup",
|
|
138
139
|
});
|
|
139
|
-
const
|
|
140
|
+
const task = (session.task_prompt ?? "") + setupNote(setup);
|
|
141
|
+
const planning = session.plan_phase === "planning";
|
|
142
|
+
if (planning) preparePlanDir(session.worktree_path);
|
|
143
|
+
const prompt = planning ? planPrompt(task) : task;
|
|
140
144
|
try {
|
|
141
145
|
if (session.view === "chat") await launchChat(sessionId, prompt);
|
|
142
146
|
else {
|
|
@@ -148,6 +152,7 @@ export async function finishTaskStart(
|
|
|
148
152
|
tmuxName: session.tmux_name,
|
|
149
153
|
cwd: session.worktree_path,
|
|
150
154
|
model: session.model,
|
|
155
|
+
provider: session.model_provider,
|
|
151
156
|
prompt,
|
|
152
157
|
brief: session.task_brief ?? undefined,
|
|
153
158
|
});
|
|
@@ -190,7 +195,7 @@ function earlierSetup(row: SetupColumns): TaskSetup | undefined {
|
|
|
190
195
|
return undefined;
|
|
191
196
|
}
|
|
192
197
|
|
|
193
|
-
const killTmux = (name: string) =>
|
|
198
|
+
export const killTmux = (name: string) =>
|
|
194
199
|
execFileAsync("tmux", ["kill-session", "-t", `=${name}`], {
|
|
195
200
|
timeout: 5000,
|
|
196
201
|
}).catch(() => undefined);
|
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,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend by model and by role over a window: tokens of each kind, turns,
|
|
3
|
+
* and the estimated cost from the price table (lib/usage/cost).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { estimateCost, rowModel, UNRECORDED, type PricedRow } from "./cost";
|
|
7
|
+
import type { ModelPrice } from "../models/providers";
|
|
8
|
+
|
|
9
|
+
export interface ModelTurnRow extends PricedRow {
|
|
10
|
+
at: number;
|
|
11
|
+
role: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TokenSpend {
|
|
15
|
+
inputTokens: number;
|
|
16
|
+
outputTokens: number;
|
|
17
|
+
cacheReadTokens: number;
|
|
18
|
+
cacheWriteTokens: number;
|
|
19
|
+
turns: number;
|
|
20
|
+
costUsd: number;
|
|
21
|
+
// Some of its turns are on a model the price table doesn't list.
|
|
22
|
+
unpriced: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ModelSpend extends TokenSpend {
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RoleSpend extends TokenSpend {
|
|
31
|
+
role: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SpendByModel {
|
|
35
|
+
since: number;
|
|
36
|
+
total: TokenSpend;
|
|
37
|
+
models: ModelSpend[];
|
|
38
|
+
roles: RoleSpend[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const emptySpend = (): TokenSpend => ({
|
|
42
|
+
inputTokens: 0,
|
|
43
|
+
outputTokens: 0,
|
|
44
|
+
cacheReadTokens: 0,
|
|
45
|
+
cacheWriteTokens: 0,
|
|
46
|
+
turns: 0,
|
|
47
|
+
costUsd: 0,
|
|
48
|
+
unpriced: false,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export function addRow(
|
|
52
|
+
s: TokenSpend,
|
|
53
|
+
r: PricedRow,
|
|
54
|
+
prices: Record<string, ModelPrice>
|
|
55
|
+
): void {
|
|
56
|
+
const cost = estimateCost(r, prices);
|
|
57
|
+
s.inputTokens += r.input_tokens;
|
|
58
|
+
s.outputTokens += r.output_tokens;
|
|
59
|
+
s.cacheReadTokens += r.cache_read_tokens;
|
|
60
|
+
s.cacheWriteTokens += r.cache_write_tokens;
|
|
61
|
+
s.turns += 1;
|
|
62
|
+
s.costUsd += cost.usd;
|
|
63
|
+
s.unpriced ||= !cost.priced;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function spendByModel(
|
|
67
|
+
rows: ModelTurnRow[],
|
|
68
|
+
since: number,
|
|
69
|
+
prices: Record<string, ModelPrice>
|
|
70
|
+
): SpendByModel {
|
|
71
|
+
const total = emptySpend();
|
|
72
|
+
const models = new Map<string, ModelSpend>();
|
|
73
|
+
const roles = new Map<string, RoleSpend>();
|
|
74
|
+
for (const r of rows) {
|
|
75
|
+
if (r.at < since) continue;
|
|
76
|
+
const { provider, model } = rowModel(r);
|
|
77
|
+
const mk = `${provider}/${model}`;
|
|
78
|
+
if (!models.has(mk)) models.set(mk, { ...emptySpend(), provider, model });
|
|
79
|
+
const role = r.role ?? UNRECORDED;
|
|
80
|
+
if (!roles.has(role)) roles.set(role, { ...emptySpend(), role });
|
|
81
|
+
for (const s of [total, models.get(mk)!, roles.get(role)!])
|
|
82
|
+
addRow(s, r, prices);
|
|
83
|
+
}
|
|
84
|
+
const byCost = (a: TokenSpend, b: TokenSpend) => b.costUsd - a.costUsd;
|
|
85
|
+
return {
|
|
86
|
+
since,
|
|
87
|
+
total,
|
|
88
|
+
models: [...models.values()].sort(byCost),
|
|
89
|
+
roles: [...roles.values()].sort(byCost),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function sumSpend(xs: TokenSpend[]): TokenSpend {
|
|
94
|
+
const t = emptySpend();
|
|
95
|
+
for (const x of xs) {
|
|
96
|
+
t.inputTokens += x.inputTokens;
|
|
97
|
+
t.outputTokens += x.outputTokens;
|
|
98
|
+
t.cacheReadTokens += x.cacheReadTokens;
|
|
99
|
+
t.cacheWriteTokens += x.cacheWriteTokens;
|
|
100
|
+
t.turns += x.turns;
|
|
101
|
+
t.costUsd += x.costUsd;
|
|
102
|
+
t.unpriced ||= x.unpriced;
|
|
103
|
+
}
|
|
104
|
+
return t;
|
|
105
|
+
}
|