@saadnvd1/agent-os 0.3.484 → 0.3.486
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 +19 -3
- package/app/api/bus/spawn/route.ts +15 -0
- package/app/api/tasks/route.ts +5 -0
- package/components/Tasks/TaskRow.tsx +6 -0
- package/components/Usage/CompareView.tsx +15 -3
- package/lib/agents/spawn.ts +21 -9
- package/lib/db/migrations.ts +21 -0
- package/lib/db/types.ts +5 -0
- package/lib/models/plan-step.ts +52 -0
- package/lib/models/roles.ts +4 -1
- package/lib/orchestrator/act.ts +32 -5
- package/lib/orchestrator/armed.ts +1 -0
- package/lib/orchestrator/brief.ts +10 -2
- package/lib/orchestrator/conditions.ts +7 -0
- package/lib/orchestrator/describe.ts +7 -1
- package/lib/orchestrator/external-gates.ts +2 -0
- package/lib/orchestrator/external-pr.ts +1 -1
- package/lib/orchestrator/facts.ts +26 -1
- package/lib/orchestrator/gates.ts +42 -9
- package/lib/orchestrator/repo-ci.ts +104 -0
- package/lib/orchestrator/serve.ts +2 -0
- package/lib/orchestrator/signoff.ts +17 -7
- package/lib/orchestrator/task-state.ts +31 -0
- package/lib/orchestrator/tool-schemas.ts +14 -2
- package/lib/orchestrator/tools.ts +2 -2
- package/lib/sessions/launch.ts +24 -5
- package/lib/tasks/index.ts +18 -1
- package/lib/tasks/queue.ts +16 -2
- package/lib/usage/compare.ts +31 -2
- package/lib/usage/turns.ts +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -494,8 +494,13 @@ The most specific setting wins: a task's own model, then its workspace's
|
|
|
494
494
|
`AGENTOS_REVIEW_MODEL` (gate review and scope check) and
|
|
495
495
|
`AGENTOS_TITLE_MODEL` (helpers) in the server's environment still replace a
|
|
496
496
|
role's default where Settings names none. A
|
|
497
|
-
Claude session
|
|
498
|
-
|
|
497
|
+
Claude session you start without a model runs on the interactive role's. One
|
|
498
|
+
an orchestrator starts (its `start_session`, or `aos spawn` run from an
|
|
499
|
+
orchestrator) is recorded as started by it (`started_by`) and runs on the
|
|
500
|
+
implementation role instead, so delegated work doesn't use Opus by default;
|
|
501
|
+
`start_session` takes a `model` (`"provider/model"`, checked like a task's:
|
|
502
|
+
gate roles can't be set, other providers must be enabled). One you pick a
|
|
503
|
+
model for keeps it.
|
|
499
504
|
|
|
500
505
|
**Plan, then implement.** A chat task whose planning and implementation
|
|
501
506
|
models differ starts with a plan step: its first turn runs on the planning
|
|
@@ -503,7 +508,13 @@ model and writes the plan to `.agentos/plan.md` in its worktree (ignored by
|
|
|
503
508
|
git), changing nothing else. When that turn ends, the same conversation
|
|
504
509
|
switches to the implementation model (a live model switch, or a resume with
|
|
505
510
|
`--model` when the provider changes) and is told to follow the plan. Turn it
|
|
506
|
-
off in Settings, or per task with `plan: false`. Terminal tasks skip it.
|
|
511
|
+
off in Settings, or per task with `plan: false`. Terminal tasks skip it. An
|
|
512
|
+
orchestrator decides it for every task: `start_task` requires `plan` and a
|
|
513
|
+
short `plan_reason`, stored on the task and shown on its card ("Planned:
|
|
514
|
+
touches 6 files across gates and UI" / "No plan: one-line default change"),
|
|
515
|
+
and **Usage → Compare** splits cost into planned and not. When the plan step
|
|
516
|
+
finishes, the orchestrator gets one event with the plan file's path and a
|
|
517
|
+
3-5 line summary; the handoff to implementation stays automatic.
|
|
507
518
|
|
|
508
519
|
**Per task.** The task draft has a **Model** chip ("Routed by role", or one
|
|
509
520
|
model); `POST /api/tasks` and the orchestrator's `start_task` take `model`, as
|
|
@@ -1044,6 +1055,11 @@ it may do on its own, and the lines that always come back to you as asks.
|
|
|
1044
1055
|
approve, answer an ask or pass a gate. The route
|
|
1045
1056
|
they call answers only the orchestrator's worker, by a per-orchestrator
|
|
1046
1057
|
secret. Its shell runs only `aos` commands that read.
|
|
1058
|
+
- **Triage:** its brief has it decide each job: small and clear is a task with
|
|
1059
|
+
`plan: false` and the steps written into the brief; big, unclear or
|
|
1060
|
+
cross-cutting is `plan: true`; work outside a repository is a
|
|
1061
|
+
`start_session` (Sonnet) with the steps in the prompt. It puts
|
|
1062
|
+
implementation on Opus only when the work needs deep reasoning, and says why.
|
|
1047
1063
|
- **Files:** it reads anywhere and writes (Write, Edit, NotebookEdit) anywhere
|
|
1048
1064
|
except a project repository, a task worktree (`~/.agent-os/worktrees`),
|
|
1049
1065
|
AgentOS's own install and state (all of `~/.agent-os` but its own folder),
|
|
@@ -4,6 +4,17 @@ import { createTask } from "@/lib/tasks";
|
|
|
4
4
|
import { startOrQueue } from "@/lib/tasks/queue";
|
|
5
5
|
import { hostIdNamed } from "@/lib/hosts";
|
|
6
6
|
import { startView } from "@/lib/sessions/launch";
|
|
7
|
+
import { db } from "@/lib/db";
|
|
8
|
+
|
|
9
|
+
// The calling session, when it's an orchestrator: what it starts runs on the
|
|
10
|
+
// implementation model. A made-up `from` can only pick that cheaper role.
|
|
11
|
+
function orchestratorFrom(from: unknown): string | null {
|
|
12
|
+
if (typeof from !== "string" || !from) return null;
|
|
13
|
+
const row = db
|
|
14
|
+
.prepare(`SELECT 1 FROM sessions WHERE id = ? AND role = 'orchestrator'`)
|
|
15
|
+
.get(from);
|
|
16
|
+
return row ? from : null;
|
|
17
|
+
}
|
|
7
18
|
|
|
8
19
|
// mode "session" starts an interactive agent; "task" starts one that ends in
|
|
9
20
|
// a PR, or queues it (over the workspace's limit, or with `after`).
|
|
@@ -18,7 +29,9 @@ export async function POST(request: NextRequest) {
|
|
|
18
29
|
on,
|
|
19
30
|
after,
|
|
20
31
|
view,
|
|
32
|
+
from,
|
|
21
33
|
} = await request.json();
|
|
34
|
+
const startedBy = orchestratorFrom(from);
|
|
22
35
|
const given = typeof name === "string" ? name : undefined;
|
|
23
36
|
if (after !== undefined && mode !== "task")
|
|
24
37
|
throw new Error("--after works for tasks only");
|
|
@@ -30,6 +43,7 @@ export async function POST(request: NextRequest) {
|
|
|
30
43
|
model,
|
|
31
44
|
hostId: on ? hostIdNamed(String(on)) : undefined,
|
|
32
45
|
view: startView(view),
|
|
46
|
+
startedBy,
|
|
33
47
|
};
|
|
34
48
|
const out = await startOrQueue(
|
|
35
49
|
{ ...task, after: typeof after === "string" ? after : undefined },
|
|
@@ -44,6 +58,7 @@ export async function POST(request: NextRequest) {
|
|
|
44
58
|
prompt,
|
|
45
59
|
name: given,
|
|
46
60
|
model,
|
|
61
|
+
startedBy,
|
|
47
62
|
});
|
|
48
63
|
return NextResponse.json({ session }, { status: 201 });
|
|
49
64
|
} catch (error) {
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -35,6 +35,7 @@ export async function POST(request: NextRequest) {
|
|
|
35
35
|
after,
|
|
36
36
|
view,
|
|
37
37
|
plan,
|
|
38
|
+
plan_reason,
|
|
38
39
|
} = await request.json();
|
|
39
40
|
if (id !== undefined && !isSessionId(id)) throw new Error("Bad task id");
|
|
40
41
|
const pid =
|
|
@@ -47,6 +48,10 @@ export async function POST(request: NextRequest) {
|
|
|
47
48
|
// Checked now, so a queued task can't fail on it later.
|
|
48
49
|
model: parseOverride(model) ?? undefined,
|
|
49
50
|
plan: typeof plan === "boolean" ? plan : undefined,
|
|
51
|
+
planReason:
|
|
52
|
+
typeof plan_reason === "string" && plan_reason.trim()
|
|
53
|
+
? plan_reason.trim().slice(0, 200)
|
|
54
|
+
: undefined,
|
|
50
55
|
name: typeof name === "string" ? name : undefined,
|
|
51
56
|
hostId: typeof hostId === "string" && hostId ? hostId : undefined,
|
|
52
57
|
baseBranch: typeof baseBranch === "string" ? baseBranch : undefined,
|
|
@@ -47,6 +47,12 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
47
47
|
{task.model &&
|
|
48
48
|
` · ${task.model}${task.planning ? " (planning)" : ""}`}
|
|
49
49
|
</p>
|
|
50
|
+
{task.plan && (
|
|
51
|
+
<p className="text-muted-foreground truncate text-[11px]">
|
|
52
|
+
{task.plan.planned ? "Planned" : "No plan"}
|
|
53
|
+
{task.plan.reason && `: ${task.plan.reason}`}
|
|
54
|
+
</p>
|
|
55
|
+
)}
|
|
50
56
|
{task.cardUrl && (
|
|
51
57
|
<a
|
|
52
58
|
href={task.cardUrl}
|
|
@@ -197,14 +197,26 @@ function CompareTables({ data }: { data: Comparison }) {
|
|
|
197
197
|
}))}
|
|
198
198
|
total={sumSpend(data.roles)}
|
|
199
199
|
/>
|
|
200
|
+
{data.plans.length > 0 && (
|
|
201
|
+
<SpendTable
|
|
202
|
+
title="Planned · not planned"
|
|
203
|
+
rows={data.plans.map((r) => ({
|
|
204
|
+
...r.spend,
|
|
205
|
+
key: r.key,
|
|
206
|
+
label: `${{ planned: "Planned", unplanned: "No plan", unstated: "Not stated" }[r.key]} · ${r.tasks} ${r.tasks === 1 ? "task" : "tasks"}`,
|
|
207
|
+
}))}
|
|
208
|
+
total={sumSpend(data.plans.map((r) => r.spend))}
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
200
211
|
<p className="text-muted-foreground text-[11px]">
|
|
201
212
|
A task counts under the provider its own turns ran on (anthropic is the
|
|
202
213
|
baseline); its reviews and checks count toward its cost. Dollars are
|
|
203
214
|
list-price estimates; for Claude, the plan's real measure is its
|
|
204
215
|
5-hour window, shared out by how far each turn moved it (an estimate).
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
216
|
+
Planned or not is the orchestrator's own call, recorded when it
|
|
217
|
+
started the task. Outcomes come from the orchestrator's checks, so
|
|
218
|
+
only its tasks have them. Under 10 tasks a column is a small sample:
|
|
219
|
+
read it as a hint, not a result.
|
|
208
220
|
</p>
|
|
209
221
|
</>
|
|
210
222
|
);
|
package/lib/agents/spawn.ts
CHANGED
|
@@ -3,6 +3,8 @@ import os from "os";
|
|
|
3
3
|
import { db, queries, type Session } from "../db";
|
|
4
4
|
import { getProject, getAllProjects } from "../projects";
|
|
5
5
|
import { resolveModelForAgent } from "../model-catalog";
|
|
6
|
+
import { parseOverride, resolveRole, setSessionModel } from "../models/resolve";
|
|
7
|
+
import { ANTHROPIC } from "../models/roles";
|
|
6
8
|
import { launchClaude } from "./launch";
|
|
7
9
|
import { nameFor } from "../session-titles";
|
|
8
10
|
|
|
@@ -29,7 +31,10 @@ export async function spawnSession(opts: {
|
|
|
29
31
|
prompt: string;
|
|
30
32
|
// Its name; generated from the prompt when absent.
|
|
31
33
|
name?: string;
|
|
32
|
-
model?: string;
|
|
34
|
+
model?: string | null;
|
|
35
|
+
// The orchestrator session that starts it: the implementation role's
|
|
36
|
+
// model unless `model` is given.
|
|
37
|
+
startedBy?: string | null;
|
|
33
38
|
}): Promise<Session> {
|
|
34
39
|
const prompt = opts.prompt.trim();
|
|
35
40
|
if (!prompt) throw new Error("Give the new session a prompt");
|
|
@@ -41,10 +46,17 @@ export async function spawnSession(opts: {
|
|
|
41
46
|
|
|
42
47
|
const id = randomUUID();
|
|
43
48
|
const tmuxName = `claude-${id}`;
|
|
44
|
-
const
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
const routed = opts.startedBy
|
|
50
|
+
? resolveRole("implement", {
|
|
51
|
+
workspaceId: project.workspace_id,
|
|
52
|
+
override: parseOverride(opts.model),
|
|
53
|
+
})
|
|
54
|
+
: null;
|
|
55
|
+
if (routed && routed.provider.id !== ANTHROPIC)
|
|
56
|
+
throw new Error("Another provider's models run only as a chat");
|
|
57
|
+
const model = routed
|
|
58
|
+
? routed.model
|
|
59
|
+
: resolveModelForAgent("claude", opts.model || project.default_model);
|
|
48
60
|
const cwd = project.working_directory.replace(/^~/, os.homedir());
|
|
49
61
|
const naming = await nameFor(prompt, cwd, opts.name);
|
|
50
62
|
|
|
@@ -64,10 +76,10 @@ export async function spawnSession(opts: {
|
|
|
64
76
|
project.id,
|
|
65
77
|
"local"
|
|
66
78
|
);
|
|
67
|
-
db.prepare(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
79
|
+
db.prepare(
|
|
80
|
+
`UPDATE sessions SET name_source = ?, started_by = ? WHERE id = ?`
|
|
81
|
+
).run(naming.source, opts.startedBy ?? null, id);
|
|
82
|
+
if (routed) setSessionModel(id, routed);
|
|
71
83
|
void naming.refine?.(id);
|
|
72
84
|
await launchClaude({ sessionId: id, tmuxName, cwd, model, prompt });
|
|
73
85
|
return queries.getSession(db).get(id) as Session;
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1472,6 +1472,27 @@ const migrations: Migration[] = [
|
|
|
1472
1472
|
db.exec(`ALTER TABLE chat_compactions ADD COLUMN phase TEXT`);
|
|
1473
1473
|
},
|
|
1474
1474
|
},
|
|
1475
|
+
{
|
|
1476
|
+
id: 65,
|
|
1477
|
+
name: "orchestrator_triage",
|
|
1478
|
+
up: (db) => {
|
|
1479
|
+
// started_by: the orchestrator session that started this one (null: a
|
|
1480
|
+
// person). plan_decided: 1 planned, 0 no plan, null not stated.
|
|
1481
|
+
const add = (table: string, col: string, type: string) => {
|
|
1482
|
+
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as {
|
|
1483
|
+
name: string;
|
|
1484
|
+
}[];
|
|
1485
|
+
if (!columns.some((c) => c.name === col))
|
|
1486
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
|
|
1487
|
+
};
|
|
1488
|
+
add("sessions", "started_by", "TEXT");
|
|
1489
|
+
add("sessions", "plan_decided", "INTEGER");
|
|
1490
|
+
add("sessions", "plan_reason", "TEXT");
|
|
1491
|
+
add("task_queue", "plan", "INTEGER");
|
|
1492
|
+
add("task_queue", "plan_reason", "TEXT");
|
|
1493
|
+
add("task_queue", "started_by", "TEXT");
|
|
1494
|
+
},
|
|
1495
|
+
},
|
|
1475
1496
|
];
|
|
1476
1497
|
|
|
1477
1498
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/types.ts
CHANGED
|
@@ -20,6 +20,11 @@ export interface Session {
|
|
|
20
20
|
model_overrides?: string | null;
|
|
21
21
|
// A task's plan step: planning on the planning model, then implementing.
|
|
22
22
|
plan_phase?: "planning" | "implementing" | null;
|
|
23
|
+
// The orchestrator session that started this one; null for a person.
|
|
24
|
+
started_by?: string | null;
|
|
25
|
+
// 1 planned, 0 no plan, null not stated; why, in the starter's words.
|
|
26
|
+
plan_decided?: number | null;
|
|
27
|
+
plan_reason?: string | null;
|
|
23
28
|
system_prompt: string | null;
|
|
24
29
|
group_path: string; // Deprecated - use project_id
|
|
25
30
|
project_id: string | null;
|
package/lib/models/plan-step.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
taskOverride,
|
|
19
19
|
} from "./resolve";
|
|
20
20
|
import { ANTHROPIC } from "./roles";
|
|
21
|
+
import { queueEvent } from "../orchestrator/events";
|
|
21
22
|
|
|
22
23
|
export const PLAN_FILE = ".agentos/plan.md";
|
|
23
24
|
|
|
@@ -41,6 +42,52 @@ export function preparePlanDir(worktree: string): void {
|
|
|
41
42
|
fs.writeFileSync(path.join(dir, ".gitignore"), "*\n");
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
const SUMMARY_LINE = 120;
|
|
46
|
+
|
|
47
|
+
// The plan's first few headings and bullets, trimmed: what the orchestrator
|
|
48
|
+
// needs to know what was decided, with no model call.
|
|
49
|
+
export function planSummary(text: string, max = 5): string[] {
|
|
50
|
+
const clean = (l: string) =>
|
|
51
|
+
l
|
|
52
|
+
.replace(/^\s*(?:#+|[-*+]|\d+[.)])\s+/, "")
|
|
53
|
+
.replace(/[*`_]/g, "")
|
|
54
|
+
.trim();
|
|
55
|
+
const trim = (l: string) =>
|
|
56
|
+
l.length > SUMMARY_LINE ? `${l.slice(0, SUMMARY_LINE - 1)}…` : l;
|
|
57
|
+
const lines: { text: string; key: boolean }[] = [];
|
|
58
|
+
let fenced = false;
|
|
59
|
+
for (const raw of text.split("\n")) {
|
|
60
|
+
if (/^\s*```/.test(raw)) {
|
|
61
|
+
fenced = !fenced;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (fenced || !raw.trim() || /^\s*(?:---+|\|)/.test(raw)) continue;
|
|
65
|
+
const t = clean(raw);
|
|
66
|
+
if (t)
|
|
67
|
+
lines.push({ text: t, key: /^\s*(?:#+|[-*+]|\d+[.)])\s+/.test(raw) });
|
|
68
|
+
}
|
|
69
|
+
const keyed = lines.filter((l) => l.key);
|
|
70
|
+
const picked = (keyed.length >= 3 ? keyed : lines).slice(0, max);
|
|
71
|
+
return picked.map((l) => trim(l.text));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Tells the orchestrator that started a task its plan is written, once.
|
|
75
|
+
function announcePlan(s: Session, ref: string): void {
|
|
76
|
+
if (!s.started_by) return;
|
|
77
|
+
const workspaceId = sessionWorkspace(s);
|
|
78
|
+
if (!workspaceId || !s.worktree_path) return;
|
|
79
|
+
const file = path.join(s.worktree_path, PLAN_FILE);
|
|
80
|
+
let head = `Task "${s.name}": no plan file was written; implementing on ${ref} anyway.`;
|
|
81
|
+
try {
|
|
82
|
+
const summary = planSummary(fs.readFileSync(file, "utf8"));
|
|
83
|
+
head = `Task "${s.name}": plan written to ${file}; implementing on ${ref}.`;
|
|
84
|
+
if (summary.length) head += `\n${summary.map((l) => `- ${l}`).join("\n")}`;
|
|
85
|
+
} catch {
|
|
86
|
+
// No plan file: the line says so.
|
|
87
|
+
}
|
|
88
|
+
queueEvent(workspaceId, `plan-done:${s.id}`, s.id, head);
|
|
89
|
+
}
|
|
90
|
+
|
|
44
91
|
export const implementMessageId = (sessionId: string) =>
|
|
45
92
|
`implement-${sessionId}`;
|
|
46
93
|
|
|
@@ -88,6 +135,11 @@ export async function handoffAfterPlan(
|
|
|
88
135
|
return true;
|
|
89
136
|
})();
|
|
90
137
|
if (!claimed) return false;
|
|
138
|
+
try {
|
|
139
|
+
announcePlan(s, routed.ref);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
console.error("plan-done event failed:", e);
|
|
142
|
+
}
|
|
91
143
|
await applySessionModel(sessionId, s.model_provider ?? ANTHROPIC, routed);
|
|
92
144
|
await send(sessionId, id);
|
|
93
145
|
return true;
|
package/lib/models/roles.ts
CHANGED
|
@@ -91,8 +91,11 @@ export function sessionRole(session: {
|
|
|
91
91
|
role?: string | null;
|
|
92
92
|
task_status?: string | null;
|
|
93
93
|
plan_phase?: string | null;
|
|
94
|
+
started_by?: string | null;
|
|
94
95
|
}): ModelRole {
|
|
95
96
|
if (session.role === "orchestrator") return "orchestrator";
|
|
96
|
-
|
|
97
|
+
// Work an orchestrator started is implementation, not a person's session.
|
|
98
|
+
if (!session.task_status)
|
|
99
|
+
return session.started_by ? "implement" : "interactive";
|
|
97
100
|
return session.plan_phase === "planning" ? "plan" : "implement";
|
|
98
101
|
}
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* the brakes.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
parseOverride,
|
|
9
|
+
sessionModelRef,
|
|
10
|
+
type ModelOverride,
|
|
11
|
+
} from "../models/resolve";
|
|
8
12
|
import { sendMessage } from "../bus";
|
|
9
13
|
import { chatState } from "../chat/runner";
|
|
10
14
|
import { stopSession } from "../sessions/stop";
|
|
@@ -88,10 +92,12 @@ export async function startTask(
|
|
|
88
92
|
after?: string;
|
|
89
93
|
model?: ModelOverride;
|
|
90
94
|
plan?: boolean;
|
|
95
|
+
planReason?: string;
|
|
91
96
|
} = {}
|
|
92
97
|
): Promise<string> {
|
|
93
98
|
const project = workspaceProject(workspaceId, projectRef);
|
|
94
99
|
const model = parseOverride(opts.model) ?? undefined;
|
|
100
|
+
const startedBy = orchestratorId(workspaceId);
|
|
95
101
|
// Its brakes read this machine's usage, not the other machine's account.
|
|
96
102
|
if (isRemoteHost(project.host_id))
|
|
97
103
|
throw new Error(
|
|
@@ -107,6 +113,9 @@ export async function startTask(
|
|
|
107
113
|
name: opts.name,
|
|
108
114
|
view,
|
|
109
115
|
model,
|
|
116
|
+
plan: opts.plan,
|
|
117
|
+
planReason: opts.planReason,
|
|
118
|
+
startedBy,
|
|
110
119
|
baseBranch: opts.base || undefined,
|
|
111
120
|
after: opts.after,
|
|
112
121
|
originWorkspaceId: workspaceId,
|
|
@@ -124,6 +133,8 @@ export async function startTask(
|
|
|
124
133
|
view,
|
|
125
134
|
model,
|
|
126
135
|
plan: opts.plan,
|
|
136
|
+
planReason: opts.planReason,
|
|
137
|
+
startedBy,
|
|
127
138
|
}),
|
|
128
139
|
(s) => s.id
|
|
129
140
|
)
|
|
@@ -133,16 +144,24 @@ export async function startTask(
|
|
|
133
144
|
return `Queued (position ${queued.position}) task "${queued.name}" (id ${queued.id.slice(0, 8)}) in ${project.name}${queued.after ? `, after ${queued.after}` : ""}. It starts by itself; you'll get an event when it does.`;
|
|
134
145
|
}
|
|
135
146
|
const task = out.started;
|
|
136
|
-
|
|
147
|
+
const skipped =
|
|
148
|
+
opts.plan && task.plan_phase !== "planning"
|
|
149
|
+
? ` Plan step skipped: ${view === "terminal" ? "a terminal task has none" : "planning and implementation use the same model"}.`
|
|
150
|
+
: "";
|
|
151
|
+
return `Started task "${task.name}" (id ${task.id.slice(0, 8)}, ${view}) in ${project.name} on ${task.branch_name} (from ${task.base_branch}). It ends in a PR; you'll get an event when it opens.${skipped}`;
|
|
137
152
|
}
|
|
138
153
|
|
|
139
154
|
export async function startSession(
|
|
140
155
|
workspaceId: string,
|
|
141
156
|
projectRef: string,
|
|
142
157
|
prompt: string,
|
|
143
|
-
opts: { name?: string; view?: StartView } = {}
|
|
158
|
+
opts: { name?: string; view?: StartView; model?: string } = {}
|
|
144
159
|
): Promise<string> {
|
|
145
160
|
const project = workspaceProject(workspaceId, projectRef);
|
|
161
|
+
const startedBy = orchestratorId(workspaceId);
|
|
162
|
+
const model = parseOverride(opts.model);
|
|
163
|
+
if (model && typeof model !== "string")
|
|
164
|
+
throw new Error(`model: a session takes one "provider/model"`);
|
|
146
165
|
const view = opts.view ?? DEFAULT_START_VIEW;
|
|
147
166
|
if (view === "chat" && isRemoteHost(project.host_id))
|
|
148
167
|
throw new Error("Spawning on other machines isn't supported yet");
|
|
@@ -159,12 +178,20 @@ export async function startSession(
|
|
|
159
178
|
name: opts.name,
|
|
160
179
|
view,
|
|
161
180
|
access: "full",
|
|
181
|
+
model,
|
|
182
|
+
startedBy,
|
|
162
183
|
})
|
|
163
184
|
).session
|
|
164
|
-
: spawnSession({
|
|
185
|
+
: spawnSession({
|
|
186
|
+
project: project.id,
|
|
187
|
+
prompt,
|
|
188
|
+
name: opts.name,
|
|
189
|
+
model,
|
|
190
|
+
startedBy,
|
|
191
|
+
}),
|
|
165
192
|
(s) => s.id
|
|
166
193
|
);
|
|
167
|
-
return `Started session "${session.name}" (id ${session.id.slice(0, 8)}, ${session.view}) in ${project.name}.`;
|
|
194
|
+
return `Started session "${session.name}" (id ${session.id.slice(0, 8)}, ${session.view}) in ${project.name} on ${sessionModelRef(session)}.`;
|
|
168
195
|
}
|
|
169
196
|
|
|
170
197
|
function itemLine(i: StackItemView): string {
|
|
@@ -36,6 +36,14 @@ Events reach you as short lines from "agentos" (a PR opened, CI finished, a sess
|
|
|
36
36
|
|
|
37
37
|
You may act without asking on anything inside this workspace that's additive or reversible: start, steer, stop and drop sessions and tasks; answer blockers; create, update and move cards; run stacks; review PRs; merge what passes the gates. Your tools only reach this workspace's projects, sessions, tasks and stacks; outside it, you can only message other workspaces' orchestrators.
|
|
38
38
|
|
|
39
|
+
## Triage every job
|
|
40
|
+
|
|
41
|
+
Before you start work, decide what it needs, and hand it to the cheapest model that can do it.
|
|
42
|
+
- Small and clear (a known change, a few files, nothing to discover): start_task with plan false and a plan_reason saying why. Write the steps into the prompt yourself, from what Saad or the card said; do not read code in your own context to do it.
|
|
43
|
+
- Big, unclear or cross-cutting (many files, a design choice, unknown ground): start_task with plan true and a plan_reason. The planning model writes the plan, then the implementation model carries it out; you get one event with the plan file's path and a short summary when it is written.
|
|
44
|
+
- Not in a repository (research, a document, a machine task): start_session, which runs on the implementation model, with the steps in the prompt.
|
|
45
|
+
- Implementation on Opus only when the work needs deep reasoning (a subtle concurrency or security change, say). Pass model and say why. Sonnet is the default.
|
|
46
|
+
|
|
39
47
|
## Merging: the gates
|
|
40
48
|
|
|
41
49
|
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:
|
|
@@ -93,9 +101,9 @@ Reading:
|
|
|
93
101
|
Acting:
|
|
94
102
|
- \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
|
|
95
103
|
- \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
|
|
96
|
-
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan
|
|
104
|
+
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan, plan_reason): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
97
105
|
- \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
|
|
98
|
-
- \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?):
|
|
106
|
+
- \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?, model?): a session without a worktree or PR, a chat unless view is terminal. It runs on the implementation model (Sonnet) unless you pass model; use it for work outside a repository, with the steps in the prompt.
|
|
99
107
|
- \`${TOOL_NAMES.quick_change}\` (project, name): a worktree and branch cut from the project's default branch for a small, low-risk edit you make yourself: a typo, a config value, a one-file fix. It returns the worktree's path; edit there with Write and Edit, and run git there only as \`git -C <path> <status|diff|log|show|add|rm|mv|restore|reset>\`. Anything that needs real work, tests or a long investigation is a task: start_task. At most a few open at once.
|
|
100
108
|
- \`${TOOL_NAMES.quick_pr}\` (change, title, body): commit everything in a quick change, push its branch (never the default branch) and open its PR, or push to the PR it has; its review starts at once. Then it's gated like any task: CI, that review (also its code-review gate), scope, then sign_off or arm. It's in \`sessions\` as your quick change; drop it to throw it away, and it's cleaned up like a task when it merges or is dropped.
|
|
101
109
|
- \`${TOOL_NAMES.stack}\` (target, plan_only?): run a board's open cards as stacked tasks; plan_only shows the plan without starting.
|
|
@@ -89,6 +89,13 @@ function sessionConditions(
|
|
|
89
89
|
ciKey(pr),
|
|
90
90
|
`${who}: ${ciWord(pr)}${pr.checks === "pass" ? " and settled" : ""}`
|
|
91
91
|
);
|
|
92
|
+
// Mergeable for a long while with no check in a repository that has CI:
|
|
93
|
+
// CI probably wasn't triggered. News once per head, never an escalation.
|
|
94
|
+
if (pr.checks === "none" && f.task?.ciSilentMs !== undefined)
|
|
95
|
+
add(
|
|
96
|
+
`ci-silent:${pr.number}:${pr.head ?? ""}`,
|
|
97
|
+
`${who}: PR #${pr.number} at ${(pr.head ?? "").slice(0, 7)} has been mergeable for ${Math.floor(f.task.ciSilentMs / 60000)}m with no CI check registered; CI may not have been triggered on this head`
|
|
98
|
+
);
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
const blocked = f.task?.blocked ?? null;
|
|
@@ -17,7 +17,9 @@ export function ciWord(pr: TaskPR): string {
|
|
|
17
17
|
? `CI failed: ${untrusted("CI", pr.failing)}`
|
|
18
18
|
: "CI failed";
|
|
19
19
|
if (pr.checks === "pending") return "CI pending";
|
|
20
|
-
return
|
|
20
|
+
return pr.mergeable === "CONFLICTING"
|
|
21
|
+
? "conflicts with its base, no CI yet"
|
|
22
|
+
: "no checks yet";
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export const shortId = (id: string) => id.slice(0, 8);
|
|
@@ -35,6 +37,10 @@ function sessionLine(f: SessionFacts): string {
|
|
|
35
37
|
task += `, BLOCKED: ${untrusted(f.name, f.task.blocked)}`;
|
|
36
38
|
parts.push(task);
|
|
37
39
|
}
|
|
40
|
+
if (f.plan)
|
|
41
|
+
parts.push(
|
|
42
|
+
`${f.plan.planned ? "planned" : "no plan"}${f.plan.reason ? `: ${untrusted(f.name, f.plan.reason)}` : ""}`
|
|
43
|
+
);
|
|
38
44
|
if (f.stack) {
|
|
39
45
|
const card = f.stack.ticket ? ` ${f.stack.ticket}` : "";
|
|
40
46
|
parts.push(
|
|
@@ -63,7 +63,7 @@ export type GateTarget =
|
|
|
63
63
|
| { kind: "external"; pr: ExternalPR };
|
|
64
64
|
|
|
65
65
|
const FIELDS =
|
|
66
|
-
"number,url,state,headRefOid,headRefName,baseRefName,isDraft,labels,isCrossRepository,statusCheckRollup,body,title";
|
|
66
|
+
"number,url,state,headRefOid,headRefName,baseRefName,isDraft,labels,isCrossRepository,statusCheckRollup,body,title,mergeable";
|
|
67
67
|
|
|
68
68
|
interface ViewedPR extends ListedPR {
|
|
69
69
|
baseRefName?: string;
|
|
@@ -18,7 +18,8 @@ import { sessionRowInfo } from "../session-meta";
|
|
|
18
18
|
import { taskView, type TaskPR, type TaskState } from "../tasks";
|
|
19
19
|
import { storedPR } from "../tasks/session";
|
|
20
20
|
import { commitTime, repoOf } from "./repo";
|
|
21
|
-
import { ciSettleIn } from "./task-state";
|
|
21
|
+
import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
|
|
22
|
+
import { repoHasCI } from "./repo-ci";
|
|
22
23
|
|
|
23
24
|
export type FactStatus = "running" | "waiting" | "idle" | "dead";
|
|
24
25
|
|
|
@@ -40,6 +41,8 @@ export interface SessionFacts {
|
|
|
40
41
|
branch: string | null;
|
|
41
42
|
// A quick change the orchestrator makes itself (quick.ts): no agent.
|
|
42
43
|
quick?: boolean;
|
|
44
|
+
// The orchestrator's call on a plan step, with its reason, when stated.
|
|
45
|
+
plan?: { planned: boolean; reason: string | null } | null;
|
|
43
46
|
task: {
|
|
44
47
|
state: TaskState;
|
|
45
48
|
pr: TaskPR | null;
|
|
@@ -47,6 +50,9 @@ export interface SessionFacts {
|
|
|
47
50
|
// With CI green on the PR's head: seconds until it counts as settled
|
|
48
51
|
// (task-state.ts), when the CI-green event goes out.
|
|
49
52
|
ciSettleIn?: number;
|
|
53
|
+
// Set once a mergeable head in a repository with CI has had no checks
|
|
54
|
+
// for CHECKLESS_MS (task-state.ts): ms it has been without.
|
|
55
|
+
ciSilentMs?: number;
|
|
50
56
|
} | null;
|
|
51
57
|
stack: {
|
|
52
58
|
id: string;
|
|
@@ -213,6 +219,20 @@ export async function settleIn(
|
|
|
213
219
|
});
|
|
214
220
|
}
|
|
215
221
|
|
|
222
|
+
async function silentFor(
|
|
223
|
+
s: Session,
|
|
224
|
+
pr: TaskPR | null
|
|
225
|
+
): Promise<number | undefined> {
|
|
226
|
+
if (!pr) return;
|
|
227
|
+
const silent = checklessFor(s.id, pr);
|
|
228
|
+
if (silent === undefined || silent < CHECKLESS_MS) return;
|
|
229
|
+
const ci = await repoHasCI(
|
|
230
|
+
repoOf(s),
|
|
231
|
+
`origin/${pr.base ?? s.base_branch ?? "main"}`
|
|
232
|
+
).catch(() => "unknown");
|
|
233
|
+
return ci === "yes" ? silent : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
216
236
|
// A finished task's state is in the database: no gh call for it.
|
|
217
237
|
async function taskFacts(
|
|
218
238
|
workspaceId: string,
|
|
@@ -230,6 +250,7 @@ async function taskFacts(
|
|
|
230
250
|
pr: view.pr,
|
|
231
251
|
blocked: view.blocked,
|
|
232
252
|
ciSettleIn: await settleIn(workspaceId, s, view.pr),
|
|
253
|
+
ciSilentMs: await silentFor(s, view.pr),
|
|
233
254
|
};
|
|
234
255
|
}
|
|
235
256
|
|
|
@@ -260,6 +281,10 @@ export async function sessionFacts(
|
|
|
260
281
|
),
|
|
261
282
|
branch: s.branch_name,
|
|
262
283
|
quick: !!s.quick_of,
|
|
284
|
+
plan:
|
|
285
|
+
s.plan_decided == null
|
|
286
|
+
? null
|
|
287
|
+
: { planned: !!s.plan_decided, reason: s.plan_reason ?? null },
|
|
263
288
|
task,
|
|
264
289
|
stack: stackOf(s.id),
|
|
265
290
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The merge gates, ported from dispatch orchestrate. Each one passes, waits
|
|
3
3
|
* (not ready yet: CI running or settling, no review of this commit yet),
|
|
4
|
-
* fails, or goes straight to Saad (no CI at all
|
|
4
|
+
* fails, or goes straight to Saad (the repository has no CI at all, never
|
|
5
|
+
* just a head with no checks). Only a failure counts
|
|
5
6
|
* against the task, and a gate failing again on a different commit goes to
|
|
6
7
|
* Saad instead of being retried. A failure counts once per task, gate and
|
|
7
8
|
* head commit, whichever path judged it (the armed watcher, CI finishing,
|
|
@@ -12,9 +13,10 @@
|
|
|
12
13
|
|
|
13
14
|
import { db } from "../db";
|
|
14
15
|
import type { CodeReviewGate } from "../tasks/code-review";
|
|
15
|
-
import type { ChecksVerdict } from "../tasks/state";
|
|
16
|
+
import type { ChecksVerdict, TaskPR } from "../tasks/state";
|
|
16
17
|
import type { CheckRow } from "./checks";
|
|
17
18
|
import { APPROVAL_GATES, mergeApprovalsOn } from "./merge-approvals";
|
|
19
|
+
import type { RepoCI } from "./repo-ci";
|
|
18
20
|
import { untrusted } from "./untrusted";
|
|
19
21
|
|
|
20
22
|
export type GateName =
|
|
@@ -37,6 +39,11 @@ export interface GateInput {
|
|
|
37
39
|
failing?: string | null;
|
|
38
40
|
// Seconds until CI on this commit counts as settled (tasks/task-state).
|
|
39
41
|
settleIn: number;
|
|
42
|
+
// GitHub's word on merging the PR into its base, and that base's name.
|
|
43
|
+
mergeable?: TaskPR["mergeable"];
|
|
44
|
+
baseName?: string | null;
|
|
45
|
+
// Whether the repository has CI; only read when the head has no checks.
|
|
46
|
+
repoCI?: RepoCI;
|
|
40
47
|
review: CheckRow | null;
|
|
41
48
|
// A BLOCKED: line, or an approval or question the agent is waiting on.
|
|
42
49
|
blocked: string | null;
|
|
@@ -68,22 +75,48 @@ function ciGate(i: GateInput): GateOutcome {
|
|
|
68
75
|
state: "fail",
|
|
69
76
|
reason: `CI failed on ${at}${i.failing ? ` (${untrusted("CI", i.failing)})` : ""}`,
|
|
70
77
|
};
|
|
78
|
+
if (i.checks === "none") return noChecks(i, at);
|
|
71
79
|
if (i.settleIn > 0)
|
|
72
80
|
return {
|
|
73
81
|
gate,
|
|
74
82
|
state: "wait",
|
|
75
|
-
reason:
|
|
76
|
-
i.checks === "none"
|
|
77
|
-
? `no CI checks have registered on ${at} yet (looking again in ${i.settleIn}s)`
|
|
78
|
-
: `CI is green on ${at} but settling: ${i.settleIn}s more with no new check`,
|
|
83
|
+
reason: `CI is green on ${at} but settling: ${i.settleIn}s more with no new check`,
|
|
79
84
|
};
|
|
80
|
-
|
|
85
|
+
return { gate, state: "pass" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A head with no checks. Only a repository with no CI at all has nothing to
|
|
89
|
+
// gate on; in one with CI the head is not yet, whatever the reason.
|
|
90
|
+
function noChecks(i: GateInput, at: string): GateOutcome {
|
|
91
|
+
const gate = "ci";
|
|
92
|
+
const wait = (reason: string): GateOutcome => ({
|
|
93
|
+
gate,
|
|
94
|
+
state: "wait",
|
|
95
|
+
reason,
|
|
96
|
+
});
|
|
97
|
+
if (i.mergeable === "CONFLICTING")
|
|
98
|
+
return wait(
|
|
99
|
+
`PR conflicts with ${i.baseName ?? "its base"}; CI can't run on ${at} until it's rebased`
|
|
100
|
+
);
|
|
101
|
+
if (i.settleIn > 0)
|
|
102
|
+
return wait(
|
|
103
|
+
`no CI checks have registered on ${at} yet (looking again in ${i.settleIn}s)`
|
|
104
|
+
);
|
|
105
|
+
if (i.repoCI === "no")
|
|
81
106
|
return {
|
|
82
107
|
gate,
|
|
83
108
|
state: "escalate",
|
|
84
|
-
reason: `no CI ran on ${at}: this repository has no checks to gate a merge on`,
|
|
109
|
+
reason: `no CI ran on ${at}: this repository has no checks to gate a merge on (no workflow files, and no check has run on its default branch or recent PRs)`,
|
|
85
110
|
};
|
|
86
|
-
|
|
111
|
+
if (i.repoCI === "yes")
|
|
112
|
+
return wait(
|
|
113
|
+
i.mergeable === "UNKNOWN"
|
|
114
|
+
? `no CI checks on ${at} yet: GitHub is still working out whether it merges cleanly`
|
|
115
|
+
: `no CI checks on ${at} yet: CI hasn't been triggered on this head`
|
|
116
|
+
);
|
|
117
|
+
return wait(
|
|
118
|
+
`no CI checks on ${at}, and whether this repository has CI couldn't be read; looking again`
|
|
119
|
+
);
|
|
87
120
|
}
|
|
88
121
|
|
|
89
122
|
// A stored check of this exact commit: missing, running or broken waits.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a repository has CI at all, decided from the repository and never
|
|
3
|
+
* from one head: workflow files on the base, else any check or status that
|
|
4
|
+
* ever ran on the default branch or the recent PRs. A head with no checks in
|
|
5
|
+
* a repository that has CI is not yet (its PR conflicts, or CI hasn't been
|
|
6
|
+
* triggered); only a repository without CI is one with nothing to gate on.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getDefaultBranch } from "../git";
|
|
10
|
+
import { git } from "../git-exec";
|
|
11
|
+
import { run } from "../tasks/gh";
|
|
12
|
+
import { repoSlug } from "./repo-slug";
|
|
13
|
+
|
|
14
|
+
export type RepoCI = "yes" | "no" | "unknown";
|
|
15
|
+
|
|
16
|
+
const YES_TTL_MS = 60 * 60 * 1000;
|
|
17
|
+
const NO_TTL_MS = 10 * 60 * 1000;
|
|
18
|
+
const cache = new Map<string, { at: number; ci: "yes" | "no" }>();
|
|
19
|
+
|
|
20
|
+
export function clearRepoCI(): void {
|
|
21
|
+
cache.clear();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RepoCIDeps {
|
|
25
|
+
// The files under .github/workflows on `ref`.
|
|
26
|
+
workflows: (repo: string, ref: string) => Promise<string[]>;
|
|
27
|
+
// How many checks or statuses ran on the default branch and the recent
|
|
28
|
+
// PRs; null when GitHub can't say.
|
|
29
|
+
history: (repo: string) => Promise<number | null>;
|
|
30
|
+
now: () => number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const count = (out: string) => {
|
|
34
|
+
const n = Number(out.trim());
|
|
35
|
+
return out.trim() !== "" && Number.isInteger(n) && n >= 0 ? n : null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const repoCIDeps: RepoCIDeps = {
|
|
39
|
+
workflows: async (repo, ref) =>
|
|
40
|
+
(
|
|
41
|
+
await git(repo, ["ls-tree", "--name-only", ref, ".github/workflows/"], {
|
|
42
|
+
timeout: 10000,
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
.split("\n")
|
|
46
|
+
.filter((f) => /\.ya?ml$/i.test(f.trim())),
|
|
47
|
+
history: async (repo) => {
|
|
48
|
+
const slug = await repoSlug(repo);
|
|
49
|
+
if (!slug) return null;
|
|
50
|
+
const branch = await getDefaultBranch(repo);
|
|
51
|
+
const ask = (args: string[]) =>
|
|
52
|
+
run("gh", args, repo, 20000).then(count, () => null);
|
|
53
|
+
const counts = await Promise.all([
|
|
54
|
+
ask([
|
|
55
|
+
"api",
|
|
56
|
+
`repos/${slug}/commits/${branch}/check-runs?per_page=1`,
|
|
57
|
+
"--jq",
|
|
58
|
+
".total_count",
|
|
59
|
+
]),
|
|
60
|
+
ask([
|
|
61
|
+
"api",
|
|
62
|
+
`repos/${slug}/commits/${branch}/status`,
|
|
63
|
+
"--jq",
|
|
64
|
+
".statuses | length",
|
|
65
|
+
]),
|
|
66
|
+
ask([
|
|
67
|
+
"pr",
|
|
68
|
+
"list",
|
|
69
|
+
"--state",
|
|
70
|
+
"all",
|
|
71
|
+
"--limit",
|
|
72
|
+
"20",
|
|
73
|
+
"--json",
|
|
74
|
+
"statusCheckRollup",
|
|
75
|
+
"--jq",
|
|
76
|
+
"[.[] | (.statusCheckRollup // []) | length] | add // 0",
|
|
77
|
+
]),
|
|
78
|
+
]);
|
|
79
|
+
if (counts.some((c) => c !== null && c > 0)) return 1;
|
|
80
|
+
return counts.every((c) => c === 0) ? 0 : null;
|
|
81
|
+
},
|
|
82
|
+
now: Date.now,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// "unknown" when it can't be told (GitHub unreachable or backed off): the
|
|
86
|
+
// ci gate waits on that, it never escalates or passes on a guess.
|
|
87
|
+
export async function repoHasCI(
|
|
88
|
+
repo: string,
|
|
89
|
+
baseRef: string,
|
|
90
|
+
deps: RepoCIDeps = repoCIDeps
|
|
91
|
+
): Promise<RepoCI> {
|
|
92
|
+
const hit = cache.get(repo);
|
|
93
|
+
if (hit && deps.now() - hit.at < (hit.ci === "yes" ? YES_TTL_MS : NO_TTL_MS))
|
|
94
|
+
return hit.ci;
|
|
95
|
+
const found = (ci: "yes" | "no") => {
|
|
96
|
+
cache.set(repo, { at: deps.now(), ci });
|
|
97
|
+
return ci;
|
|
98
|
+
};
|
|
99
|
+
if ((await deps.workflows(repo, baseRef).catch(() => [])).length)
|
|
100
|
+
return found("yes");
|
|
101
|
+
const seen = await deps.history(repo).catch(() => null);
|
|
102
|
+
if (seen === null) return "unknown";
|
|
103
|
+
return found(seen > 0 ? "yes" : "no");
|
|
104
|
+
}
|
|
@@ -107,6 +107,7 @@ export async function runTool(
|
|
|
107
107
|
after: a.after,
|
|
108
108
|
model: a.model,
|
|
109
109
|
plan: a.plan,
|
|
110
|
+
planReason: a.plan_reason,
|
|
110
111
|
});
|
|
111
112
|
}
|
|
112
113
|
case "set_task_model": {
|
|
@@ -122,6 +123,7 @@ export async function runTool(
|
|
|
122
123
|
return startSession(w, a.project, a.prompt, {
|
|
123
124
|
name: a.name,
|
|
124
125
|
view: a.view,
|
|
126
|
+
model: a.model,
|
|
125
127
|
});
|
|
126
128
|
}
|
|
127
129
|
case "quick_change": {
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* commit that was judged. With merge approvals on (merge-approvals.ts),
|
|
4
4
|
* anything touching CI config, build and hook scripts, agent config, deploys
|
|
5
5
|
* or secrets handling goes to Saad whatever the gates say. A repository with
|
|
6
|
-
* no CI
|
|
7
|
-
*
|
|
6
|
+
* no CI (decided from the repository, never from one head with no checks)
|
|
7
|
+
* and a gate failing on two different commits go to him either way. Once a
|
|
8
|
+
* task is with Saad the orchestrator doesn't merge it; he signs it off or drops it.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { MERGED_AS, type MergeMethod } from "../tasks/merge-policy";
|
|
@@ -32,6 +33,7 @@ import { heldVerdict } from "./held";
|
|
|
32
33
|
import { mergeApprovalsOn } from "./merge-approvals";
|
|
33
34
|
import { isPaused } from "./pause";
|
|
34
35
|
import { commitTime, fetchRefs, repoOf } from "./repo";
|
|
36
|
+
import { repoHasCI } from "./repo-ci";
|
|
35
37
|
import { DIFF_CAP, review } from "./review";
|
|
36
38
|
import { workspaceStack, workspaceTask } from "./targets";
|
|
37
39
|
import { ciSettleIn, waitingState } from "./task-state";
|
|
@@ -112,6 +114,8 @@ export async function judge(
|
|
|
112
114
|
checkCount: pr.checkCount ?? 0,
|
|
113
115
|
committedAt: await commitTime(repo, sha),
|
|
114
116
|
}),
|
|
117
|
+
mergeable: pr.mergeable,
|
|
118
|
+
baseName: pr.base ?? task.base_branch,
|
|
115
119
|
review: getCheck(task.id, sha, "review"),
|
|
116
120
|
...(await waitingState(task)),
|
|
117
121
|
fromCard: !!task.lh_card_id,
|
|
@@ -203,11 +207,17 @@ export async function gateVerdict(
|
|
|
203
207
|
}
|
|
204
208
|
}
|
|
205
209
|
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
const ruled = ruleBreaks(files, await addedLines(repo, base, sha));
|
|
211
|
+
const live = await inputs();
|
|
212
|
+
// Asked only when it can change the answer: a head with no checks that
|
|
213
|
+
// isn't settling or conflicting.
|
|
214
|
+
const repoCI =
|
|
215
|
+
live.checks === "none" &&
|
|
216
|
+
live.settleIn === 0 &&
|
|
217
|
+
live.mergeable !== "CONFLICTING"
|
|
218
|
+
? await repoHasCI(repo, base)
|
|
219
|
+
: undefined;
|
|
220
|
+
const outcomes = evaluateGates({ sha, ruleBreaks: ruled, ...live, repoCI });
|
|
211
221
|
const head = `${work.name} (PR #${pr.number} at ${short(sha)}) can't merge:`;
|
|
212
222
|
const toSaad = outcomes.find((o) => o.state === "escalate");
|
|
213
223
|
if (toSaad)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// live inputs to the blocked and ci gates.
|
|
3
3
|
|
|
4
4
|
import type { Session } from "../db";
|
|
5
|
+
import type { TaskPR } from "../tasks/state";
|
|
5
6
|
import { chatStateNow } from "../chat/runner";
|
|
6
7
|
import { saidSinceLastMessage } from "../chat/store";
|
|
7
8
|
import { blockedReason } from "../tasks/state";
|
|
@@ -139,3 +140,33 @@ export function ciSettleIn(input: {
|
|
|
139
140
|
const settledAt = Math.max(input.committedAt, changedAt) + SETTLE_S;
|
|
140
141
|
return Math.max(0, settledAt - now);
|
|
141
142
|
}
|
|
143
|
+
|
|
144
|
+
// A head with no checks for this long after its PR became mergeable, in a
|
|
145
|
+
// repository with CI, is news for the orchestrator (never an escalation).
|
|
146
|
+
export const CHECKLESS_MS = 30 * 60 * 1000;
|
|
147
|
+
|
|
148
|
+
const checkless = new Map<string, number>();
|
|
149
|
+
|
|
150
|
+
// How long this head has had no checks while its PR could merge cleanly, in
|
|
151
|
+
// ms, or undefined when it isn't in that state. In memory: a restart only
|
|
152
|
+
// restarts the clock.
|
|
153
|
+
export function checklessFor(
|
|
154
|
+
taskId: string,
|
|
155
|
+
pr: Pick<TaskPR, "state" | "checks" | "head" | "mergeable">,
|
|
156
|
+
now = Date.now()
|
|
157
|
+
): number | undefined {
|
|
158
|
+
const key = `${taskId}:${pr.head ?? ""}`;
|
|
159
|
+
for (const k of checkless.keys())
|
|
160
|
+
if (k.startsWith(`${taskId}:`) && k !== key) checkless.delete(k);
|
|
161
|
+
if (
|
|
162
|
+
pr.state !== "OPEN" ||
|
|
163
|
+
pr.checks !== "none" ||
|
|
164
|
+
pr.mergeable !== "MERGEABLE"
|
|
165
|
+
) {
|
|
166
|
+
checkless.delete(key);
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
const since = checkless.get(key) ?? now;
|
|
170
|
+
checkless.set(key, since);
|
|
171
|
+
return now - since;
|
|
172
|
+
}
|
|
@@ -105,9 +105,16 @@ export const TOOL_SHAPES = {
|
|
|
105
105
|
model: modelOverride,
|
|
106
106
|
plan: z
|
|
107
107
|
.boolean()
|
|
108
|
-
.optional()
|
|
109
108
|
.describe(
|
|
110
|
-
"
|
|
109
|
+
"Your decision for this job: true to plan first on the planning model, then implement on the implementation model; false to skip the plan and put the steps in the prompt yourself"
|
|
110
|
+
),
|
|
111
|
+
plan_reason: z
|
|
112
|
+
.string()
|
|
113
|
+
.trim()
|
|
114
|
+
.min(3)
|
|
115
|
+
.max(200)
|
|
116
|
+
.describe(
|
|
117
|
+
'Why, in a few words Saad reads on the task card: "touches 6 files across gates and UI", "one-line default change"'
|
|
111
118
|
),
|
|
112
119
|
},
|
|
113
120
|
set_task_model: {
|
|
@@ -134,6 +141,11 @@ export const TOOL_SHAPES = {
|
|
|
134
141
|
.optional()
|
|
135
142
|
.describe("A short name, 2-6 words (default: one made from the prompt)"),
|
|
136
143
|
view,
|
|
144
|
+
model: modelRef
|
|
145
|
+
.optional()
|
|
146
|
+
.describe(
|
|
147
|
+
'One model for the session, "provider/model"; default: the implementation model. Opus only when the work needs deep reasoning'
|
|
148
|
+
),
|
|
137
149
|
},
|
|
138
150
|
quick_change: {
|
|
139
151
|
project,
|
|
@@ -61,11 +61,11 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
61
61
|
message_orchestrator:
|
|
62
62
|
"Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
|
|
63
63
|
start_task:
|
|
64
|
-
"Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Refused while a brake holds.",
|
|
64
|
+
"Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason: plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. Refused while a brake holds.",
|
|
65
65
|
set_task_model:
|
|
66
66
|
'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
|
|
67
67
|
start_session:
|
|
68
|
-
|
|
68
|
+
'Start an agent session in a project with a prompt, for work that isn\'t a PR task. It runs on the implementation model unless you give model (one "provider/model"; Opus only when the work needs deep reasoning), as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.',
|
|
69
69
|
quick_change:
|
|
70
70
|
"Make a small, low-risk edit yourself (a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. You may write there (Write, Edit) and run git there as `git -C <path> ...` (status, diff, add, rm, mv, restore, reset, log, show); nowhere else in a project. Anything needing real work, tests or an investigation is a task (start_task).",
|
|
71
71
|
quick_pr:
|
package/lib/sessions/launch.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { db, queries, type Session } from "../db";
|
|
|
11
11
|
import type { AgentType } from "../providers";
|
|
12
12
|
import type { ChatAccess, ChatImage } from "../chat/events";
|
|
13
13
|
import { resolveModelForAgent } from "../model-catalog";
|
|
14
|
-
import { resolveRole, setSessionModel } from "../models/resolve";
|
|
14
|
+
import { parseOverride, resolveRole, setSessionModel } from "../models/resolve";
|
|
15
15
|
import { ANTHROPIC } from "../models/roles";
|
|
16
16
|
import { getProject } from "../projects";
|
|
17
17
|
import { getHost, isRemoteHost } from "../hosts";
|
|
@@ -71,6 +71,9 @@ export interface LaunchInput {
|
|
|
71
71
|
name?: string | null;
|
|
72
72
|
view?: "chat" | "terminal";
|
|
73
73
|
parentSessionId?: string | null;
|
|
74
|
+
// The orchestrator session that starts it: it runs on the implementation
|
|
75
|
+
// role's model (or `model`), not the interactive one's.
|
|
76
|
+
startedBy?: string | null;
|
|
74
77
|
groupPath?: string;
|
|
75
78
|
systemPrompt?: string | null;
|
|
76
79
|
}
|
|
@@ -222,10 +225,20 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
222
225
|
: DEFAULT_START_VIEW;
|
|
223
226
|
// Claude with no model picked runs on the interactive role's, here; on
|
|
224
227
|
// another provider only as a chat, whose worker carries its endpoint.
|
|
228
|
+
const role = input.startedBy ? "implement" : "interactive";
|
|
229
|
+
// An orchestrator's explicit model is validated like a task's.
|
|
230
|
+
const picked = input.startedBy ? parseOverride(input.model) : null;
|
|
231
|
+
if (picked && typeof picked !== "string")
|
|
232
|
+
throw new Error(`model: a session takes one "provider/model"`);
|
|
225
233
|
const routed =
|
|
226
|
-
agentType === "claude" && !input.model?.trim() && !remote
|
|
227
|
-
? resolveRole(
|
|
234
|
+
agentType === "claude" && (picked || !input.model?.trim()) && !remote
|
|
235
|
+
? resolveRole(role, {
|
|
236
|
+
workspaceId: project?.workspace_id,
|
|
237
|
+
override: picked,
|
|
238
|
+
})
|
|
228
239
|
: null;
|
|
240
|
+
if (picked && routed && routed.provider.id !== ANTHROPIC && view !== "chat")
|
|
241
|
+
throw new Error("Another provider's models run only as a chat");
|
|
229
242
|
const interactive =
|
|
230
243
|
routed && (routed.provider.id === ANTHROPIC || view === "chat")
|
|
231
244
|
? routed
|
|
@@ -264,8 +277,14 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
264
277
|
runOn
|
|
265
278
|
);
|
|
266
279
|
db.prepare(
|
|
267
|
-
`UPDATE sessions SET view = ?, name_source = ?, setup_status = ? WHERE id = ?`
|
|
268
|
-
).run(
|
|
280
|
+
`UPDATE sessions SET view = ?, name_source = ?, setup_status = ?, started_by = ? WHERE id = ?`
|
|
281
|
+
).run(
|
|
282
|
+
view,
|
|
283
|
+
naming.source,
|
|
284
|
+
useWorktree ? "running" : null,
|
|
285
|
+
input.startedBy ?? null,
|
|
286
|
+
id
|
|
287
|
+
);
|
|
269
288
|
if (interactive) setSessionModel(id, interactive);
|
|
270
289
|
if (input.access)
|
|
271
290
|
db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
|
package/lib/tasks/index.ts
CHANGED
|
@@ -96,6 +96,8 @@ export interface TaskView {
|
|
|
96
96
|
// plan step (lib/models).
|
|
97
97
|
model?: string;
|
|
98
98
|
planning?: boolean;
|
|
99
|
+
// What its starter decided about a plan step, and why; null if unstated.
|
|
100
|
+
plan?: { planned: boolean; reason: string | null } | null;
|
|
99
101
|
// A quick change: the orchestrator session that makes it (no agent).
|
|
100
102
|
quickOf?: string | null;
|
|
101
103
|
}
|
|
@@ -111,6 +113,10 @@ export async function createTask(opts: {
|
|
|
111
113
|
model?: ModelOverride;
|
|
112
114
|
// Plan first on the planning model (Settings' default otherwise).
|
|
113
115
|
plan?: boolean;
|
|
116
|
+
// Why the starter chose to plan or not: shown on the task's card.
|
|
117
|
+
planReason?: string | null;
|
|
118
|
+
// The orchestrator session that started it (null: a person).
|
|
119
|
+
startedBy?: string | null;
|
|
114
120
|
// Started from this LumifyHub card, which the task then moves.
|
|
115
121
|
cardId?: string;
|
|
116
122
|
// Stacked: cut from another task's pushed branch, at this exact commit.
|
|
@@ -247,7 +253,8 @@ async function startTask(
|
|
|
247
253
|
// Everything the launch needs is on the row, so a restart can resume it.
|
|
248
254
|
db.prepare(
|
|
249
255
|
`UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
|
|
250
|
-
model_provider = ?, model_overrides = ?, plan_phase =
|
|
256
|
+
model_provider = ?, model_overrides = ?, plan_phase = ?,
|
|
257
|
+
plan_decided = ?, plan_reason = ?, started_by = ? WHERE id = ?`
|
|
251
258
|
).run(
|
|
252
259
|
prompt,
|
|
253
260
|
buildTaskBrief({
|
|
@@ -260,6 +267,9 @@ async function startTask(
|
|
|
260
267
|
routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
|
|
261
268
|
routing.override ? JSON.stringify(routing.override) : null,
|
|
262
269
|
routing.plan ? "planning" : null,
|
|
270
|
+
opts.plan === undefined ? null : opts.plan ? 1 : 0,
|
|
271
|
+
opts.planReason?.trim() || null,
|
|
272
|
+
opts.startedBy ?? null,
|
|
263
273
|
id
|
|
264
274
|
);
|
|
265
275
|
opts.onCreated?.(id);
|
|
@@ -376,6 +386,13 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
376
386
|
armed: !!session.merge_armed_at,
|
|
377
387
|
model: sessionModelRef(session),
|
|
378
388
|
planning: session.plan_phase === "planning",
|
|
389
|
+
plan:
|
|
390
|
+
session.plan_decided == null
|
|
391
|
+
? null
|
|
392
|
+
: {
|
|
393
|
+
planned: !!session.plan_decided,
|
|
394
|
+
reason: session.plan_reason ?? null,
|
|
395
|
+
},
|
|
379
396
|
quickOf: session.quick_of ?? null,
|
|
380
397
|
};
|
|
381
398
|
}
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -44,6 +44,11 @@ export interface QueueRow {
|
|
|
44
44
|
model: string | null;
|
|
45
45
|
// A per-role model map (lib/models), when that's what it was given.
|
|
46
46
|
model_overrides: string | null;
|
|
47
|
+
// The starter's plan decision (1/0, null: not stated), its reason, and the
|
|
48
|
+
// orchestrator session that started it.
|
|
49
|
+
plan: number | null;
|
|
50
|
+
plan_reason: string | null;
|
|
51
|
+
started_by: string | null;
|
|
47
52
|
view: "chat" | "terminal" | null;
|
|
48
53
|
base_branch: string | null;
|
|
49
54
|
host_id: string | null;
|
|
@@ -205,6 +210,9 @@ export interface QueueRequest {
|
|
|
205
210
|
prompt: string;
|
|
206
211
|
name?: string;
|
|
207
212
|
model?: ModelOverride;
|
|
213
|
+
plan?: boolean;
|
|
214
|
+
planReason?: string | null;
|
|
215
|
+
startedBy?: string | null;
|
|
208
216
|
view?: "chat" | "terminal";
|
|
209
217
|
baseBranch?: string;
|
|
210
218
|
hostId?: string;
|
|
@@ -251,9 +259,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
251
259
|
if (!req.prompt.trim()) throw new Error("Describe the task");
|
|
252
260
|
const id = randomUUID();
|
|
253
261
|
db.prepare(
|
|
254
|
-
`INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, view, base_branch,
|
|
262
|
+
`INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, started_by, view, base_branch,
|
|
255
263
|
host_id, after_task, after_any, origin_workspace_id, position)
|
|
256
|
-
VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @view, @base_branch,
|
|
264
|
+
VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @started_by, @view, @base_branch,
|
|
257
265
|
@host_id, @after_task, @after_any, @origin,
|
|
258
266
|
(SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
|
|
259
267
|
).run({
|
|
@@ -266,6 +274,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
266
274
|
req.model && typeof req.model === "object"
|
|
267
275
|
? JSON.stringify(req.model)
|
|
268
276
|
: null,
|
|
277
|
+
plan: req.plan === undefined ? null : req.plan ? 1 : 0,
|
|
278
|
+
plan_reason: req.planReason?.trim() || null,
|
|
279
|
+
started_by: req.startedBy ?? null,
|
|
269
280
|
view: req.view ?? null,
|
|
270
281
|
base_branch: req.baseBranch || null,
|
|
271
282
|
host_id: req.hostId || null,
|
|
@@ -440,6 +451,9 @@ async function startRow(
|
|
|
440
451
|
model: r.model_overrides
|
|
441
452
|
? (JSON.parse(r.model_overrides) as ModelOverride)
|
|
442
453
|
: (r.model ?? undefined),
|
|
454
|
+
plan: r.plan == null ? undefined : !!r.plan,
|
|
455
|
+
planReason: r.plan_reason,
|
|
456
|
+
startedBy: r.started_by,
|
|
443
457
|
view: r.view ?? undefined,
|
|
444
458
|
baseBranch: r.base_branch ?? undefined,
|
|
445
459
|
hostId: r.host_id ?? undefined,
|
package/lib/usage/compare.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface CompareTurn extends ModelTurnRow {
|
|
|
35
35
|
export interface TaskOutcome {
|
|
36
36
|
id: string;
|
|
37
37
|
status: string | null;
|
|
38
|
+
// The decision on a plan step: null when none was recorded.
|
|
39
|
+
planned: boolean | null;
|
|
38
40
|
// The first finished CI run, and review, on its PR: passed or not; null
|
|
39
41
|
// when it never had one (no PR, or not under an orchestrator).
|
|
40
42
|
ciFirstGreen: boolean | null;
|
|
@@ -74,11 +76,19 @@ export interface RoleModelRow extends TokenSpend {
|
|
|
74
76
|
wallMs: number;
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
// Tasks split by whether the orchestrator chose a plan step for them.
|
|
80
|
+
export interface PlanSplitRow {
|
|
81
|
+
key: "planned" | "unplanned" | "unstated";
|
|
82
|
+
tasks: number;
|
|
83
|
+
spend: TokenSpend;
|
|
84
|
+
}
|
|
85
|
+
|
|
77
86
|
export interface Comparison {
|
|
78
87
|
from: number;
|
|
79
88
|
to: number;
|
|
80
89
|
cohorts: Cohort[];
|
|
81
90
|
roles: RoleModelRow[];
|
|
91
|
+
plans: PlanSplitRow[];
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
const rate = (): Rate => ({ yes: 0, of: 0 });
|
|
@@ -154,6 +164,17 @@ export function compareRuns(
|
|
|
154
164
|
}
|
|
155
165
|
for (const c of cohorts.values()) c.smallSample = c.tasks < SMALL_SAMPLE;
|
|
156
166
|
|
|
167
|
+
const plans = new Map<PlanSplitRow["key"], PlanSplitRow>();
|
|
168
|
+
for (const [id, ts] of byTask) {
|
|
169
|
+
const planned = outcomes.get(id)!.planned;
|
|
170
|
+
const key =
|
|
171
|
+
planned === null ? "unstated" : planned ? "planned" : "unplanned";
|
|
172
|
+
let r = plans.get(key);
|
|
173
|
+
if (!r) plans.set(key, (r = { key, tasks: 0, spend: emptySpend() }));
|
|
174
|
+
r.tasks += 1;
|
|
175
|
+
for (const t of ts) addRow(r.spend, t, prices);
|
|
176
|
+
}
|
|
177
|
+
|
|
157
178
|
const roles = new Map<string, RoleModelRow>();
|
|
158
179
|
for (const t of inRange) {
|
|
159
180
|
const { provider, model } = rowModel(t);
|
|
@@ -179,6 +200,9 @@ export function compareRuns(
|
|
|
179
200
|
roles: [...roles.values()].sort(
|
|
180
201
|
(a, b) => a.role.localeCompare(b.role) || b.costUsd - a.costUsd
|
|
181
202
|
),
|
|
203
|
+
plans: (["planned", "unplanned", "unstated"] as const)
|
|
204
|
+
.map((k) => plans.get(k))
|
|
205
|
+
.filter((r): r is PlanSplitRow => !!r),
|
|
182
206
|
};
|
|
183
207
|
}
|
|
184
208
|
|
|
@@ -201,10 +225,14 @@ export function taskOutcomes(ids: string[]): Map<string, TaskOutcome> {
|
|
|
201
225
|
const marks = ids.map(() => "?").join(",");
|
|
202
226
|
const tasks = db
|
|
203
227
|
.prepare(
|
|
204
|
-
`SELECT id, task_status FROM sessions
|
|
228
|
+
`SELECT id, task_status, plan_decided FROM sessions
|
|
205
229
|
WHERE id IN (${marks}) AND task_status IS NOT NULL`
|
|
206
230
|
)
|
|
207
|
-
.all(...ids) as {
|
|
231
|
+
.all(...ids) as {
|
|
232
|
+
id: string;
|
|
233
|
+
task_status: string;
|
|
234
|
+
plan_decided: number | null;
|
|
235
|
+
}[];
|
|
208
236
|
const checks = db
|
|
209
237
|
.prepare(
|
|
210
238
|
`SELECT id, session_id, sha, kind, status FROM orchestrator_checks
|
|
@@ -227,6 +255,7 @@ export function taskOutcomes(ids: string[]): Map<string, TaskOutcome> {
|
|
|
227
255
|
out.set(t.id, {
|
|
228
256
|
id: t.id,
|
|
229
257
|
status: t.task_status,
|
|
258
|
+
planned: t.plan_decided === null ? null : t.plan_decided === 1,
|
|
230
259
|
ciFirstGreen: null,
|
|
231
260
|
reviewFirstPass: null,
|
|
232
261
|
gateFailures: 0,
|
package/lib/usage/turns.ts
CHANGED
|
@@ -60,7 +60,7 @@ export function recordTurn(
|
|
|
60
60
|
const current = db
|
|
61
61
|
.prepare(
|
|
62
62
|
`SELECT name, workspace_id, model, model_provider, role, task_status,
|
|
63
|
-
plan_phase FROM sessions WHERE id = ?`
|
|
63
|
+
plan_phase, started_by FROM sessions WHERE id = ?`
|
|
64
64
|
)
|
|
65
65
|
.get(session.id) as
|
|
66
66
|
| Pick<
|
|
@@ -72,6 +72,7 @@ export function recordTurn(
|
|
|
72
72
|
| "role"
|
|
73
73
|
| "task_status"
|
|
74
74
|
| "plan_phase"
|
|
75
|
+
| "started_by"
|
|
75
76
|
>
|
|
76
77
|
| undefined;
|
|
77
78
|
const provider = current?.model_provider ?? ANTHROPIC;
|