@saadnvd1/agent-os 0.3.485 → 0.3.487

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 CHANGED
@@ -464,7 +464,9 @@ default; set it in **Settings → Workspaces**, or `PATCH /api/workspaces/<id>`
464
464
  worktree or agent yet, and starts by itself in line order when one finishes
465
465
  (merged, dropped or done). `aos task --after <task|any>` (and the
466
466
  orchestrator's `start_task` with `after`) holds a task until a named task
467
- finishes, or until any task running now does. Pause stops automatic starts,
467
+ finishes, or until any task still working now does (a running task whose PR
468
+ was closed or merged doesn't count; with none, it starts at once). The
469
+ orchestrator's `drop` removes a queued task by its id. Pause stops automatic starts,
468
470
  and the orchestrator's brakes still apply when its queued tasks start. The
469
471
  line is kept in the database across restarts (`GET /api/tasks/queue`; start
470
472
  now, move up or down, or remove with `/api/tasks/queue/<id>`). The sidebar
@@ -494,8 +496,13 @@ The most specific setting wins: a task's own model, then its workspace's
494
496
  `AGENTOS_REVIEW_MODEL` (gate review and scope check) and
495
497
  `AGENTOS_TITLE_MODEL` (helpers) in the server's environment still replace a
496
498
  role's default where Settings names none. A
497
- Claude session started without a model (the orchestrator's `start_session`,
498
- the API) runs on the interactive role's; one you pick a model for keeps it.
499
+ Claude session you start without a model runs on the interactive role's. One
500
+ an orchestrator starts (its `start_session`, or `aos spawn` run from an
501
+ orchestrator) is recorded as started by it (`started_by`) and runs on the
502
+ implementation role instead, so delegated work doesn't use Opus by default;
503
+ `start_session` takes a `model` (`"provider/model"`, checked like a task's:
504
+ gate roles can't be set, other providers must be enabled). One you pick a
505
+ model for keeps it.
499
506
 
500
507
  **Plan, then implement.** A chat task whose planning and implementation
501
508
  models differ starts with a plan step: its first turn runs on the planning
@@ -503,7 +510,13 @@ model and writes the plan to `.agentos/plan.md` in its worktree (ignored by
503
510
  git), changing nothing else. When that turn ends, the same conversation
504
511
  switches to the implementation model (a live model switch, or a resume with
505
512
  `--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.
513
+ off in Settings, or per task with `plan: false`. Terminal tasks skip it. An
514
+ orchestrator decides it for every task: `start_task` requires `plan` and a
515
+ short `plan_reason`, stored on the task and shown on its card ("Planned:
516
+ touches 6 files across gates and UI" / "No plan: one-line default change"),
517
+ and **Usage → Compare** splits cost into planned and not. When the plan step
518
+ finishes, the orchestrator gets one event with the plan file's path and a
519
+ 3-5 line summary; the handoff to implementation stays automatic.
507
520
 
508
521
  **Per task.** The task draft has a **Model** chip ("Routed by role", or one
509
522
  model); `POST /api/tasks` and the orchestrator's `start_task` take `model`, as
@@ -1044,6 +1057,11 @@ it may do on its own, and the lines that always come back to you as asks.
1044
1057
  approve, answer an ask or pass a gate. The route
1045
1058
  they call answers only the orchestrator's worker, by a per-orchestrator
1046
1059
  secret. Its shell runs only `aos` commands that read.
1060
+ - **Triage:** its brief has it decide each job: small and clear is a task with
1061
+ `plan: false` and the steps written into the brief; big, unclear or
1062
+ cross-cutting is `plan: true`; work outside a repository is a
1063
+ `start_session` (Sonnet) with the steps in the prompt. It puts
1064
+ implementation on Opus only when the work needs deep reasoning, and says why.
1047
1065
  - **Files:** it reads anywhere and writes (Write, Edit, NotebookEdit) anywhere
1048
1066
  except a project repository, a task worktree (`~/.agent-os/worktrees`),
1049
1067
  AgentOS's own install and state (all of `~/.agent-os` but its own folder),
@@ -1113,7 +1131,11 @@ it may do on its own, and the lines that always come back to you as asks.
1113
1131
  nothing uncommitted, AgentOS rebases it in the task's worktree and pushes
1114
1132
  only its branch with `--force-with-lease`, and the orchestrator's review
1115
1133
  carries over when the PR's own patch is
1116
- unchanged (same `git patch-id`; a fresh review starts otherwise). Not
1134
+ unchanged (same `git patch-id`; a fresh review starts otherwise). Your
1135
+ merge approval carries over a rebase the same way (any rebase, the task's
1136
+ own included): when the PR's patch is unchanged it follows the new head,
1137
+ else you're asked again, naming the new head and what changed, and an open
1138
+ merge ask follows its PR's head. Not
1117
1139
  clean: the task is asked once to rebase. Either way, one event. Main and
1118
1140
  other tasks' branches are never written.
1119
1141
  - **Tasks woken on CI failures and reviews:** a check failing on a task's
@@ -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) {
@@ -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&apos;s real measure is its
204
215
  5-hour window, shared out by how far each turn moved it (an estimate).
205
- Outcomes come from the orchestrator&apos;s checks, so only its tasks
206
- have them. Under 10 tasks a column is a small sample: read it as a hint,
207
- not a result.
216
+ Planned or not is the orchestrator&apos;s own call, recorded when it
217
+ started the task. Outcomes come from the orchestrator&apos;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
  );
@@ -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 model = resolveModelForAgent(
45
- "claude",
46
- opts.model || project.default_model
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(`UPDATE sessions SET name_source = ? WHERE id = ?`).run(
68
- naming.source,
69
- id
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;
@@ -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;
@@ -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;
@@ -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
- if (!session.task_status) return "interactive";
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
  }
@@ -4,7 +4,11 @@
4
4
  * the brakes.
5
5
  */
6
6
 
7
- import { parseOverride, type ModelOverride } from "../models/resolve";
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";
@@ -16,7 +20,12 @@ import {
16
20
  type StartView,
17
21
  } from "../sessions/launch";
18
22
  import { createTask, dropTask } from "../tasks";
19
- import { startOrQueue } from "../tasks/queue";
23
+ import {
24
+ findQueued,
25
+ queuedLabel,
26
+ removeQueued,
27
+ startOrQueue,
28
+ } from "../tasks/queue";
20
29
  import { getStack, previewStack, startStack } from "../stacks";
21
30
  import { treeOrder } from "../stacks/tree";
22
31
  import type { StackItemView } from "../stacks/types";
@@ -88,10 +97,12 @@ export async function startTask(
88
97
  after?: string;
89
98
  model?: ModelOverride;
90
99
  plan?: boolean;
100
+ planReason?: string;
91
101
  } = {}
92
102
  ): Promise<string> {
93
103
  const project = workspaceProject(workspaceId, projectRef);
94
104
  const model = parseOverride(opts.model) ?? undefined;
105
+ const startedBy = orchestratorId(workspaceId);
95
106
  // Its brakes read this machine's usage, not the other machine's account.
96
107
  if (isRemoteHost(project.host_id))
97
108
  throw new Error(
@@ -107,6 +118,9 @@ export async function startTask(
107
118
  name: opts.name,
108
119
  view,
109
120
  model,
121
+ plan: opts.plan,
122
+ planReason: opts.planReason,
123
+ startedBy,
110
124
  baseBranch: opts.base || undefined,
111
125
  after: opts.after,
112
126
  originWorkspaceId: workspaceId,
@@ -124,6 +138,8 @@ export async function startTask(
124
138
  view,
125
139
  model,
126
140
  plan: opts.plan,
141
+ planReason: opts.planReason,
142
+ startedBy,
127
143
  }),
128
144
  (s) => s.id
129
145
  )
@@ -133,16 +149,24 @@ export async function startTask(
133
149
  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
150
  }
135
151
  const task = out.started;
136
- 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.`;
152
+ const skipped =
153
+ opts.plan && task.plan_phase !== "planning"
154
+ ? ` Plan step skipped: ${view === "terminal" ? "a terminal task has none" : "planning and implementation use the same model"}.`
155
+ : "";
156
+ 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
157
  }
138
158
 
139
159
  export async function startSession(
140
160
  workspaceId: string,
141
161
  projectRef: string,
142
162
  prompt: string,
143
- opts: { name?: string; view?: StartView } = {}
163
+ opts: { name?: string; view?: StartView; model?: string } = {}
144
164
  ): Promise<string> {
145
165
  const project = workspaceProject(workspaceId, projectRef);
166
+ const startedBy = orchestratorId(workspaceId);
167
+ const model = parseOverride(opts.model);
168
+ if (model && typeof model !== "string")
169
+ throw new Error(`model: a session takes one "provider/model"`);
146
170
  const view = opts.view ?? DEFAULT_START_VIEW;
147
171
  if (view === "chat" && isRemoteHost(project.host_id))
148
172
  throw new Error("Spawning on other machines isn't supported yet");
@@ -159,12 +183,20 @@ export async function startSession(
159
183
  name: opts.name,
160
184
  view,
161
185
  access: "full",
186
+ model,
187
+ startedBy,
162
188
  })
163
189
  ).session
164
- : spawnSession({ project: project.id, prompt, name: opts.name }),
190
+ : spawnSession({
191
+ project: project.id,
192
+ prompt,
193
+ name: opts.name,
194
+ model,
195
+ startedBy,
196
+ }),
165
197
  (s) => s.id
166
198
  );
167
- return `Started session "${session.name}" (id ${session.id.slice(0, 8)}, ${session.view}) in ${project.name}.`;
199
+ return `Started session "${session.name}" (id ${session.id.slice(0, 8)}, ${session.view}) in ${project.name} on ${sessionModelRef(session)}.`;
168
200
  }
