@saadnvd1/agent-os 0.3.571 → 0.3.572

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 (54) hide show
  1. package/README.md +1 -1
  2. package/app/api/bus/spawn/route.ts +21 -3
  3. package/app/api/load/route.test.ts +32 -0
  4. package/app/api/load/route.ts +20 -0
  5. package/app/api/sessions/[id]/pause/route.ts +97 -0
  6. package/app/api/tasks/[id]/priority/route.ts +29 -0
  7. package/app/api/tasks/route.ts +17 -2
  8. package/components/Chat/ChatPanel.tsx +2 -0
  9. package/components/Chat/PausedBanner.tsx +54 -0
  10. package/components/Draft/DraftChips.tsx +25 -0
  11. package/components/Draft/DraftPanel.tsx +1 -0
  12. package/components/SessionList/QueuedRow.tsx +46 -2
  13. package/components/SessionList/RowSubtitle.tsx +21 -3
  14. package/components/SessionList/SessionRowMenu.tsx +49 -1
  15. package/components/SessionList/useShelfActions.ts +39 -2
  16. package/data/sessions/shelf.ts +50 -0
  17. package/data/tasks/index.ts +2 -0
  18. package/data/tasks/queue.ts +38 -1
  19. package/lib/bus/index.ts +21 -2
  20. package/lib/chat/hold.ts +11 -2
  21. package/lib/db/migrations.ts +56 -1
  22. package/lib/db/types.ts +30 -0
  23. package/lib/done/index.ts +2 -0
  24. package/lib/drafts.ts +3 -0
  25. package/lib/hosts/peer-load.ts +120 -0
  26. package/lib/load/machines.ts +124 -0
  27. package/lib/load/monitor.ts +17 -2
  28. package/lib/load/start-gate.ts +95 -0
  29. package/lib/orchestrator/act.ts +78 -0
  30. package/lib/orchestrator/brakes.ts +2 -0
  31. package/lib/orchestrator/brief.ts +4 -1
  32. package/lib/orchestrator/digest.ts +15 -4
  33. package/lib/orchestrator/serve.ts +18 -0
  34. package/lib/orchestrator/tool-names.ts +3 -0
  35. package/lib/orchestrator/tool-schemas.ts +33 -0
  36. package/lib/orchestrator/tools.ts +7 -1
  37. package/lib/project-config/agentos.schema.json +13 -0
  38. package/lib/project-config/schema.ts +9 -0
  39. package/lib/sidebar/paused.ts +13 -0
  40. package/lib/sidebar/queued.ts +34 -1
  41. package/lib/sidebar/shelves.ts +21 -1
  42. package/lib/sidebar/snooze.ts +26 -0
  43. package/lib/tasks/finish.ts +7 -0
  44. package/lib/tasks/move-carry.ts +1 -0
  45. package/lib/tasks/move-flow.ts +6 -0
  46. package/lib/tasks/pause-hold.ts +70 -0
  47. package/lib/tasks/pause-policy.ts +130 -0
  48. package/lib/tasks/pause.ts +718 -0
  49. package/lib/tasks/placement.ts +219 -0
  50. package/lib/tasks/queue.ts +522 -64
  51. package/lib/tasks/schedule-types.ts +64 -0
  52. package/lib/tasks/schedule.ts +271 -0
  53. package/package.json +1 -1
  54. package/server.ts +2 -1
@@ -2,9 +2,12 @@
2
2
  * Queued tasks: a task that can't start yet waits here instead of being
3
3
  * refused. It waits for a slot under its workspace's limit on running tasks
4
4
  * (`max_running_tasks`, off unless set), or for another task to finish
5
- * (`after`). The stack watcher's loop ticks it (lib/stacks/tick.ts), with
6
- * the same slot filling as stacks: in line order, up to the limit, skipping
7
- * one whose `after` hasn't happened.
5
+ * (`after`). The stack watcher's loop ticks it (lib/stacks/tick.ts), and the
6
+ * load monitor nudges it when a held machine calms down. Each tick scores
7
+ * the candidates (lib/tasks/schedule.ts: priority, age, a resume bonus),
8
+ * holds the ones a machine's load, simulators or start spacing rule out, and
9
+ * starts what fits under the limit, best first. A start the home machine's
10
+ * load rules out goes to a linked machine that is free (placement.ts).
8
11
  *
9
12
  * A row's id is the session id it starts as, and it's claimed ('starting')
10
13
  * before the task is created, so a restart never starts one twice. Nothing
@@ -18,7 +21,6 @@ import { db, type Session } from "../db";
18
21
  import { getProject } from "../projects";
19
22
  import { isRemoteHost } from "../hosts";
20
23
  import { getWorkspace } from "../workspaces";
21
- import { fillSlots } from "../stacks/ready";
22
24
  import { isPaused } from "../orchestrator/pause";
