@saadnvd1/agent-os 0.3.631 → 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
@@ -0,0 +1,7 @@
1
+ import path from "path";
2
+ import { plansDir } from "../plans/features";
3
+
4
+ // A plan written on a linked machine, copied here so this machine's
5
+ // orchestrator and plan view can read it (remote-plan.ts).
6
+ export const remotePlanPath = (sessionId: string) =>
7
+ path.join(plansDir(), "remote", `${sessionId}.md`);
@@ -0,0 +1,308 @@
1
+ /**
2
+ * A plan placed on a linked machine (queue placement, remote.ts). Planning
3
+ * only reads code, so it runs wherever there is room; what follows the plan
4
+ * is decided here, on the machine whose scheduler, needs and workspace apply.
5
+ * The other machine plans, stops, and waits. This machine then copies the
6
+ * plan, says it is written, and either approves it there (the task is built
7
+ * where it planned) or moves the task here once this machine can take it
8
+ * (it needs a Mac, or its plan is split into pieces, which register here).
9
+ * Moving the task reuses the ordinary move (move-flow.ts).
10
+ */
11
+
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import { db, type Session } from "../db";
15
+ import { isRemoteHost } from "../hosts";
16
+ import { peerSource } from "../hosts/peer-load";
17
+ import { hostApi, hostLink, requireHostLink } from "../hosts/remote-api";
18
+ import { LOCAL, machineCapabilities } from "../load/machines";
19
+ import { noApprovalsOn, recordAutoApproval } from "../approvals/no-approvals";
20
+ import { implementArrived, planNoteFor } from "../models/plan-step";
21
+ import { sessionWorkspace } from "../models/resolve";
22
+ import { queueEvent } from "../orchestrator/events";
23
+ import { brakesOn } from "../orchestrator/brakes";
24
+ import { isPaused } from "../orchestrator/pause";
25
+ import { getProject } from "../projects";
26
+ import { planFor, splitOf } from "../plans/features";
27
+ import { forgetHostTasks, hostTasks } from "./remote";
28
+ import { moveTask } from "./move-flow";
29
+ import { meetsNeeds, needsOf } from "./placement";
30
+ import { remotePlanPath } from "./remote-plan-path";
31
+ import { DEFAULT_PRIORITY, isPriority } from "./schedule-types";
32
+ import { localHolds } from "./hold-checks";
33
+ import { expandHome } from "./session";
34
+ import { orchestratorOf } from "./starter";
35
+
36
+ export { remotePlanPath };
37
+
38
+ const getSession = (id: string) =>
39
+ db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
40
+ | Session
41
+ | undefined;
42
+
43
+ function readCopy(id: string): string | null {
44
+ try {
45
+ return fs.readFileSync(remotePlanPath(id), "utf8");
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function checkedSplit(s: Session, text: string | null) {
52
+ try {
53
+ return text ? splitOf(s, text) : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Where a written plan is built: "there" (approved on the machine that
61
+ * planned it) or "home" (moved here first). Pieces are checked against this
62
+ * machine's projects, and work that needs a Mac can't be built on a machine
63
+ * without one, so those come home; everything else stays where it is, since
64
+ * the machine that planned it is the one with room.
65
+ */
66
+ export function implementTarget(
67
+ s: Session,
68
+ split: ReturnType<typeof splitOf>
69
+ ): "there" | "home" {
70
+ const project = s.project_id ? getProject(s.project_id) : null;
71
+ // The project lives there: nothing here to build it in.
72
+ if (!project || isRemoteHost(project.host_id)) return "there";
73
+ if (split) return "home";
74
+ const { needs } = needsOf({
75
+ projectId: project.id,
76
+ projectPath: expandHome(project.working_directory),
77
+ prompt: s.task_prompt ?? "",
78
+ });
79
+ const caps = peerSource(s.host_id!, Date.now()).capabilities;
80
+ if (meetsNeeds(needs, caps)) return "there";
81
+ return meetsNeeds(needs, machineCapabilities()) ? "home" : "there";
82
+ }
83
+
84
+ // One look at a mirror that is planning: if its plan is written there, copy
85
+ // it, say so, and carry on for a plan-first task.
86
+ async function planWritten(s: Session): Promise<void> {
87
+ const link = hostLink(s.host_id);
88
+ if (!link) return;
89
+ const view = await hostTasks(link)
90
+ .then(({ tasks }) => tasks.find((t) => t?.id === s.id))
91
+ .catch(() => undefined);
92
+ if (!view?.planReady) return;
93
+ const { text } = await hostApi<{ text: string | null }>(
94
+ link,
95
+ `/api/tasks/${encodeURIComponent(s.id)}/plan`,
96
+ { timeout: 30000 }
97
+ );
98
+ const claimed = db
99
+ .prepare(
100
+ `UPDATE sessions SET plan_phase = 'awaiting' WHERE id = ? AND plan_phase = 'planning'`
101
+ )
102
+ .run(s.id).changes;
103
+ if (!claimed) return;
104
+ if (text !== null) {
105
+ fs.mkdirSync(path.dirname(remotePlanPath(s.id)), { recursive: true });
106
+ fs.writeFileSync(remotePlanPath(s.id), text);
107
+ }
108
+ const fresh = getSession(s.id)!;
109
+ const split = checkedSplit(fresh, text);
110
+ const errors = split?.errors ?? [];
111
+ // As for a task planned here: a plan-first task goes on by itself, a
112
+ // plan-only one waits unless no-approvals is on, and pieces that couldn't
113
+ // be registered always hold it.
114
+ const auto = !!fresh.plan_only && noApprovalsOn();
115
+ const goOn = (!fresh.plan_only || auto) && !errors.length;
116
+ const target = goOn ? implementTarget(fresh, split) : null;
117
+ const next =
118
+ target === "there"
119
+ ? `implementing on ${hostLink(s.host_id)?.hostName ?? "that machine"}`
120
+ : target === "home"
121
+ ? "moving here to implement once this machine is free"
122
+ : "waiting for approval, not implementing (approve_plan to go ahead, drop to reject)";
123
+ const note = planNoteFor(fresh, remotePlanPath(s.id), next);
124
+ db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(note, s.id);
125
+ const workspaceId = sessionWorkspace(fresh);
126
+ if (orchestratorOf(fresh.started_by) && workspaceId) {
127
+ queueEvent(workspaceId, `plan-done:${s.id}`, s.id, note);
128
+ if (errors.length)
129
+ queueEvent(
130
+ workspaceId,
131
+ `plan-pieces:${s.id}`,
132
+ s.id,
133
+ `Plan of "${s.name}": couldn't register its pieces: ${errors.join("; ")}. It is held, not building. Message the task to fix the plan, or approve_plan to build it as one piece.`
134
+ );
135
+ }
136
+ if (!goOn) {
137
+ try {
138
+ const planned = text ? planFor(fresh, text) : null;
139
+ (await import("../notify/push/events")).pushPlanAwaiting(
140
+ s.id,
141
+ s.name,
142
+ planned?.pieces.length ?? 1
143
+ );
144
+ } catch (e) {
145
+ console.error("plan push failed:", e);
146
+ }
147
+ return;
148
+ }
149
+ await continueRemotePlan(fresh);
150
+ if (auto)
151
+ recordAutoApproval({
152
+ source: "plan",
153
+ workspaceId,
154
+ sessionId: s.id,
155
+ subject: `plan for ${s.name}`,
156
+ detail: "implementing without waiting for approve_plan",
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Carries a written plan on: approves it on the machine that planned it, or
162
+ * hands it to the move home. Returns what it will implement on.
163
+ */
164
+ export async function continueRemotePlan(
165
+ s: Session,
166
+ message?: string
167
+ ): Promise<string> {
168
+ const link = requireHostLink(s.host_id!);
169
+ const split = checkedSplit(s, readCopy(s.id));
170
+ if (implementTarget(s, split) === "there") {
171
+ const out = await hostApi<{ model?: string }>(
172
+ link,
173
+ `/api/tasks/${encodeURIComponent(s.id)}/approve-plan`,
174
+ { body: message ? { message } : {}, timeout: 60000 }
175
+ );
176
+ db.prepare(
177
+ `UPDATE sessions SET plan_phase = 'implementing' WHERE id = ? AND plan_phase = 'awaiting'`
178
+ ).run(s.id);
179
+ forgetHostTasks(link.hostId);
180
+ return out.model ?? link.hostName;
181
+ }
182
+ db.prepare(
183
+ `UPDATE sessions SET plan_phase = 'handoff', plan_handoff_message = ?
184
+ WHERE id = ? AND plan_phase = 'awaiting'`
185
+ ).run(message ?? null, s.id);
186
+ const workspaceId = sessionWorkspace(s);
187
+ if (orchestratorOf(s.started_by) && workspaceId)
188
+ queueEvent(
189
+ workspaceId,
190
+ `plan-handoff:${s.id}`,
191
+ s.id,
192
+ `Task "${s.name}": plan approved; it moves here to be built once this machine is free (${split ? "its plan is in pieces" : "it needs this machine"}).`
193
+ );
194
+ void handoffHome(s.id).catch((e) =>
195
+ console.error(`Moving ${s.id} here failed:`, e)
196
+ );
197
+ return "this machine, once it is free";
198
+ }
199
+
200
+ const handingOff = new Set<string>();
201
+
202
+ async function finishArrival(id: string, message?: string): Promise<boolean> {
203
+ await implementArrived(id, message || undefined);
204
+ db.prepare(
205
+ `UPDATE sessions SET plan_handoff_message = NULL WHERE id = ?`
206
+ ).run(id);
207
+ return true;
208
+ }
209
+
210
+ // Fired, not awaited: a move takes minutes and the queue tick must not wait.
211
+ function fire(id: string, run: () => Promise<unknown>) {
212
+ if (handingOff.has(id)) return;
213
+ void run().catch((e) => console.error(`Moving ${id} here failed:`, e));
214
+ }
215
+
216
+ /**
217
+ * Moves an approved plan's task here, when this machine can take it, and
218
+ * starts implementing. False while the machine is loaded (tried again on the
219
+ * next look) or another call has it.
220
+ */
221
+ export async function handoffHome(
222
+ id: string,
223
+ now = Date.now()
224
+ ): Promise<boolean> {
225
+ const s = getSession(id);
226
+ if (!s || s.plan_phase !== "handoff" || !isRemoteHost(s.host_id))
227
+ return false;
228
+ if (handingOff.has(id)) return false;
229
+ const project = s.project_id ? getProject(s.project_id) : null;
230
+ if (!project) return false;
231
+ // What an orchestrator started stays under its Pause and brakes.
232
+ const workspaceId = sessionWorkspace(s);
233
+ if (workspaceId && orchestratorOf(s.started_by)) {
234
+ if (isPaused(workspaceId)) return false;
235
+ if ((await brakesOn(workspaceId)).length) return false;
236
+ }
237
+ const { needs } = needsOf({
238
+ projectId: project.id,
239
+ projectPath: expandHome(project.working_directory),
240
+ prompt: s.task_prompt ?? "",
241
+ });
242
+ const holds = localHolds(
243
+ {
244
+ needs,
245
+ priority: isPriority(s.priority) ? s.priority : DEFAULT_PRIORITY,
246
+ },
247
+ now
248
+ );
249
+ if (holds.length) return false;
250
+ handingOff.add(id);
251
+ try {
252
+ const message = s.plan_handoff_message ?? undefined;
253
+ const edits = s.plan_edits ?? null;
254
+ const arrived = await moveTask(id, LOCAL);
255
+ // The arrived row keeps the pending hand-off until implementing has
256
+ // started, so a restart in between is picked up by syncRemotePlans.
257
+ db.prepare(
258
+ `UPDATE sessions SET plan_edits = COALESCE(?, plan_edits), plan_handoff_message = ? WHERE id = ?`
259
+ ).run(edits, message ?? "", arrived.id);
260
+ handingOff.add(arrived.id);
261
+ try {
262
+ return await finishArrival(arrived.id, message);
263
+ } finally {
264
+ handingOff.delete(arrived.id);
265
+ }
266
+ } finally {
267
+ handingOff.delete(id);
268
+ }
269
+ }
270
+
271
+ /** Every mirror whose plan is being written, or approved and coming here. */
272
+ export async function syncRemotePlans(): Promise<void> {
273
+ const rows = db
274
+ .prepare(
275
+ `SELECT * FROM sessions
276
+ WHERE plan_waits_for_caller = 1 AND plan_phase IN ('planning', 'handoff')
277
+ AND task_status = 'running' AND archived_at IS NULL`
278
+ )
279
+ .all() as Session[];
280
+ for (const s of rows) {
281
+ if (!isRemoteHost(s.host_id)) continue;
282
+ try {
283
+ if (s.plan_phase === "planning") await planWritten(s);
284
+ else fire(s.id, () => handoffHome(s.id));
285
+ } catch (e) {
286
+ console.error(`Plan on ${s.host_id} for ${s.id} failed:`, e);
287
+ }
288
+ }
289
+ // A task that arrived but never began implementing (a restart between).
290
+ const arrived = db
291
+ .prepare(
292
+ `SELECT id, plan_handoff_message FROM sessions
293
+ WHERE plan_phase = 'awaiting' AND plan_handoff_message IS NOT NULL
294
+ AND task_status = 'running' AND archived_at IS NULL AND peer_mirror IS NOT 1`
295
+ )
296
+ .all() as { id: string; plan_handoff_message: string }[];
297
+ for (const a of arrived) {
298
+ if (isRemoteHost(getSession(a.id)?.host_id)) continue;
299
+ fire(a.id, async () => {
300
+ handingOff.add(a.id);
301
+ try {
302
+ await finishArrival(a.id, a.plan_handoff_message);
303
+ } finally {
304
+ handingOff.delete(a.id);
305
+ }
306
+ });
307
+ }
308
+ }
@@ -38,6 +38,9 @@ export const TASK_CAPABILITIES = [
38
38
  // A keyed start that carries an orchestrator's description holds the task
39
39
  // for the device that sent it, as a move does (lib/tasks/held.ts).
40
40
  "held-start",
41
+ // A plan placed here by another machine stops at the plan for that machine
42
+ // to decide what follows (lib/tasks/remote-plan.ts).
43
+ "remote-plan",
41
44
  ] as const;
42
45
 
43
46
  export interface HostTasks {
@@ -138,10 +141,12 @@ export async function startRemoteTask(
138
141
  view?: "chat" | "terminal";
139
142
  // The orchestrator that started it: that machine holds the task for it.
140
143
  startedBy?: string | null;
144
+ // It plans first: there it plans, stops, and waits for this machine.
145
+ plan?: { only: boolean; reason: string | null };
141
146
  }
142
147
  ): Promise<Session> {
143
148
  const link = requireHostLink(hostId);
144
- const { startedBy: by, ...rest } = opts;
149
+ const { startedBy: by, plan, ...rest } = opts;
145
150
  const startedBy = orchestratorOf(by);
146
151
  if (startedBy)
147
152
  await requireCapability(
@@ -150,17 +155,29 @@ export async function startRemoteTask(
150
155
  "an orchestrator's task",
151
156
  "hold"
152
157
  );
158
+ if (plan) await requireCapability(link, "remote-plan", "a plan step", "plan");
153
159
  const { session } = await postIdempotent<{ session: Session }>(
154
160
  link,
155
161
  "/api/tasks",
156
162
  {
157
163
  project: await projectRef(project),
158
164
  ...rest,
165
+ ...(plan && {
166
+ plan: true,
167
+ plan_only: true,
168
+ plan_waits: true,
169
+ plan_reason: plan.reason ?? undefined,
170
+ }),
159
171
  orchestrated: startedBy ? orchestratedStart(startedBy) : undefined,
160
172
  },
161
173
  180000
162
174
  );
163
175
  mirrorTask(hostId, project.id, session, startedBy);
176
+ if (plan)
177
+ db.prepare(
178
+ `UPDATE sessions SET plan_phase = 'planning', plan_only = ?, plan_decided = 1,
179
+ plan_reason = ?, plan_waits_for_caller = 1 WHERE id = ?`
180
+ ).run(plan.only ? 1 : null, plan.reason, session.id);
164
181
  forgetHostTasks(hostId);
165
182
  return db
166
183
  .prepare(`SELECT * FROM sessions WHERE id = ?`)
@@ -13,59 +13,33 @@ import {
13
13
  PRIORITY_LABEL,
14
14
  type Priority,
15
15
  type QueueExplainView,
16
- type WaitCode,
17
16
  } from "./schedule-types";
17
+ import {
18
+ PRIORITY_POINTS,
19
+ holdsFor,
20
+ type Hold,
21
+ type MachineView,
22
+ } from "./holds";
18
23
 
19
24
  export { PRIORITIES, DEFAULT_PRIORITY } from "./schedule-types";
20
25
  export type { Priority, WaitCode } from "./schedule-types";
26
+ export {
27
+ AMBER_MIN_PRIORITY,
28
+ HEAVY_START_GAP_MS,
29
+ LOAD_SETTLE_MS,
30
+ PRIORITY_POINTS,
31
+ START_GAP_MS,
32
+ machineHold,
33
+ slotText,
34
+ type Hold,
35
+ type MachineLevel,
36
+ type MachineView,
37
+ } from "./holds";
21
38
 
22
- export const PRIORITY_POINTS: Record<Priority, number> = {
23
- urgent: 400,
24
- blocking: 300,
25
- normal: 200,
26
- background: 100,
27
- };
28
39
  export const AGE_POINTS_PER_MIN = 0.5;
29
40
  // Reached after five hours of waiting.
30
41
  export const AGE_CAP = 150;
31
42
  export const RESUME_BONUS = 150;
32
- // Out of red this long before automatic starts resume.
33
- export const LOAD_SETTLE_MS = 120_000;
34
- // On amber, only this priority and above start.
35
- export const AMBER_MIN_PRIORITY: Priority = "blocking";
36
- // Between automatic starts on one machine.
37
- export const START_GAP_MS = 60_000;
38
- // Between starts of tasks that boot a simulator.
39
- export const HEAVY_START_GAP_MS = 5 * 60_000;
40
-
41
- export type MachineLevel = "green" | "amber" | "red";
42
-
43
- // What the machine a candidate would start on looks like right now.
44
- export interface MachineView {
45
- // Shown in hold texts, e.g. "this machine".
46
- label: string;
47
- // False: no load holds at all (AGENTOS_LOAD=off).
48
- enabled: boolean;
49
- // No sample read yet since AgentOS started.
50
- reading: boolean;
51
- level: MachineLevel;
52
- // Not red, and calm for LOAD_SETTLE_MS since it last was.
53
- settled: boolean;
54
- // Seconds of calm still needed, when not settled and not red.
55
- settleLeftMs: number;
56
- booted: number;
57
- maxBooted: number;
58
- // Heavy tasks started within HEAVY_START_GAP_MS that hold no lease yet.
59
- heavyStarting: number;
60
- lastAutoStartAt: number | null;
61
- lastHeavyStartAt: number | null;
62
- }
63
-
64
- export interface Hold {
65
- code: WaitCode;
66
- text: string;
67
- }
68
-
69
43
  export interface Candidate {
70
44
  id: string;
71
45
  kind: "start" | "resume";
@@ -88,7 +62,7 @@ export interface Candidate {
88
62
  // `after X` hold, from the caller (it needs the database).
89
63
  before: Hold | null;
90
64
  // Orchestrator Pause and the usage window, which hold after `after`.
91
- auto: Hold | null;
65
+ auto: Hold | readonly Hold[] | null;
92
66
  // "After any": its set, the ones that have finished, the ones other waiters
93
67
  // already used, and whether it was already released (a retry).
94
68
  any: {
@@ -110,6 +84,8 @@ export interface Plan {
110
84
  // The finish an "after any" waiter takes, by candidate id (null: none left).
111
85
  consume: Map<string, string | null>;
112
86
  explain: Map<string, QueueExplainView>;
87
+ // Every hold on each candidate, in order (empty: it starts).
88
+ holds: Map<string, Hold[]>;
113
89
  }
114
90
 
115
91
  export function scoreParts(
@@ -124,75 +100,6 @@ export function scoreParts(
124
100
  return { priority, age, resume, score: priority + age + resume };
125
101
  }
126
102
 
127
- const priorityRank = (p: Priority) => PRIORITY_POINTS[p] ?? 0;
128
-
129
- const seconds = (ms: number) => Math.max(1, Math.ceil(ms / 1000));
130
-
131
- // The machine holds, in order. Null: it may start here now.
132
- export function machineHold(
133
- c: Pick<Candidate, "heavy" | "needsUnmet" | "remote" | "priority"> &
134
- Partial<Pick<Candidate, "planOnly">>,
135
- m: MachineView,
136
- now: number
137
- ): Hold | null {
138
- if (c.remote || c.planOnly) return null;
139
- if (c.needsUnmet) return { code: "needs", text: `Waiting: needs a Mac` };
140
- if (!m.enabled) return null;
141
- if (m.reading)
142
- return { code: "load_red", text: "Waiting: reading machine load" };
143
- if (m.level === "red")
144
- return { code: "load_red", text: `Waiting: ${m.label} load red` };
145
- if (!m.settled)
146
- return {
147
- code: "load_red",
148
- text: `Waiting: ${m.label} load was red; calm for ${seconds(m.settleLeftMs)}s more`,
149
- };
150
- if (
151
- m.level === "amber" &&
152
- priorityRank(c.priority) < priorityRank(AMBER_MIN_PRIORITY)
153
- )
154
- return {
155
- code: "load_amber",
156
- text: `Waiting: ${m.label} load amber; ${c.priority} work starts on green`,
157
- };
158
- if (c.heavy) {
159
- if (m.level !== "green")
160
- return {
161
- code: "heavy",
162
- text: `Waiting: ${m.label} load amber; simulator work starts on green`,
163
- };
164
- if (m.heavyStarting > 0)
165
- return {
166
- code: "heavy",
167
- text: `Waiting: a simulator is booting on ${m.label}`,
168
- };
169
- if (m.booted >= m.maxBooted)
170
- return {
171
- code: "heavy",
172
- text: `Waiting: ${m.booted} of ${m.maxBooted} simulators in use`,
173
- };
174
- if (
175
- m.lastHeavyStartAt !== null &&
176
- now - m.lastHeavyStartAt < HEAVY_START_GAP_MS
177
- )
178
- return {
179
- code: "heavy",
180
- text: `Waiting: a simulator task just started on ${m.label} (next in ${seconds(HEAVY_START_GAP_MS - (now - m.lastHeavyStartAt))}s)`,
181
- };
182
- }
183
- if (m.lastAutoStartAt !== null && now - m.lastAutoStartAt < START_GAP_MS)
184
- return {
185
- code: "spacing",
186
- text: `Waiting: spacing starts (next in ${seconds(START_GAP_MS - (now - m.lastAutoStartAt))}s)`,
187
- };
188
- return null;
189
- }
190
-
191
- export function slotText(rank: number, of: number, priority: Priority): string {
192
- const word = priority === DEFAULT_PRIORITY ? "" : ` · ${priority}`;
193
- return `#${rank} of ${of}${word}`;
194
- }
195
-
196
103
  export function priorityWord(priority: Priority): string {
197
104
  return PRIORITY_LABEL[priority];
198
105
  }
@@ -204,7 +111,12 @@ export function planStarts(
204
111
  machine: MachineView,
205
112
  now: number
206
113
  ): Plan {
207
- const plan: Plan = { start: [], consume: new Map(), explain: new Map() };
114
+ const plan: Plan = {
115
+ start: [],
116
+ consume: new Map(),
117
+ explain: new Map(),
118
+ holds: new Map(),
119
+ };
208
120
  const m: MachineView = { ...machine };
209
121
  const byGroup = new Map<string, Candidate[]>();
210
122
  for (const c of candidates)
@@ -218,26 +130,27 @@ export function planStarts(
218
130
  const scored = list
219
131
  .map((c) => ({ c, ...scoreParts(c, now) }))
220
132
  .sort((a, b) => b.score - a.score || a.c.position - b.c.position);
221
- scored.forEach(({ c, score, priority: _p }, index) => {
222
- const rank = index + 1;
223
- const hold = holdOf(c);
224
- const view = (h: Hold | null): QueueExplainView => ({
225
- code: h?.code ?? null,
226
- text: h?.text ?? null,
133
+ scored.forEach(({ c, score }, index) => {
134
+ const holds = holdsFor(c, {
135
+ machine: m,
136
+ now,
137
+ used,
138
+ inFlight,
139
+ limit: group.limit,
140
+ rank: index + 1,
141
+ of: scored.length,
142
+ });
143
+ const hold = holds[0] ?? null;
144
+ plan.holds.set(c.id, holds);
145
+ plan.explain.set(c.id, {
146
+ code: hold?.code ?? null,
147
+ text: hold?.text ?? null,
227
148
  score: Math.round(score * 10) / 10,
228
- rank,
149
+ rank: index + 1,
229
150
  of: scored.length,
230
151
  priority: c.priority,
231
152
  });
232
- if (hold) return void plan.explain.set(c.id, view(hold));
233
- if (!c.planOnly && inFlight >= group.limit)
234
- return void plan.explain.set(
235
- c.id,
236
- view({
237
- code: "slot",
238
- text: slotText(rank, scored.length, c.priority),
239
- })
240
- );
153
+ if (hold) return;
241
154
  // Passed every hold: it starts.
242
155
  if (c.any && !c.any.released) {
243
156
  const free = c.any.finished.find((id) => !used.has(id)) ?? null;
@@ -253,23 +166,7 @@ export function planStarts(
253
166
  }
254
167
  }
255
168
  plan.start.push(c.id);
256
- plan.explain.set(c.id, view(null));
257
169
  });
258
170
  }
259
171
  return plan;
260
-
261
- function holdOf(c: Candidate): Hold | null {
262
- if (c.before) return c.before;
263
- if (c.any && !c.any.released) {
264
- const free = c.any.finished.some((id) => !used.has(id));
265
- const spent =
266
- c.any.ids.length > 0 && c.any.finished.length === c.any.ids.length;
267
- if (!free && !spent)
268
- return {
269
- code: "after_any",
270
- text: `Waiting: after any running task (${c.any.label})`,
271
- };
272
- }
273
- return c.auto ?? machineHold(c, m, now);
274
- }
275
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.631",
3
+ "version": "0.3.637",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"