@saadnvd1/agent-os 0.3.630 → 0.3.637

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.
Files changed (45) hide show
  1. package/app/api/tasks/[id]/approve-plan/route.ts +6 -1
  2. package/app/api/tasks/[id]/export/route.ts +3 -2
  3. package/app/api/tasks/[id]/plan/route.ts +37 -0
  4. package/app/api/tasks/route.ts +4 -1
  5. package/app/globals.css +28 -0
  6. package/components/Chat/ChatPanel.tsx +2 -11
  7. package/components/Chat/EventTurns.tsx +1 -7
  8. package/components/Chat/Markdown.tsx +1 -0
  9. package/components/Chat/Messages.tsx +1 -1
  10. package/lib/approvals/enable.ts +9 -2
  11. package/lib/chat/events.ts +4 -0
  12. package/lib/chat/group.ts +3 -1
  13. package/lib/chat/reading-order.ts +50 -0
  14. package/lib/chat/turns.ts +22 -3
  15. package/lib/chat/worker/host.ts +3 -0
  16. package/lib/db/migrations/20261015T1600_plan_waits_for_caller.ts +19 -0
  17. package/lib/db/types.ts +6 -1
  18. package/lib/env-setup.ts +17 -4
  19. package/lib/models/plan-step.ts +75 -24
  20. package/lib/tasks/held.ts +23 -0
  21. package/lib/tasks/hold-checks.ts +134 -0
  22. package/lib/tasks/holds.ts +281 -0
  23. package/lib/tasks/import.ts +11 -3
  24. package/lib/tasks/index.ts +25 -3
  25. package/lib/tasks/move-bundle.ts +17 -2
  26. package/lib/tasks/move-guard.ts +9 -1
  27. package/lib/tasks/move.ts +5 -4
  28. package/lib/tasks/pause.ts +2 -31
  29. package/lib/tasks/placement.ts +16 -16
  30. package/lib/tasks/plan-view.ts +11 -3
  31. package/lib/tasks/queue-candidates.ts +151 -0
  32. package/lib/tasks/queue-placement.ts +155 -0
  33. package/lib/tasks/queue-rows.ts +236 -0
  34. package/lib/tasks/queue-start.ts +211 -0
  35. package/lib/tasks/queue-tick.ts +148 -0
  36. package/lib/tasks/queue-types.ts +141 -0
  37. package/lib/tasks/queue-view.ts +112 -0
  38. package/lib/tasks/queue.ts +53 -1096
  39. package/lib/tasks/remote-plan-path.ts +7 -0
  40. package/lib/tasks/remote-plan.ts +308 -0
  41. package/lib/tasks/remote.ts +18 -1
  42. package/lib/tasks/schedule.ts +44 -147
  43. package/package.json +1 -1
  44. package/scripts/check +55 -7
  45. package/lib/tasks/auto-hold.ts +0 -17
@@ -12,12 +12,8 @@ import path from "path";
12
12
  import { db } from "../db";
13
13
  import { loadProjectConfig } from "../project-config";
14
14
  import { MACHINE_NEEDS } from "../project-config/schema";
15
- import {
16
- machineHold,
17
- type Candidate,
18
- type Hold,
19
- type MachineView,
20
- } from "./schedule";
15
+ import { machineHold, placeable, type MachineView } from "./holds";
16
+ import type { Candidate } from "./schedule";
21
17
 
22
18
  export type Need = (typeof MACHINE_NEEDS)[number];
23
19
  export type NeedsSource = "task" | "brief" | "config" | "inferred" | "none";
