@saadnvd1/agent-os 0.3.631 → 0.3.639

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 (177) hide show
  1. package/app/api/sessions/[id]/pr/route.ts +3 -3
  2. package/app/api/sessions/[id]/setup/route.ts +3 -3
  3. package/app/api/tasks/[id]/approve-plan/route.ts +5 -1
  4. package/app/api/tasks/[id]/arm/route.ts +6 -5
  5. package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
  6. package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
  7. package/app/api/tasks/[id]/checks/route.ts +1 -1
  8. package/app/api/tasks/[id]/diff/route.ts +1 -1
  9. package/app/api/tasks/[id]/done/route.ts +2 -2
  10. package/app/api/tasks/[id]/drop/route.ts +6 -2
  11. package/app/api/tasks/[id]/export/route.ts +3 -4
  12. package/app/api/tasks/[id]/merge/route.ts +3 -3
  13. package/app/api/tasks/[id]/message/route.ts +2 -2
  14. package/app/api/tasks/[id]/model/route.ts +5 -1
  15. package/app/api/tasks/[id]/move/route.ts +5 -1
  16. package/app/api/tasks/[id]/moved/route.ts +6 -8
  17. package/app/api/tasks/[id]/plan/route.ts +35 -0
  18. package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
  19. package/app/api/tasks/[id]/priority/route.ts +4 -1
  20. package/app/api/tasks/[id]/resume/route.ts +3 -4
  21. package/app/api/tasks/[id]/stop/route.ts +2 -2
  22. package/app/api/tasks/route.ts +5 -2
  23. package/app/globals.css +28 -0
  24. package/components/Chat/ChatPanel.tsx +2 -11
  25. package/components/Chat/EventTurns.tsx +1 -7
  26. package/components/Chat/Markdown.tsx +1 -0
  27. package/components/Chat/Messages.tsx +1 -1
  28. package/lib/approvals/enable.ts +9 -2
  29. package/lib/away/report.ts +4 -4
  30. package/lib/away/run.ts +2 -2
  31. package/lib/bookmarks/store.ts +3 -3
  32. package/lib/bus/held-reply.ts +2 -2
  33. package/lib/bus/index.ts +3 -3
  34. package/lib/chat/compact-context.ts +1 -1
  35. package/lib/chat/events.ts +4 -0
  36. package/lib/chat/group.ts +3 -1
  37. package/lib/chat/hold.ts +3 -3
  38. package/lib/chat/reading-order.ts +50 -0
  39. package/lib/chat/refresh.ts +1 -1
  40. package/lib/chat/registry.ts +3 -3
  41. package/lib/chat/runner.ts +1 -1
  42. package/lib/chat/turns.ts +22 -3
  43. package/lib/chat/worker/host.ts +3 -0
  44. package/lib/chat/worker/main.ts +1 -1
  45. package/lib/db/index.ts +5 -1
  46. package/lib/db/lumifyhub.ts +8 -9
  47. package/lib/db/machine-scope.ts +12 -0
  48. package/lib/db/migrations/20261015T1600_plan_waits_for_caller.ts +19 -0
  49. package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
  50. package/lib/db/migrations/README.md +7 -0
  51. package/lib/db/queries.ts +16 -31
  52. package/lib/db/session-parts/columns.ts +186 -0
  53. package/lib/db/session-parts/fill.ts +33 -0
  54. package/lib/db/session-parts/index.ts +9 -0
  55. package/lib/db/session-parts/pauses.ts +11 -0
  56. package/lib/db/session-parts/placement.ts +11 -0
  57. package/lib/db/session-parts/plans.ts +31 -0
  58. package/lib/db/session-parts/prs.ts +31 -0
  59. package/lib/db/session-parts/tasks.ts +27 -0
  60. package/lib/db/session-parts/view.ts +43 -0
  61. package/lib/db/session-parts/write.ts +69 -0
  62. package/lib/db/types.ts +6 -1
  63. package/lib/done/archive.ts +2 -2
  64. package/lib/done/auto-archive.ts +1 -1
  65. package/lib/done/bulk.ts +1 -1
  66. package/lib/done/index.ts +8 -14
  67. package/lib/env-setup.ts +17 -4
  68. package/lib/health/evidence.ts +1 -1
  69. package/lib/health/facts.ts +1 -1
  70. package/lib/health/pieces.ts +1 -1
  71. package/lib/hosts/peer-actions.ts +2 -2
  72. package/lib/hosts/peer-mirror.ts +2 -2
  73. package/lib/hosts/peer-sync.ts +69 -32
  74. package/lib/learning-cards/input.ts +1 -1
  75. package/lib/learning-cards/store.ts +1 -1
  76. package/lib/models/plan-step.ts +83 -46
  77. package/lib/models/resume.ts +5 -7
  78. package/lib/notify/index.ts +1 -1
  79. package/lib/notify/push/events.ts +1 -1
  80. package/lib/notify/push/index.ts +1 -1
  81. package/lib/orchestrator/act.ts +1 -1
  82. package/lib/orchestrator/armed.ts +17 -15
  83. package/lib/orchestrator/ask-approvals.ts +1 -1
  84. package/lib/orchestrator/ask-heads.ts +1 -1
  85. package/lib/orchestrator/ask-settle.ts +1 -1
  86. package/lib/orchestrator/brakes.ts +1 -1
  87. package/lib/orchestrator/digest.ts +2 -2
  88. package/lib/orchestrator/external-pr.ts +2 -2
  89. package/lib/orchestrator/facts.ts +4 -28
  90. package/lib/orchestrator/held-asks.ts +3 -3
  91. package/lib/orchestrator/home.ts +2 -2
  92. package/lib/orchestrator/overview.ts +1 -1
  93. package/lib/orchestrator/pr-wake.ts +3 -5
  94. package/lib/orchestrator/questions.ts +1 -1
  95. package/lib/orchestrator/quick-store.ts +1 -1
  96. package/lib/orchestrator/quick.ts +10 -7
  97. package/lib/orchestrator/read.ts +1 -1
  98. package/lib/orchestrator/rebase.ts +8 -11
  99. package/lib/orchestrator/reconcile.ts +2 -2
  100. package/lib/orchestrator/signoff.ts +3 -1
  101. package/lib/orchestrator/targets.ts +1 -1
  102. package/lib/orchestrator/task-state.ts +4 -2
  103. package/lib/plans/features.ts +1 -1
  104. package/lib/plans/release.ts +1 -1
  105. package/lib/ports.ts +1 -1
  106. package/lib/resurrect/find.ts +1 -1
  107. package/lib/resurrect/index.ts +24 -18
  108. package/lib/schedules/store.ts +1 -1
  109. package/lib/session-audit/sources.ts +3 -3
  110. package/lib/session-logs/index.ts +1 -1
  111. package/lib/session-titles.ts +1 -1
  112. package/lib/sessions/setup-progress.ts +1 -1
  113. package/lib/sessions/stop.ts +1 -1
  114. package/lib/sessions/worktree-setup.ts +2 -1
  115. package/lib/simulator/index.ts +1 -1
  116. package/lib/simulator/task-devices.ts +1 -1
  117. package/lib/stacks/board.ts +1 -1
  118. package/lib/stacks/land.ts +1 -1
  119. package/lib/stacks/restack.ts +1 -1
  120. package/lib/stacks/start.ts +2 -2
  121. package/lib/stacks/tick.ts +1 -1
  122. package/lib/stacks/view.ts +1 -1
  123. package/lib/status/incremental.ts +1 -1
  124. package/lib/tasks/contract/actions.ts +35 -0
  125. package/lib/tasks/contract/authorize.ts +82 -0
  126. package/lib/tasks/contract/client.ts +296 -0
  127. package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
  128. package/lib/tasks/contract/index.ts +28 -0
  129. package/lib/tasks/contract/plan-path.ts +7 -0
  130. package/lib/tasks/contract/plan.ts +274 -0
  131. package/lib/tasks/contract/routes.ts +26 -0
  132. package/lib/tasks/contract/start.ts +122 -0
  133. package/lib/tasks/contract/sync.ts +126 -0
  134. package/lib/tasks/contract/types.ts +41 -0
  135. package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
  136. package/lib/tasks/finish.ts +10 -9
  137. package/lib/tasks/held-replies.ts +7 -13
  138. package/lib/tasks/held.ts +36 -17
  139. package/lib/tasks/hold-checks.ts +134 -0
  140. package/lib/tasks/holds.ts +281 -0
  141. package/lib/tasks/import.ts +26 -14
  142. package/lib/tasks/index.ts +56 -22
  143. package/lib/tasks/launch-gate.ts +1 -1
  144. package/lib/tasks/move-bundle.ts +17 -2
  145. package/lib/tasks/move-carry.ts +15 -7
  146. package/lib/tasks/move-flow.ts +29 -36
  147. package/lib/tasks/move-guard.ts +10 -2
  148. package/lib/tasks/move-recover.ts +6 -25
  149. package/lib/tasks/move.ts +28 -27
  150. package/lib/tasks/moved-away.ts +1 -1
  151. package/lib/tasks/pause-hold.ts +13 -5
  152. package/lib/tasks/pause.ts +21 -54
  153. package/lib/tasks/placement.ts +16 -16
  154. package/lib/tasks/plan-view.ts +11 -3
  155. package/lib/tasks/queue-candidates.ts +151 -0
  156. package/lib/tasks/queue-placement.ts +155 -0
  157. package/lib/tasks/queue-rows.ts +236 -0
  158. package/lib/tasks/queue-start.ts +213 -0
  159. package/lib/tasks/queue-tick.ts +148 -0
  160. package/lib/tasks/queue-types.ts +141 -0
  161. package/lib/tasks/queue-view.ts +112 -0
  162. package/lib/tasks/queue.ts +67 -1102
  163. package/lib/tasks/schedule.ts +44 -147
  164. package/lib/tasks/session.ts +7 -4
  165. package/lib/tasks/start.ts +4 -4
  166. package/lib/tasks/starter.ts +8 -11
  167. package/lib/usage/calls.ts +1 -1
  168. package/lib/usage/compare.ts +1 -1
  169. package/lib/usage/quality.ts +1 -1
  170. package/lib/usage/turns.ts +1 -1
  171. package/lib/voice/vocab.ts +1 -1
  172. package/package.json +1 -1
  173. package/scripts/backfill-session-logs.ts +1 -1
  174. package/scripts/check +55 -7
  175. package/lib/tasks/auto-hold.ts +0 -17
  176. package/lib/tasks/held-routes.ts +0 -33
  177. package/lib/tasks/remote.ts +0 -238
@@ -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 { list as hostTasks } from "./contract/client";
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 session_rows 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 session_rows 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 session_rows 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 session_rows 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
+ }