169
201
 
170
202
  function itemLine(i: StackItemView): string {
@@ -208,7 +240,27 @@ export async function drop(
208
240
  reason: string
209
241
  ): Promise<string> {
210
242
  if (!reason.trim()) throw new Error("Say why it's dropped");
211
- const task = workspaceTask(workspaceId, ref);
243
+ // A task still in the queue has no session yet: its id removes it.
244
+ const removeQueuedTask = (
245
+ queued: NonNullable<ReturnType<typeof findQueued>>
246
+ ) => {
247
+ removeQueued(queued.id);
248
+ addNote(
249
+ workspaceId,
250
+ `Removed queued task ${queuedLabel(queued)} before it started: ${reason.trim()}`
251
+ );
252
+ return `Removed queued task "${queuedLabel(queued)}" (id ${queued.id.slice(0, 8)}) from the queue; it never started.`;
253
+ };
254
+ const byId = findQueued(workspaceId, ref);
255
+ if (byId) return removeQueuedTask(byId);
256
+ let task;
257
+ try {
258
+ task = workspaceTask(workspaceId, ref);
259
+ } catch (error) {
260
+ const byName = findQueued(workspaceId, ref, { byName: true });
261
+ if (byName) return removeQueuedTask(byName);
262
+ throw error;
263
+ }
212
264
  await dropTask(task.id);
213
265
  addNote(workspaceId, `Dropped task ${task.name}: ${reason.trim()}`);
214
266
  return `Dropped ${task.name}: its PR is closed and its worktree removed.`;
@@ -0,0 +1,48 @@
1
+ // Saad's merge approval across a rebase. He approved a commit; the task
2
+ // rebased onto the base (only to renumber a migration, say) and its head
3
+ // moved. The approval was for the PR's patch, so it follows the head when
4
+ // that patch is unchanged (the check the review's carry-over uses, patch.ts),
5
+ // and otherwise is asked again, with a note on what changed.
6
+
7
+ import type { AskRow } from "./asks";
8
+ import { getAsk } from "./asks";
9
+ import { rebindApproval } from "./ask-approvals";
10
+ import { addNote } from "./notes";
11
+ import { comparePatches } from "./patch";
12
+ import { fetchRefs } from "./repo";
13
+ import type { Session } from "../db";
14
+
15
+ const short = (sha: string) => sha.slice(0, 7);
16
+
17
+ export async function carryApproval(
18
+ workspaceId: string,
19
+ work: {
20
+ name: string;
21
+ repo: string;
22
+ refs: Pick<Session, "base_branch" | "branch_name">;
23
+ },
24
+ approval: AskRow & { sha: string },
25
+ head: string
26
+ ): Promise<{ approval: AskRow | null; note: string }> {
27
+ let base: string;
28
+ try {
29
+ base = await fetchRefs(work.repo, work.refs);
30
+ } catch {
31
+ return { approval: null, note: "its base couldn't be fetched to compare" };
32
+ }
33
+ const { same, note } = await comparePatches(
34
+ work.repo,
35
+ base,
36
+ approval.sha,
37
+ head
38
+ );
39
+ if (!same) return { approval: null, note };
40
+ const line = `Carried to ${short(head)}: same patch as ${short(approval.sha)} (rebased onto ${base}).`;
41
+ if (rebindApproval(approval.id, approval.sha, head, line))
42
+ addNote(
43
+ workspaceId,
44
+ `Saad's approval of ${work.name} at ${short(approval.sha)} carries to ${short(head)}: same patch, rebased onto ${base}.`
45
+ );
46
+ const now = getAsk(workspaceId, approval.id);
47
+ return { approval: now?.sha === head && !now.used_at ? now : null, note };
48
+ }
@@ -1,7 +1,7 @@
1
1
  // Spending Saad's approvals: each covers one item, once.
2
2
 
3
3
  import { db } from "../db";
4
- import type { AskRow } from "./asks";
4
+ import { MAX_DETAIL, type AskRow } from "./asks";
5
5
 
6
6
  // An approval on the subject not spent yet, newer than maxAgeMs if given.
7
7
  export function unspentApproval(
@@ -104,3 +104,22 @@ export function voidApprovalsOn(subject: string): number {
104
104
  )
105
105
  .run(subject).changes;
106
106
  }
107
+
108
+ // Moves an approval Saad gave to the commit it was rebased into, when the
109
+ // patch is the same: it stays one approval, spent once. Compare-and-swap on
110
+ // the old commit, so two judgements racing move it once.
111
+ export function rebindApproval(
112
+ id: number,
113
+ from: string,
114
+ to: string,
115
+ line: string
116
+ ): boolean {
117
+ return (
118
+ db
119
+ .prepare(
120
+ `UPDATE orchestrator_asks SET sha = ?, detail = substr(detail || char(10) || ?, 1, ?)
121
+ WHERE id = ? AND sha = ? AND status = 'approved' AND used_at IS NULL`
122
+ )
123
+ .run(to, line, MAX_DETAIL, id, from).changes === 1
124
+ );
125
+ }
@@ -0,0 +1,47 @@
1
+ // An open merge ask follows its PR's head. A task that rebases or pushes
2
+ // while the ask waits would otherwise show Saad a commit that's already
3
+ // gone; the ask is moved to the head it would merge, with a line on whether
4
+ // the PR's patch changed. The approve button is bound to the commit shown,
5
+ // so an answer from a screen that predates the move is refused (asks.ts).
6
+
7
+ import { db, type Session } from "../db";
8
+ import { hostLink } from "../hosts/remote-api";
9
+ import { moveAskHead, openAsks, taskSubject } from "./asks";
10
+ import type { SessionFacts } from "./facts";
11
+ import { addNote } from "./notes";
12
+ import { comparePatches } from "./patch";
13
+ import { fetchRefs, repoOf } from "./repo";
14
+
15
+ const short = (sha: string) => sha.slice(0, 7);
16
+
17
+ // Tasks only: a PR no task owns has no facts here.
18
+ export async function followAskHeads(
19
+ workspaceId: string,
20
+ facts: SessionFacts[]
21
+ ): Promise<void> {
22
+ for (const ask of openAsks(workspaceId)) {
23
+ const f = facts.find((x) => taskSubject(x.id) === ask.subject);
24
+ const pr = f?.task?.pr;
25
+ if (!ask.sha || pr?.state !== "OPEN" || !pr.head || pr.head === ask.sha)
26
+ continue;
27
+ const task = db
28
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
29
+ .get(f!.id) as Session | undefined;
30
+ if (!task || hostLink(task.host_id)) continue;
31
+ try {
32
+ const repo = repoOf(task);
33
+ const base = await fetchRefs(repo, task);
34
+ const { same, note } = await comparePatches(repo, base, ask.sha, pr.head);
35
+ const what = same ? `same patch (rebased onto ${base})` : note;
36
+ const line = `Head moved from ${short(ask.sha)} to ${short(pr.head)}: ${what}. Approving merges ${short(pr.head)}.`;
37
+ if (moveAskHead(ask.id, ask.sha, pr.head, line))
38
+ addNote(
39
+ workspaceId,
40
+ `Saad's ask on ${task.name} now names ${short(pr.head)} (${same ? "same patch" : "patch changed"}).`,
41
+ "ask"
42
+ );
43
+ } catch (error) {
44
+ console.error(`Following ${task.name}'s head in its ask:`, error);
45
+ }
46
+ }
47
+ }
@@ -330,3 +330,29 @@ export function resolveAsks(
330
330
  )
331
331
  .run(why, workspaceId, subject).changes;
332
332
  }
333
+
334
+ // An open ask about a commit that has since been replaced (a rebase, a push)
335
+ // now names the head, with a line saying what happened to it. Only while it
336
+ // still names `from`, so a re-raise in between isn't overwritten.
337
+ export function moveAskHead(
338
+ id: number,
339
+ from: string,
340
+ to: string,
341
+ line: string
342
+ ): boolean {
343
+ const ask = db
344
+ .prepare(`SELECT detail FROM orchestrator_asks WHERE id = ?`)
345
+ .get(id) as { detail: string } | undefined;
346
+ if (!ask) return false;
347
+ const rest = ask.detail.split(/\n?Head moved from /)[0];
348
+ const room = Math.max(0, MAX_DETAIL - line.length - 1);
349
+ const detail = `${rest.slice(0, room)}\n${line}`.trim();
350
+ return (
351
+ db
352
+ .prepare(
353
+ `UPDATE orchestrator_asks SET sha = ?, detail = ?
354
+ WHERE id = ? AND status = 'open' AND sha = ?`
355
+ )
356
+ .run(to, detail, id, from).changes === 1
357
+ );
358
+ }