@@ -138,8 +134,6 @@ export function unplaceable(r: {
138
134
  host_id: string | null;
139
135
  }): string | null {
140
136
  if (r.host_id) return "it was pinned to a machine";
141
- if (r.plan_only) return "plan-only tasks run on this machine";
142
- if (r.plan) return "plan-first tasks run on this machine";
143
137
  if (r.agent_type && r.agent_type !== "claude")
144
138
  return "only Claude tasks run on another machine";
145
139
  if (r.model_overrides) return "per-role models run on this machine";
@@ -153,11 +147,24 @@ export interface PeerOption {
153
147
  capabilities: string[];
154
148
  // It can hold an orchestrator's task (false: an older AgentOS). Unset: yes.
155
149
  holds?: boolean;
150
+ // It can run a plan step that stops for this machine (false: an older
151
+ // AgentOS). Unset: yes.
152
+ plans?: boolean;
156
153
  // The project exists there (projectMachines said null).
157
154
  hasProject: boolean;
158
155
  loadPerCore: number;
159
156
  }
160
157
 
158
+ // A start that plans first: the plan phase only reads code, so it needs no
159
+ // simulator or Mac, and the plan-only skip of load holds doesn't apply to
160
+ // where it goes, only to whether it may wait. The candidate as a plan phase
161
+ // sees it.
162
+ export const asPlanPhase = <
163
+ T extends Pick<Candidate, "heavy" | "needsUnmet" | "planOnly">,
164
+ >(
165
+ c: T
166
+ ): T => ({ ...c, heavy: false, needsUnmet: false, planOnly: false });
167
+
161
168
  export interface Placement {
162
169
  hostId: string;
163
170
  name: string;
@@ -165,13 +172,6 @@ export interface Placement {
165
172
  why: string;
166
173
  }
167
174
 
168
- const LOAD_HOLDS = new Set<Hold["code"]>([
169
- "load_red",
170
- "load_amber",
171
- "needs",
172
- "heavy",
173
- ]);
174
-
175
175
  // True when the home machine's load or needs rule this start out, which is
176
176
  // the only time a linked machine is worth asking.
177
177
  export const wantsPlacing = (
@@ -181,7 +181,7 @@ export const wantsPlacing = (
181
181
  ): boolean => {
182
182
  if (c.remote || home.reading) return false;
183
183
  const hold = machineHold(c, home, now);
184
- return !!hold && LOAD_HOLDS.has(hold.code);
184
+ return !!hold && placeable(hold);
185
185
  };
186
186
 
187
187
  // Null: start it at home (or hold it there for a reason placing doesn't
@@ -17,6 +17,8 @@ import {
17
17
  type TaskPlan,
18
18
  } from "./loop-types";
19
19
  import { expandHome } from "./session";
20
+ import { isRemoteHost } from "../hosts";
21
+ import { remotePlanPath } from "./remote-plan-path";
20
22
 
21
23
  const TITLE_MAX = 120;
22
24
 
@@ -131,7 +133,10 @@ export function taskPlan(session: Session): TaskPlan | null {
131
133
  };
132
134
 
133
135
  if (phase && session.worktree_path) {
134
- const file = path.join(expandHome(session.worktree_path), PLAN_FILE);
136
+ // A plan written on a linked machine is read from the copy kept here.
137
+ const file = isRemoteHost(session.host_id)
138
+ ? remotePlanPath(session.id)
139
+ : path.join(expandHome(session.worktree_path), PLAN_FILE);
135
140
  const text = readHead(file, PLAN_TEXT_MAX);
136
141
  if (text !== null || phase === "planning") {
137
142
  const body = text ?? "";
@@ -141,13 +146,16 @@ export function taskPlan(session: Session): TaskPlan | null {
141
146
  status:
142
147
  phase === "planning"
143
148
  ? "writing"
144
- : phase === "awaiting"
149
+ : phase === "awaiting" || phase === "handoff"
145
150
  ? "ready"
146
151
  : "approved",
147
152
  source: "step",
148
153
  title: planTitle(body),
149
154
  path: text !== null ? PLAN_FILE : null,
150
- pieces: phase === "awaiting" ? piecesView(session, file) : null,
155
+ pieces:
156
+ phase === "awaiting" || phase === "handoff"
157
+ ? piecesView(session, file)
158
+ : null,
151
159
  ...capped(body),
152
160
  };
153
161
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Building the candidates a look at the line scores: a fresh or retried start
3
+ * from a queue row, and a paused task that came due to resume. Each carries
4
+ * the holds that need the database (`after`, Pause, the usage window); the
5
+ * machine's holds are applied when the line is planned.
6
+ */
7
+
8
+ import { getProject } from "../projects";
9
+ import { isRemoteHost } from "../hosts";
10
+ import { machineCapabilities } from "../load/machines";
11
+ import {
12
+ claimResume,
13
+ resumeHoldFor,
14
+ setPauseNote,
15
+ type DueResume,
16
+ } from "./pause";
17
+ import {
18
+ heavyNeeds,
19
+ meetsNeeds,
20
+ needsOf,
21
+ parseNeeds,
22
+ type Need,
23
+ } from "./placement";
24
+ import { DEFAULT_PRIORITY, isPriority } from "./schedule-types";
25
+ import type { Candidate, Group } from "./schedule";
26
+ import { expandHome } from "./session";
27
+ import { afterHold, autoHold } from "./hold-checks";
28
+ import {
29
+ groupFor,
30
+ groupKey,
31
+ sessionOf,
32
+ sqliteMs,
33
+ stillWorking,
34
+ workspaceOf,
35
+ } from "./queue-rows";
36
+ import type { QueueRowWithWorkspace } from "./queue-types";
37
+
38
+ export function candidateOf(
39
+ r: QueueRowWithWorkspace,
40
+ used: string[]
41
+ ): { c: Candidate; needs: Need[] } {
42
+ const project = getProject(r.project_id);
43
+ const hostId = r.host_id ?? project?.host_id;
44
+ const remote = isRemoteHost(hostId);
45
+ const needs = remote
46
+ ? []
47
+ : (parseNeeds(r.machine_needs) ??
48
+ (project
49
+ ? needsOf({
50
+ projectId: project.id,
51
+ projectPath: expandHome(project.working_directory),
52
+ prompt: r.prompt,
53
+ }).needs
54
+ : []));
55
+ let any: Candidate["any"] = null;
56
+ if (r.after_any) {
57
+ const ids = JSON.parse(r.after_any) as string[];
58
+ any = {
59
+ ids,
60
+ finished: ids.filter((id) => !stillWorking(id)),
61
+ used: used.filter((id) => id !== r.after_any_used),
62
+ released: !!r.after_any_used,
63
+ label:
64
+ ids
65
+ .filter(stillWorking)
66
+ .map((id) => sessionOf(id)?.name ?? id.slice(0, 8))
67
+ .join(", ") || "its set",
68
+ };
69
+ }
70
+ return {
71
+ needs,
72
+ c: {
73
+ id: r.id,
74
+ kind: "start",
75
+ group: groupKey(r.workspace_id, r.project_id),
76
+ priority: isPriority(r.priority) ? r.priority : DEFAULT_PRIORITY,
77
+ position: r.position,
78
+ waitingSinceMs: sqliteMs(r.created_at),
79
+ heavy: heavyNeeds(needs),
80
+ needsUnmet: !remote && !meetsNeeds(needs, machineCapabilities()),
81
+ remote,
82
+ planOnly: !!r.plan_only,
83
+ before: afterHold(r),
84
+ auto: autoHold(r.workspace_id, !!r.plan_only),
85
+ any,
86
+ },
87
+ };
88
+ }
89
+
90
+ // A paused task that came due, as a candidate to score against new arrivals.
91
+ export function resumeCandidate(d: DueResume): Candidate {
92
+ const workspaceId = workspaceOf(d.projectId);
93
+ return {
94
+ id: `resume:${d.sessionId}`,
95
+ kind: "resume",
96
+ group: groupKey(workspaceId, d.projectId),
97
+ priority: d.priority,
98
+ position: -1_000_000 + d.pauseId,
99
+ waitingSinceMs: d.dueAtMs,
100
+ heavy: false,
101
+ needsUnmet: false,
102
+ remote: false,
103
+ before: null,
104
+ // A person's timed resume goes ahead of an orchestrator Pause.
105
+ auto: d.byKind === "person" ? null : autoHold(workspaceId),
106
+ any: null,
107
+ };
108
+ }
109
+
110
+ // A deliberate resume starts past the slot limit; only a red machine holds
111
+ // it, and the task says so.
112
+ export async function forceDueResumes(due: DueResume[]): Promise<void> {
113
+ for (const d of due.filter((x) => x.resumeBy)) {
114
+ const s = sessionOf(d.sessionId);
115
+ const why = s ? resumeHoldFor(s, d.resumeBy!) : null;
116
+ if (why) setPauseNote(d.sessionId, `held: ${why}`);
117
+ else await claimResume(d.sessionId, { reason: d.resumeBy!, force: true });
118
+ }
119
+ }
120
+
121
+ // Each line's running count and limit, for the lines the candidates are in.
122
+ export function buildGroups(
123
+ candidates: Candidate[],
124
+ rows: QueueRowWithWorkspace[],
125
+ resumes: DueResume[]
126
+ ): Map<string, Group> {
127
+ const groups = new Map<string, Group>();
128
+ for (const c of candidates) {
129
+ if (groups.has(c.group)) continue;
130
+ const r = rows.find(
131
+ (x) => groupKey(x.workspace_id, x.project_id) === c.group
132
+ );
133
+ const projectId =
134
+ r?.project_id ??
135
+ resumes.find((d) => `resume:${d.sessionId}` === c.id)!.projectId;
136
+ const workspaceId = r?.workspace_id ?? workspaceOf(projectId);
137
+ groups.set(
138
+ c.group,
139
+ groupFor(
140
+ workspaceId,
141
+ projectId,
142
+ rows.filter(
143
+ (x) =>
144
+ x.status === "starting" &&
145
+ groupKey(x.workspace_id, x.project_id) === c.group
146
+ ).length
147
+ )
148
+ );
149
+ }
150
+ return groups;
151
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Placing starts on linked machines: which machines could take which task,
3
+ * then the best machine for each start this one's load rules out, best score
4
+ * first, one per machine per look. The rules are pure, in placement.ts.
5
+ */
6
+
7
+ import { getProject } from "../projects";
8
+ import { hostLink, linkedHostIds } from "../hosts/remote-api";
9
+ import { peerReading, peerSource, refreshPeers } from "../hosts/peer-load";
10
+ import { projectMachines } from "../hosts/project-machines";
11
+ import { viewOf } from "../load/machines";
12
+ import { usesGitCrypt } from "../worktree-add";
13
+ import { hostTasks } from "./remote";
14
+ import {
15
+ asPlanPhase,
16
+ place,
17
+ unplaceable,
18
+ wantsPlacing,
19
+ type Need,
20
+ type PeerOption,
21
+ type Placement,
22
+ } from "./placement";
23
+ import { scoreParts, type Candidate, type MachineView } from "./schedule";
24
+ import { expandHome } from "./session";
25
+ import { orchestratorOf } from "./starter";
26
+ import { lineSignature, row } from "./queue-rows";
27
+ import type { QueueRow, QueueRowWithWorkspace } from "./queue-types";
28
+
29
+ // A plan placed elsewhere may have to come back here to be built, and a
30
+ // git-crypt repository never leaves this machine (the key stays here).
31
+ function placementBarred(r: Pick<QueueRow, "project_id">): boolean {
32
+ const project = getProject(r.project_id);
33
+ return !project || usesGitCrypt(expandHome(project.working_directory));
34
+ }
35
+
36
+ // The linked machines a task could be placed on, with what they report.
37
+ async function peerOptions(
38
+ rows: QueueRowWithWorkspace[],
39
+ now: number
40
+ ): Promise<Map<string, PeerOption[]>> {
41
+ const out = new Map<string, PeerOption[]>();
42
+ const hostIds = [...linkedHostIds()];
43
+ if (!hostIds.length || !rows.length) return out;
44
+ await refreshPeers(hostIds, now);
45
+ // Whether each machine can hold an orchestrator's task (an older AgentOS
46
+ // can't, and would fail its start); unreadable counts as no.
47
+ const holds = new Map<string, boolean>();
48
+ const plans = new Map<string, boolean>();
49
+ await Promise.all(
50
+ hostIds.map(async (hostId) => {
51
+ const link = hostLink(hostId);
52
+ const caps = link
53
+ ? await hostTasks(link)
54
+ .then((t) => t.capabilities ?? [])
55
+ .catch(() => [])
56
+ : [];
57
+ holds.set(hostId, caps.includes("held-start"));
58
+ plans.set(hostId, caps.includes("remote-plan"));
59
+ })
60
+ );
61
+ const perProject = new Map<string, Record<string, string | null>>();
62
+ for (const r of rows) {
63
+ if (out.has(r.project_id)) continue;
64
+ const project = getProject(r.project_id);
65
+ let can: Record<string, string | null> = {};
66
+ if (project) {
67
+ can =
68
+ perProject.get(project.id) ??
69
+ (await projectMachines(project).catch(() => ({})));
70
+ perProject.set(project.id, can);
71
+ }
72
+ out.set(
73
+ r.project_id,
74
+ hostIds.map((hostId) => {
75
+ const src = peerSource(hostId, now);
76
+ const reading = peerReading(hostId);
77
+ return {
78
+ hostId,
79
+ name: src.label,
80
+ view: viewOf(src, now),
81
+ capabilities: src.capabilities,
82
+ holds: holds.get(hostId) ?? false,
83
+ plans: plans.get(hostId) ?? false,
84
+ hasProject: can[hostId] === null,
85
+ loadPerCore: reading ? reading.load1 / reading.cores : 99,
86
+ };
87
+ })
88
+ );
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // A start that plans first goes by its plan phase: it needs no Mac or
94
+ // simulator, and a plan-only one that no machine can take starts here
95
+ // as ever (it never waits on load).
96
+ const plansFirst = (r: QueueRow) => !!(r.plan || r.plan_only);
97
+
98
+ // Marks each start the home machine's load rules out as remote and returns
99
+ // where it goes. "stale": the line moved while the machines were asked, so
100
+ // this look is over, and the one a change during it asks for sees the line
101
+ // as it is now.
102
+ export async function placeCandidates(
103
+ candidates: Candidate[],
104
+ rows: QueueRowWithWorkspace[],
105
+ needsOf: Map<string, Need[]>,
106
+ home: MachineView,
107
+ now: number
108
+ ): Promise<Map<string, Placement> | "stale"> {
109
+ const placements = new Map<string, Placement>();
110
+ const asked = (c: Candidate) => {
111
+ const r = row(c.id)!;
112
+ return plansFirst(r) ? asPlanPhase(c) : c;
113
+ };
114
+ const toPlace = candidates.filter((c) => {
115
+ if (c.kind !== "start" || c.remote) return false;
116
+ const r = row(c.id)!;
117
+ if (unplaceable(r)) return false;
118
+ if (plansFirst(r) && placementBarred(r)) return false;
119
+ return wantsPlacing(asked(c), home, now);
120
+ });
121
+ if (!toPlace.length) return placements;
122
+ const before = lineSignature();
123
+ const peers = await peerOptions(
124
+ toPlace.map((c) => rows.find((r) => r.id === c.id)!),
125
+ now
126
+ );
127
+ if (lineSignature() !== before) return "stale";
128
+ const taken = new Set<string>();
129
+ for (const c of [...toPlace].sort(
130
+ (a, b) =>
131
+ scoreParts(b, now).score - scoreParts(a, now).score ||
132
+ a.position - b.position
133
+ )) {
134
+ const r = rows.find((x) => x.id === c.id)!;
135
+ const plan = plansFirst(r);
136
+ const options = (peers.get(r.project_id) ?? []).filter(
137
+ (p) =>
138
+ !taken.has(p.hostId) &&
139
+ (!orchestratorOf(r.started_by) || p.holds !== false) &&
140
+ (!plan || p.plans)
141
+ );
142
+ const spot = place(
143
+ asked(c),
144
+ home,
145
+ options,
146
+ plan ? [] : (needsOf.get(c.id) ?? []),
147
+ now
148
+ );
149
+ if (!spot) continue;
150
+ taken.add(spot.hostId);
151
+ placements.set(c.id, spot);
152
+ c.remote = true;
153
+ }
154
+ return placements;
155
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * The line's rows and slot counts: the small reads and writes everything else
3
+ * in the queue builds on.
4
+ */
5
+
6
+ import { db, type Session } from "../db";
7
+ import { getProject } from "../projects";
8
+ import { getWorkspace } from "../workspaces";
9
+ import { queuedLabel } from "./queue-held";
10
+ import type { Group } from "./schedule";
11
+ import type { QueueRow } from "./queue-types";
12
+
13
+ export const IN_FLIGHT = ["running", "moving"];
14
+
15
+ export const row = (id: string) =>
16
+ db.prepare(`SELECT * FROM task_queue WHERE id = ?`).get(id) as
17
+ | QueueRow
18
+ | undefined;
19
+
20
+ export const sessionOf = (id: string) =>
21
+ db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
22
+ | Session
23
+ | undefined;
24
+
25
+ export function patch(id: string, fields: Partial<QueueRow>): void {
26
+ const keys = Object.keys(fields);
27
+ if (!keys.length) return;
28
+ db.prepare(
29
+ `UPDATE task_queue SET ${keys.map((k) => `${k} = @${k}`).join(", ")} WHERE id = @id`
30
+ ).run({ ...fields, id });
31
+ }
32
+
33
+ export const workspaceOf = (projectId: string) =>
34
+ getProject(projectId)?.workspace_id ?? null;
35
+
36
+ // Direct starts under way, by workspace: their task rows don't exist until
37
+ // createTask gets that far, so they hold their slot here meanwhile.
38
+ // On globalThis: the custom server's watcher and the route bundles each load
39
+ // their own copy of this module, and must see one set of reservations.
40
+ const g = globalThis as unknown as {
41
+ __agentosQueueReserved?: Map<string, number>;
42
+ };
43
+ export const reserved = (g.__agentosQueueReserved ??= new Map<
44
+ string,
45
+ number
46
+ >());
47
+
48
+ // Running tasks hold their slot until they finish (merged, dropped, done),
49
+ // PR open or not; a queued task being started counts too. A paused task
50
+ // gives its slot back until it resumes. A quick change
51
+ // runs no agent, so it holds none. Without a
52
+ // workspace, the project is the scope.
53
+ export function runningIn(
54
+ scope: { workspaceId: string | null; projectId: string },
55
+ forSlots = false
56
+ ) {
57
+ const where = scope.workspaceId
58
+ ? `p.workspace_id = @ws`
59
+ : `p.id = @project AND p.workspace_id IS NULL`;
60
+ return (
61
+ db
62
+ .prepare(
63
+ `SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
64
+ WHERE ${where} AND s.archived_at IS NULL AND s.quick_of IS NULL
65
+ AND (s.pause_state IS NULL OR s.pause_state != 'paused')
66
+ ${forSlots ? `AND NOT (COALESCE(s.plan_only, 0) = 1 AND COALESCE(s.plan_phase, '') != 'implementing')` : ""}
67
+ AND s.task_status IN (${IN_FLIGHT.map((s) => `'${s}'`).join(", ")})`
68
+ )
69
+ .all({ ws: scope.workspaceId, project: scope.projectId }) as {
70
+ id: string;
71
+ }[]
72
+ ).map((r) => r.id);
73
+ }
74
+
75
+ export function startingIn(workspaceId: string): number {
76
+ return (
77
+ (reserved.get(workspaceId) ?? 0) +
78
+ (
79
+ db
80
+ .prepare(
81
+ `SELECT COUNT(*) AS n FROM task_queue q JOIN projects p ON p.id = q.project_id
82
+ WHERE q.status = 'starting' AND p.workspace_id = ?
83
+ AND q.plan_only IS NULL`
84
+ )
85
+ .get(workspaceId) as { n: number }
86
+ ).n
87
+ );
88
+ }
89
+
90
+ export const limitOf = (workspaceId: string | null) =>
91
+ workspaceId ? (getWorkspace(workspaceId)?.max_running_tasks ?? null) : null;
92
+
93
+ // Finished, as far as a wait on it goes: merged, dropped, done or handed to
94
+ // another machine, or a queued task that was removed or failed to start.
95
+ export function hasFinished(taskId: string): boolean {
96
+ const s = sessionOf(taskId);
97
+ if (s) return !!s.archived_at || !IN_FLIGHT.includes(s.task_status ?? "");
98
+ const q = row(taskId);
99
+ return !q || (q.status !== "queued" && q.status !== "starting");
100
+ }
101
+
102
+ // Still something to wait for under "any": in flight, and not a task whose
103
+ // PR was closed or merged and which nobody finished (that one never will).
104
+ export function stillWorking(taskId: string): boolean {
105
+ if (hasFinished(taskId)) return false;
106
+ const s = sessionOf(taskId);
107
+ return !s || (s.pr_status !== "closed" && s.pr_status !== "merged");
108
+ }
109
+
110
+ export function afterMet(r: QueueRow): boolean {
111
+ if (r.after_task && !hasFinished(r.after_task)) return false;
112
+ if (r.after_any && !r.after_any_used) {
113
+ const ids = JSON.parse(r.after_any) as string[];
114
+ if (ids.length && !ids.some((id) => !stillWorking(id))) return false;
115
+ }
116
+ return true;
117
+ }
118
+
119
+ // A task named by id, id prefix or name, running or queued; in one
120
+ // workspace when given (the orchestrator's).
121
+ export function resolveAfter(
122
+ ref: string,
123
+ workspaceId: string | null
124
+ ): { id: string; name: string } {
125
+ const want = ref.trim();
126
+ const lower = want.toLowerCase();
127
+ const scope = workspaceId ? `AND p.workspace_id = @ws` : "";
128
+ const candidates = db
129
+ .prepare(
130
+ `SELECT s.id, s.name FROM sessions s JOIN projects p ON p.id = s.project_id
131
+ WHERE s.archived_at IS NULL AND s.task_status IN ('running', 'moving') ${scope}
132
+ UNION ALL
133
+ SELECT q.id, COALESCE(q.name, q.prompt) AS name FROM task_queue q
134
+ JOIN projects p ON p.id = q.project_id
135
+ WHERE q.status IN ('queued', 'starting') ${scope}`
136
+ )
137
+ .all({ ws: workspaceId }) as { id: string; name: string }[];
138
+ const exact = candidates.filter(
139
+ (c) => c.id === want || c.name.toLowerCase() === lower
140
+ );
141
+ const hits = exact.length
142
+ ? exact
143
+ : want.length >= 4
144
+ ? candidates.filter((c) => c.id.startsWith(want))
145
+ : [];
146
+ if (hits.length === 1) return hits[0];
147
+ if (hits.length > 1)
148
+ throw new Error(
149
+ `"${ref}" matches ${hits.length} tasks: ${hits.map((h) => `${h.name} (${h.id.slice(0, 8)})`).join(", ")}`
150
+ );
151
+ throw new Error(`No running or queued task matches "${ref}"`);
152
+ }
153
+
154
+ // How full the workspace's running-task limit is for this project.
155
+ export function slotState(projectId: string): {
156
+ running: number;
157
+ limit: number | null;
158
+ full: boolean;
159
+ } {
160
+ const workspaceId = workspaceOf(projectId);
161
+ const limit = limitOf(workspaceId);
162
+ if (!workspaceId || limit === null)
163
+ return { running: 0, limit: null, full: false };
164
+ const running =
165
+ runningIn({ workspaceId, projectId }, true).length +
166
+ startingIn(workspaceId);
167
+ return { running, limit, full: running >= limit };
168
+ }
169
+
170
+ export const waitingIn = (workspaceId: string) =>
171
+ (
172
+ db
173
+ .prepare(
174
+ `SELECT COUNT(*) AS n FROM task_queue q JOIN projects p ON p.id = q.project_id
175
+ WHERE q.status = 'queued' AND p.workspace_id = ?
176
+ AND q.after_task IS NULL AND q.after_any IS NULL
177
+ AND q.plan_only IS NULL`
178
+ )
179
+ .get(workspaceId) as { n: number }
180
+ ).n;
181
+
182
+ export const label = queuedLabel;
183
+
184
+ export function afterLabel(r: QueueRow): string | null {
185
+ if (r.after_task) {
186
+ const s = sessionOf(r.after_task);
187
+ const q = s ? null : row(r.after_task);
188
+ return s?.name ?? (q ? label(q) : r.after_task.slice(0, 8));
189
+ }
190
+ return r.after_any ? "any running task" : null;
191
+ }
192
+
193
+ // SQLite's datetime('now') is UTC without a zone.
194
+ export const sqliteMs = (v: string) =>
195
+ Date.parse(v.includes("T") ? v : `${v.replace(" ", "T")}Z`) || 0;
196
+
197
+ export const groupKey = (workspaceId: string | null, projectId: string) =>
198
+ workspaceId ?? `project:${projectId}`;
199
+
200
+ export function groupFor(
201
+ workspaceId: string | null,
202
+ projectId: string,
203
+ startingRows: number
204
+ ): Group {
205
+ const scope = { workspaceId, projectId };
206
+ return {
207
+ inFlight:
208
+ runningIn(scope, true).length +
209
+ (workspaceId ? (reserved.get(workspaceId) ?? 0) : 0) +
210
+ startingRows,
211
+ limit: limitOf(workspaceId) ?? Number.POSITIVE_INFINITY,
212
+ };
213
+ }
214
+
215
+ // Finishes other waiters have taken, so one finish lets one waiter go.
216
+ export const usedFinishes = (): string[] =>
217
+ (
218
+ db
219
+ .prepare(
220
+ `SELECT after_any_used AS id FROM task_queue WHERE after_any_used IS NOT NULL`
221
+ )
222
+ .all() as { id: string }[]
223
+ ).map((x) => x.id);
224
+
225
+ export function lineSignature(): string {
226
+ const r = db
227
+ .prepare(
228
+ `SELECT COUNT(*) AS n, COALESCE(MAX(position), 0) AS p,
229
+ COALESCE(SUM(priority = 'normal'), 0) AS pr,
230
+ (SELECT COUNT(*) FROM sessions WHERE task_status IS NOT NULL
231
+ AND task_status NOT IN ('merged', 'dropped')) AS running
232
+ FROM task_queue WHERE status IN ('queued', 'starting')`
233
+ )
234
+ .get() as { n: number; p: number; pr: number; running: number };
235
+ return `${r.n}:${r.p}:${r.pr}:${r.running}`;
236
+ }