23
25
  import {
24
26
  BrakeRefused,
@@ -29,6 +31,45 @@ import {
29
31
  import { queueEvent } from "../orchestrator/events";
30
32
  import { readUsage, windowRefusal } from "../orchestrator/usage";
31
33
  import { createTask } from "./index";
34
+ import { expandHome } from "./session";
35
+ import {
36
+ DEFAULT_PRIORITY,
37
+ isPriority,
38
+ type Priority,
39
+ type WaitCode,
40
+ } from "./schedule-types";
41
+ import {
42
+ machineHold,
43
+ planStarts,
44
+ scoreParts,
45
+ type Candidate,
46
+ type Group,
47
+ type Hold,
48
+ } from "./schedule";
49
+ import {
50
+ heavyNeeds,
51
+ meetsNeeds,
52
+ needsFromSetting,
53
+ needsOf,
54
+ parseNeeds,
55
+ place,
56
+ unplaceable,
57
+ wantsPlacing,
58
+ type Need,
59
+ type Placement,
60
+ type PeerOption,
61
+ } from "./placement";
62
+ import {
63
+ LOCAL,
64
+ localView,
65
+ machineCapabilities,
66
+ recordAutoStart,
67
+ viewOf,
68
+ } from "../load/machines";
69
+ import { peerReading, peerSource, refreshPeers } from "../hosts/peer-load";
70
+ import { linkedHostIds } from "../hosts/remote-api";
71
+ import { projectMachines } from "../hosts/project-machines";
72
+ import { claimResume, dueResumes } from "./pause";
32
73
 
33
74
  export type QueueStatus =
34
75
  | "queued"
@@ -60,6 +101,14 @@ export interface QueueRow {
60
101
  // "after any": the tasks running when it was queued, as JSON; the first
61
102
  // of them to finish lets it go.
62
103
  after_any: string | null;
104
+ // The finish (a task id) this "after any" waiter took; one finish lets one
105
+ // waiter go (a unique index keeps it so).
106
+ after_any_used: string | null;
107
+ priority: Priority;
108
+ // What it needs from a machine, as JSON, when the starter said.
109
+ machine_needs: string | null;
110
+ // The code behind `note`, set by the scheduler.
111
+ wait_code: WaitCode | null;
63
112
  // The orchestrator's workspace, when the orchestrator queued it: its
64
113
  // start goes through that orchestrator's brakes.
65
114
  origin_workspace_id: string | null;
@@ -93,6 +142,12 @@ export interface QueuedTaskView {
93
142
  // The orchestrator session that queued it, when one did.
94
143
  startedBy: string | null;
95
144
  createdAt: string;
145
+ priority: Priority;
146
+ // Why it isn't starting, from the scheduler; null: nothing but its turn.
147
+ wait: WaitCode | null;
148
+ // Its place in the score order within its line, and how many are in it.
149
+ rank: number;
150
+ of: number;
96
151
  }
97
152
 
98
153
  export const START_ATTEMPTS = 3;
@@ -120,7 +175,8 @@ const workspaceOf = (projectId: string) =>
120
175
  getProject(projectId)?.workspace_id ?? null;
121
176
 
122
177
  // Running tasks hold their slot until they finish (merged, dropped, done),
123
- // PR open or not; a queued task being started counts too. A quick change
178
+ // PR open or not; a queued task being started counts too. A paused task
179
+ // gives its slot back until it resumes. A quick change
124
180
  // runs no agent, so it holds none. Without a
125
181
  // workspace, the project is the scope.
126
182
  function runningIn(scope: { workspaceId: string | null; projectId: string }) {
@@ -132,6 +188,7 @@ function runningIn(scope: { workspaceId: string | null; projectId: string }) {
132
188
  .prepare(
133
189
  `SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
134
190
  WHERE ${where} AND s.archived_at IS NULL AND s.quick_of IS NULL
191
+ AND (s.pause_state IS NULL OR s.pause_state != 'paused')
135
192
  AND s.task_status IN (${IN_FLIGHT.map((s) => `'${s}'`).join(", ")})`
136
193
  )
137
194
  .all({ ws: scope.workspaceId, project: scope.projectId }) as {
@@ -176,7 +233,7 @@ function stillWorking(taskId: string): boolean {
176
233
 
177
234
  export function afterMet(r: QueueRow): boolean {
178
235
  if (r.after_task && !hasFinished(r.after_task)) return false;
179
- if (r.after_any) {
236
+ if (r.after_any && !r.after_any_used) {
180
237
  const ids = JSON.parse(r.after_any) as string[];
181
238
  if (ids.length && !ids.some((id) => !stillWorking(id))) return false;
182
239
  }
@@ -235,6 +292,11 @@ export interface QueueRequest {
235
292
  after?: string;
236
293
  // Set when the orchestrator starts it.
237
294
  originWorkspaceId?: string;
295
+ // Where it sits among the queued: urgent, blocking, normal (the default)
296
+ // or background.
297
+ priority?: Priority;
298
+ // "mac" pins it to a Mac; "any" says it needs nothing in particular.
299
+ machine?: "any" | "mac";
238
300
  }
239
301
 
240
302
  const waitingIn = (workspaceId: string) =>
@@ -278,14 +340,26 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
278
340
  startingIn(workspaceId) >=
279
341
  limit ||
280
342
  waitingIn(workspaceId) > 0);
281
- if (!waits && !full) return null;
343
+ if (req.priority !== undefined && !isPriority(req.priority))
344
+ throw new Error(`Priority is one of: urgent, blocking, normal, background`);
345
+ const setting = needsFromSetting(req.machine);
346
+ if (req.machine !== undefined && !setting)
347
+ throw new Error(`Machine is "any" or "mac"`);
348
+ // The orchestrator's own starts wait out a loaded machine; a person's
349
+ // direct start goes ahead, as "start now" always has.
350
+ const loaded =
351
+ !waits &&
352
+ !full &&
353
+ !!req.originWorkspaceId &&
354
+ loadHeldStart(project.id, req.prompt, setting);
355
+ if (!waits && !full && !loaded) return null;
282
356
  if (!req.prompt.trim()) throw new Error("Describe the task");
283
357
  const id = randomUUID();
284
358
  db.prepare(
285
359
  `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, view, agent_type, base_branch,
286
- host_id, after_task, after_any, origin_workspace_id, position)
360
+ host_id, after_task, after_any, origin_workspace_id, priority, machine_needs, position)
287
361
  VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @view, @agent_type, @base_branch,
288
- @host_id, @after_task, @after_any, @origin,
362
+ @host_id, @after_task, @after_any, @origin, @priority, @machine_needs,
289
363
  (SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
290
364
  ).run({
291
365
  id,
@@ -308,6 +382,8 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
308
382
  after_task: afterTask,
309
383
  after_any: afterAny ? JSON.stringify(afterAny) : null,
310
384
  origin: req.originWorkspaceId ?? null,
385
+ priority: req.priority ?? DEFAULT_PRIORITY,
386
+ machine_needs: setting ? JSON.stringify(setting) : null,
311
387
  });
312
388
  tickQueueSoon();
313
389
  return queuedView(id)!;
@@ -372,10 +448,28 @@ export function listQueue(): QueuedTaskView[] {
372
448
  workspace_id: string | null;
373
449
  })[];
374
450
  const places = new Map<string, number>();
451
+ // The score order within each line, by priority and age alone: holds
452
+ // don't move a task's rank.
453
+ const now = Date.now();
454
+ const ranked = new Map<string, { rank: number; of: number }>();
455
+ const lines = new Map<string, typeof rows>();
456
+ for (const r of rows.filter((r) => r.status === "queued")) {
457
+ const key = r.workspace_id ?? `project:${r.project_id}`;
458
+ lines.set(key, [...(lines.get(key) ?? []), r]);
459
+ }
460
+ for (const line of lines.values()) {
461
+ const order = line
462
+ .map((r) => ({ r, score: scoreOf(r, now) }))
463
+ .sort((a, b) => b.score - a.score || a.r.position - b.r.position);
464
+ order.forEach(({ r }, i) =>
465
+ ranked.set(r.id, { rank: i + 1, of: order.length })
466
+ );
467
+ }
375
468
  return rows.map((r) => {
376
469
  const key = r.workspace_id ?? `project:${r.project_id}`;
377
470
  const place = (places.get(key) ?? 0) + 1;
378
471
  places.set(key, place);
472
+ const rank = ranked.get(r.id);
379
473
  return {
380
474
  id: r.id,
381
475
  name: label(r),
@@ -391,20 +485,67 @@ export function listQueue(): QueuedTaskView[] {
391
485
  error: r.error,
392
486
  startedBy: r.started_by,
393
487
  createdAt: r.created_at,
488
+ priority: isPriority(r.priority) ? r.priority : DEFAULT_PRIORITY,
489
+ wait: r.wait_code,
490
+ rank: rank?.rank ?? place,
491
+ of: rank?.of ?? place,
394
492
  };
395
493
  });
396
494
  }
397
495
 
496
+ function scoreOf(r: QueueRow, now: number): number {
497
+ return scoreParts(
498
+ {
499
+ kind: "start",
500
+ priority: isPriority(r.priority) ? r.priority : DEFAULT_PRIORITY,
501
+ waitingSinceMs: sqliteMs(r.created_at),
502
+ },
503
+ now
504
+ ).score;
505
+ }
506
+
507
+ // SQLite's datetime('now') is UTC without a zone.
508
+ const sqliteMs = (v: string) =>
509
+ Date.parse(v.includes("T") ? v : `${v.replace(" ", "T")}Z`) || 0;
510
+
398
511
  export const queuedView = (id: string) =>
399
512
  listQueue().find((q) => q.id === id) ?? null;
400
513
 
401
514
  // Why an automatic start has to wait, or null. A start now by a person
402
515
  // isn't held.
403
- function autoHold(workspaceId: string | null): string | null {
516
+ function autoHold(workspaceId: string | null): Hold | null {
404
517
  if (workspaceId && isPaused(workspaceId))
405
- return "Waiting: the orchestrator is paused";
518
+ return { code: "orch_paused", text: "Waiting: the orchestrator is paused" };
406
519
  const window = brakesEnabled() ? windowRefusal(readUsage()) : null;
407
- return window ? `Waiting: ${window}` : null;
520
+ return window ? { code: "window", text: `Waiting: ${window}` } : null;
521
+ }
522
+
523
+ // Whether this machine's load rules out starting a task for this project now.
524
+ function loadHeldStart(
525
+ projectId: string,
526
+ prompt: string,
527
+ setting: Need[] | null
528
+ ): boolean {
529
+ const project = getProject(projectId);
530
+ if (!project || isRemoteHost(project.host_id)) return false;
531
+ const needs =
532
+ setting ??
533
+ needsOf({
534
+ projectId,
535
+ projectPath: expandHome(project.working_directory),
536
+ prompt,
537
+ }).needs;
538
+ const hold = machineHold(
539
+ {
540
+ heavy: heavyNeeds(needs),
541
+ needsUnmet: !meetsNeeds(needs, machineCapabilities()),
542
+ remote: false,
543
+ priority: DEFAULT_PRIORITY,
544
+ },
545
+ localView(),
546
+ Date.now()
547
+ );
548
+ return !!hold && (hold.code === "load_red" || hold.code === "heavy");
408
549
  }
409
550
 
410
551
  export type StartOutcome = "started" | "held" | "retry" | "failed";
@@ -412,20 +553,32 @@ export type StartOutcome = "started" | "held" | "retry" | "failed";
412
553
  // Tells the orchestrator that queued it that it started.
413
554
  function announce(
414
555
  r: Pick<QueueRow, "id" | "origin_workspace_id">,
415
- session: Pick<Session, "name" | "branch_name">
556
+ session: Pick<Session, "name" | "branch_name">,
557
+ placed?: { text: string } | null
416
558
  ): void {
417
559
  if (!r.origin_workspace_id) return;
418
560
  queueEvent(
419
561
  r.origin_workspace_id,
420
562
  `queued-start:${r.id}`,
421
563
  r.id,
422
- `Queued task "${session.name}" (id ${r.id.slice(0, 8)}) started on ${session.branch_name ?? "its branch"}.`
564
+ placed
565
+ ? `Queued task "${session.name}" (id ${r.id.slice(0, 8)}) ${placed.text}.`
566
+ : `Queued task "${session.name}" (id ${r.id.slice(0, 8)}) started on ${session.branch_name ?? "its branch"}.`
423
567
  );
424
568
  }
425
569
 
570
+ interface StartOpts {
571
+ force?: boolean;
572
+ // The finish an "after any" start takes (null: none left to take).
573
+ consume?: string | null;
574
+ // Start on this linked machine instead of the project's.
575
+ placement?: Placement | null;
576
+ heavy?: boolean;
577
+ }
578
+
426
579
  async function startRow(
427
580
  r: QueueRow,
428
- opts: { force?: boolean } = {}
581
+ opts: StartOpts = {}
429
582
  ): Promise<StartOutcome> {
430
583
  const project = getProject(r.project_id);
431
584
  const workspaceId = project?.workspace_id ?? null;
@@ -448,7 +601,8 @@ async function startRow(
448
601
  if (!opts.force) {
449
602
  const held = autoHold(workspaceId);
450
603
  if (held) {
451
- if (r.note !== held) patch(r.id, { note: held });
604
+ if (r.note !== held.text)
605
+ patch(r.id, { note: held.text, wait_code: held.code });
452
606
  return "held";
453
607
  }
454
608
  // A start that came in during this tick's earlier starts may have taken
@@ -463,12 +617,24 @@ async function startRow(
463
617
  )
464
618
  return "held";
465
619
  }
466
- const claimed = db
467
- .prepare(
468
- `UPDATE task_queue SET status = 'starting', note = NULL WHERE id = ? AND status = 'queued'`
469
- )
470
- .run(r.id).changes;
620
+ let claimed = 0;
621
+ try {
622
+ claimed = db
623
+ .prepare(
624
+ `UPDATE task_queue SET status = 'starting', note = NULL, wait_code = NULL,
625
+ after_any_used = COALESCE(after_any_used, @used)
626
+ WHERE id = @id AND status = 'queued'`
627
+ )
628
+ .run({ id: r.id, used: opts.consume ?? null }).changes;
629
+ } catch (error) {
630
+ // Another waiter took that finish between the plan and the claim.
631
+ if (String(error).includes("UNIQUE")) return "held";
632
+ throw error;
633
+ }
471
634
  if (!claimed) return "held";
635
+ const placed = opts.placement ?? null;
636
+ if (!opts.force)
637
+ recordAutoStart(placed?.hostId ?? LOCAL, !!opts.heavy, Date.now());
472
638
  const task = {
473
639
  id: r.id,
474
640
  projectId: r.project_id,
@@ -484,9 +650,12 @@ async function startRow(
484
650
  view: r.view ?? undefined,
485
651
  agent: (r.agent_type as AgentType | null) ?? undefined,
486
652
  baseBranch: r.base_branch ?? undefined,
487
- hostId: r.host_id ?? undefined,
653
+ hostId: placed?.hostId ?? r.host_id ?? undefined,
488
654
  reclaim: true,
489
655
  };
656
+ const placedText = placed
657
+ ? `started on ${placed.name} · ${placed.why}`
658
+ : null;
490
659
  try {
491
660
  // The orchestrator's queued starts pass its brakes when they start,
492
661
  // a person's "start now" included: it skips only the limit and `after`.
@@ -505,7 +674,8 @@ async function startRow(
505
674
  error: null,
506
675
  note: null,
507
676
  });
508
- announce(r, session);
677
+ stampStarted(r, placed);
678
+ announce(r, session, placedText ? { text: placedText } : null);
509
679
  return "started";
510
680
  } catch (error) {
511
681
  if (error instanceof BrakeRefused) {
@@ -538,65 +708,328 @@ async function startRow(
538
708
  }
539
709
  }
540
710
 
541
- // One look at the line: per workspace, what fits under its limit, in order,
542
- // with its `after` met. Pure, for tests: rows in, ids to start out.
543
- export function planQueue(
544
- rows: (QueueRow & { workspace_id: string | null })[],
545
- running: (scope: { workspaceId: string | null; projectId: string }) => number,
546
- limit: (workspaceId: string | null) => number | null,
547
- ready: (r: QueueRow) => boolean
548
- ): string[] {
549
- const groups = new Map<string, typeof rows>();
550
- for (const r of rows) {
551
- const key = r.workspace_id ?? `project:${r.project_id}`;
552
- groups.set(key, [...(groups.get(key) ?? []), r]);
553
- }
554
- const start: string[] = [];
555
- for (const group of groups.values()) {
556
- const first = group[0];
557
- const scope = {
558
- workspaceId: first.workspace_id,
559
- projectId: first.project_id,
711
+ // Marks the task a queue row became: its priority, and where it was placed.
712
+ function stampStarted(r: QueueRow, placed: Placement | null): void {
713
+ const note = placed ? `Started on ${placed.name} · ${placed.why}` : null;
714
+ const priority =
715
+ r.priority && r.priority !== DEFAULT_PRIORITY ? r.priority : null;
716
+ if (!note && !priority) return;
717
+ db.prepare(
718
+ `UPDATE sessions SET priority = COALESCE(?, priority), placed_note = COALESCE(?, placed_note) WHERE id = ?`
719
+ ).run(priority, note, r.id);
720
+ }
721
+
722
+ type QueueRowWithWorkspace = QueueRow & { workspace_id: string | null };
723
+
724
+ const groupKey = (workspaceId: string | null, projectId: string) =>
725
+ workspaceId ?? `project:${projectId}`;
726
+
727
+ function groupFor(
728
+ workspaceId: string | null,
729
+ projectId: string,
730
+ startingRows: number
731
+ ): Group {
732
+ const scope = { workspaceId, projectId };
733
+ return {
734
+ inFlight:
735
+ runningIn(scope).length +
736
+ (workspaceId ? (reserved.get(workspaceId) ?? 0) : 0) +
737
+ startingRows,
738
+ limit: limitOf(workspaceId) ?? Number.POSITIVE_INFINITY,
739
+ };
740
+ }
741
+
742
+ // Finishes other waiters have taken, so one finish lets one waiter go.
743
+ const usedFinishes = (): string[] =>
744
+ (
745
+ db
746
+ .prepare(
747
+ `SELECT after_any_used AS id FROM task_queue WHERE after_any_used IS NOT NULL`
748
+ )
749
+ .all() as { id: string }[]
750
+ ).map((x) => x.id);
751
+
752
+ function candidateOf(
753
+ r: QueueRowWithWorkspace,
754
+ used: string[]
755
+ ): { c: Candidate; needs: Need[] } {
756
+ const project = getProject(r.project_id);
757
+ const hostId = r.host_id ?? project?.host_id;
758
+ const remote = isRemoteHost(hostId);
759
+ const needs = remote
760
+ ? []
761
+ : (parseNeeds(r.machine_needs) ??
762
+ (project
763
+ ? needsOf({
764
+ projectId: project.id,
765
+ projectPath: expandHome(project.working_directory),
766
+ prompt: r.prompt,
767
+ }).needs
768
+ : []));
769
+ let before: Hold | null = null;
770
+ if (r.after_task && !hasFinished(r.after_task))
771
+ before = {
772
+ code: "after",
773
+ text: `After ${afterLabel({ ...r, after_any: null }) ?? "its task"}`,
774
+ };
775
+ let any: Candidate["any"] = null;
776
+ if (r.after_any) {
777
+ const ids = JSON.parse(r.after_any) as string[];
778
+ any = {
779
+ ids,
780
+ finished: ids.filter((id) => !stillWorking(id)),
781
+ used: used.filter((id) => id !== r.after_any_used),
782
+ released: !!r.after_any_used,
783
+ label:
784
+ ids
785
+ .filter(stillWorking)
786
+ .map((id) => sessionOf(id)?.name ?? id.slice(0, 8))
787
+ .join(", ") || "its set",
560
788
  };
561
- const max = limit(first.workspace_id) ?? Number.POSITIVE_INFINITY;
562
- const queued = group.filter((r) => r.status === "queued");
563
- const inFlight =
564
- running(scope) + group.filter((r) => r.status === "starting").length;
565
- start.push(...fillSlots(queued, inFlight, max, ready).map((r) => r.id));
566
789
  }
567
- return start;
790
+ return {
791
+ needs,
792
+ c: {
793
+ id: r.id,
794
+ kind: "start",
795
+ group: groupKey(r.workspace_id, r.project_id),
796
+ priority: isPriority(r.priority) ? r.priority : DEFAULT_PRIORITY,
797
+ position: r.position,
798
+ waitingSinceMs: sqliteMs(r.created_at),
799
+ heavy: heavyNeeds(needs),
800
+ needsUnmet: !remote && !meetsNeeds(needs, machineCapabilities()),
801
+ remote,
802
+ before,
803
+ auto: autoHold(r.workspace_id),
804
+ any,
805
+ },
806
+ };
807
+ }
808
+
809
+ // The linked machines a task could be placed on, with what they report.
810
+ async function peerOptions(
811
+ rows: QueueRowWithWorkspace[],
812
+ now: number
813
+ ): Promise<Map<string, PeerOption[]>> {
814
+ const out = new Map<string, PeerOption[]>();
815
+ const hostIds = [...linkedHostIds()];
816
+ if (!hostIds.length || !rows.length) return out;
817
+ await refreshPeers(hostIds, now);
818
+ const perProject = new Map<string, Record<string, string | null>>();
819
+ for (const r of rows) {
820
+ if (out.has(r.project_id)) continue;
821
+ const project = getProject(r.project_id);
822
+ let can: Record<string, string | null> = {};
823
+ if (project) {
824
+ can =
825
+ perProject.get(project.id) ??
826
+ (await projectMachines(project).catch(() => ({})));
827
+ perProject.set(project.id, can);
828
+ }
829
+ out.set(
830
+ r.project_id,
831
+ hostIds.map((hostId) => {
832
+ const src = peerSource(hostId, now);
833
+ const reading = peerReading(hostId);
834
+ return {
835
+ hostId,
836
+ name: src.label,
837
+ view: viewOf(src, now),
838
+ capabilities: src.capabilities,
839
+ hasProject: can[hostId] === null,
840
+ loadPerCore: reading ? reading.load1 / reading.cores : 99,
841
+ };
842
+ })
843
+ );
844
+ }
845
+ return out;
568
846
  }
569
847
 
570
- async function tickOnce(): Promise<void> {
848
+ // One look at the line: every queued task and every paused task that came
849
+ // due, scored, held or started. Machine readings come from the monitor and
850
+ // the peer poll; the clock is passed in.
851
+ export async function tickOnce(now = Date.now()): Promise<"stale" | void> {
571
852
  const rows = db
572
853
  .prepare(
573
854
  `SELECT q.*, p.workspace_id AS workspace_id FROM task_queue q
574
855
  JOIN projects p ON p.id = q.project_id
575
856
  WHERE q.status IN ('queued', 'starting') ORDER BY q.position`
576
857
  )
577
- .all() as (QueueRow & { workspace_id: string | null })[];
578
- if (!rows.length) return;
579
- const plan = planQueue(
580
- rows,
581
- (scope) =>
582
- runningIn(scope).length +
583
- (scope.workspaceId ? (reserved.get(scope.workspaceId) ?? 0) : 0),
584
- limitOf,
585
- afterMet
858
+ .all() as QueueRowWithWorkspace[];
859
+ const resumes = dueResumes();
860
+ if (!rows.length && !resumes.length) return;
861
+ const queued = rows.filter((r) => r.status === "queued");
862
+ const used = usedFinishes();
863
+ const built = new Map(
864
+ queued.map((r) => [r.id, candidateOf(r, used)] as const)
865
+ );
866
+ const candidates: Candidate[] = [...built.values()].map((b) => b.c);
867
+ for (const d of resumes) {
868
+ const workspaceId = workspaceOf(d.projectId);
869
+ candidates.push({
870
+ id: `resume:${d.sessionId}`,
871
+ kind: "resume",
872
+ group: groupKey(workspaceId, d.projectId),
873
+ priority: d.priority,
874
+ position: -1_000_000 + d.pauseId,
875
+ waitingSinceMs: d.dueAtMs,
876
+ heavy: false,
877
+ needsUnmet: false,
878
+ remote: false,
879
+ before: null,
880
+ // A person's timed resume goes ahead of an orchestrator Pause.
881
+ auto: d.byKind === "person" ? null : autoHold(workspaceId),
882
+ any: null,
883
+ });
884
+ }
885
+
886
+ // Placement: a start the home machine's load rules out goes to a free
887
+ // linked machine, best score first, one per machine per look.
888
+ const placements = new Map<string, Placement>();
889
+ const home = localView(now);
890
+ const toPlace = candidates.filter(
891
+ (c) =>
892
+ c.kind === "start" &&
893
+ !c.remote &&
894
+ !unplaceable(row(c.id)!) &&
895
+ wantsPlacing(c, home, now)
586
896
  );
587
- for (const id of plan) {
897
+ if (toPlace.length) {
898
+ const before = lineSignature();
899
+ const peers = await peerOptions(
900
+ toPlace.map((c) => rows.find((r) => r.id === c.id)!),
901
+ now
902
+ );
903
+ // The line moved while the machines were asked: this look is stale, and
904
+ // the one a change during it asks for sees the line as it is now.
905
+ if (lineSignature() !== before) return "stale";
906
+ const taken = new Set<string>();
907
+ for (const c of [...toPlace].sort(
908
+ (a, b) =>
909
+ scoreParts(b, now).score - scoreParts(a, now).score ||
910
+ a.position - b.position
911
+ )) {
912
+ const r = rows.find((x) => x.id === c.id)!;
913
+ const options = (peers.get(r.project_id) ?? []).filter(
914
+ (p) => !taken.has(p.hostId)
915
+ );
916
+ const spot = place(c, home, options, built.get(c.id)!.needs, now);
917
+ if (!spot) continue;
918
+ taken.add(spot.hostId);
919
+ placements.set(c.id, spot);
920
+ c.remote = true;
921
+ }
922
+ }
923
+
924
+ const groups = new Map<string, Group>();
925
+ for (const c of candidates) {
926
+ if (groups.has(c.group)) continue;
927
+ const r = rows.find(
928
+ (x) => groupKey(x.workspace_id, x.project_id) === c.group
929
+ );
930
+ const projectId =
931
+ r?.project_id ??
932
+ resumes.find((d) => `resume:${d.sessionId}` === c.id)!.projectId;
933
+ const workspaceId = r?.workspace_id ?? workspaceOf(projectId);
934
+ groups.set(
935
+ c.group,
936
+ groupFor(
937
+ workspaceId,
938
+ projectId,
939
+ rows.filter(
940
+ (x) =>
941
+ x.status === "starting" &&
942
+ groupKey(x.workspace_id, x.project_id) === c.group
943
+ ).length
944
+ )
945
+ );
946
+ }
947
+
948
+ const plan = planStarts(candidates, groups, home, now);
949
+ for (const r of queued)
950
+ noteWaiting(r, withStayReason(r, plan.explain.get(r.id)));
951
+
952
+ for (const id of plan.start) {
953
+ if (id.startsWith("resume:")) {
954
+ await claimResume(id.slice("resume:".length), { reason: "due" });
955
+ continue;
956
+ }
588
957
  const r = row(id);
589
- if (r?.status === "queued") await startRow(r);
958
+ if (r?.status === "queued")
959
+ await startRow(r, {
960
+ consume: plan.consume.get(id) ?? null,
961
+ placement: placements.get(id) ?? null,
962
+ heavy: built.get(id)?.c.heavy,
963
+ });
964
+ }
965
+ }
966
+
967
+ // Writes why a row waits when that changed: its hold text and code. A bare
968
+ // rank isn't stored (the view computes it), so the stream stays quiet.
969
+ const MACHINE_HOLDS = new Set<WaitCode>([
970
+ "load_red",
971
+ "load_amber",
972
+ "heavy",
973
+ "needs",
974
+ ]);
975
+
976
+ // A hold on this machine's load says, for a task that can't go elsewhere,
977
+ // why it waits here instead of starting on a linked machine.
978
+ function withStayReason(
979
+ r: QueueRow,
980
+ explain: { code: WaitCode | null; text: string | null } | undefined
981
+ ) {
982
+ if (!explain?.code || !explain.text || !MACHINE_HOLDS.has(explain.code))
983
+ return explain;
984
+ const why = unplaceable(r);
985
+ return why ? { ...explain, text: `${explain.text} (${why})` } : explain;
986
+ }
987
+
988
+ function noteWaiting(
989
+ r: QueueRow,
990
+ explain: { code: WaitCode | null; text: string | null } | undefined
991
+ ): void {
992
+ if (!explain) return;
993
+ const hold = explain.code && explain.code !== "slot" ? explain : null;
994
+ if (hold) {
995
+ if (r.note !== hold.text || r.wait_code !== hold.code)
996
+ patch(r.id, { note: hold.text, wait_code: hold.code });
997
+ return;
590
998
  }
999
+ // A hold we wrote before is cleared; a brake's note isn't ours to clear.
1000
+ if (r.wait_code && r.wait_code !== "slot")
1001
+ patch(r.id, { note: null, wait_code: explain.code });
1002
+ else if (r.wait_code !== explain.code)
1003
+ patch(r.id, { wait_code: explain.code });
1004
+ }
1005
+
1006
+ function lineSignature(): string {
1007
+ const r = db
1008
+ .prepare(
1009
+ `SELECT COUNT(*) AS n, COALESCE(MAX(position), 0) AS p,
1010
+ COALESCE(SUM(priority = 'normal'), 0) AS pr,
1011
+ (SELECT COUNT(*) FROM sessions WHERE task_status IS NOT NULL
1012
+ AND task_status NOT IN ('merged', 'dropped')) AS running
1013
+ FROM task_queue WHERE status IN ('queued', 'starting')`
1014
+ )
1015
+ .get() as { n: number; p: number; pr: number; running: number };
1016
+ return `${r.n}:${r.p}:${r.pr}:${r.running}`;
591
1017
  }
592
1018
 
593
- // One look at a time; a call during one shares it.
1019
+ // One look at a time; a call during one shares it, and a look that went
1020
+ // stale (the line changed while it waited on a machine) is run again.
594
1021
  const tickState = (g.__agentosQueueTick ??= { ticking: null });
595
1022
 
596
1023
  export function tickQueue(): Promise<void> {
597
- tickState.ticking ??= tickOnce().finally(() => {
598
- tickState.ticking = null;
599
- });
1024
+ tickState.ticking ??= (async () => {
1025
+ try {
1026
+ for (let i = 0; i < 3; i++) {
1027
+ if ((await tickOnce()) !== "stale") break;
1028
+ }
1029
+ } finally {
1030
+ tickState.ticking = null;
1031
+ }
1032
+ })();
600
1033
  return tickState.ticking;
601
1034
  }
602
1035
 
@@ -646,6 +1079,31 @@ export function findQueued(
646
1079
 
647
1080
  export const queuedLabel = label;
648
1081
 
1082
+ // Changes the priority of a queued task or a running one (they share an
1083
+ // id). Age is kept. Throws when no such task is waiting or running.
1084
+ export function setPriority(id: string, priority: Priority): void {
1085
+ if (!isPriority(priority))
1086
+ throw new Error("Priority is one of: urgent, blocking, normal, background");
1087
+ const queued = db
1088
+ .prepare(
1089
+ `UPDATE task_queue SET priority = ? WHERE id = ? AND status IN ('queued', 'failed')`
1090
+ )
1091
+ .run(priority, id).changes;
1092
+ const running = db
1093
+ .prepare(
1094
+ `UPDATE sessions SET priority = ? WHERE id = ? AND task_status IS NOT NULL AND archived_at IS NULL`
1095
+ )
1096
+ .run(priority === DEFAULT_PRIORITY ? null : priority, id).changes;
1097
+ if (!queued && !running) throw new Error("No such queued or running task");
1098
+ tickQueueSoon();
1099
+ }
1100
+
1101
+ // A task started directly (never queued) keeps the priority it was given.
1102
+ export function stampPriority(id: string, priority: Priority | undefined) {
1103
+ if (!priority || priority === DEFAULT_PRIORITY) return;
1104
+ db.prepare(`UPDATE sessions SET priority = ? WHERE id = ?`).run(priority, id);
1105
+ }
1106
+
649
1107
  export function removeQueued(id: string): void {
650
1108
  const changed = db
651
1109
  .prepare(