@saadnvd1/agent-os 0.3.570 → 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 (71) hide show
  1. package/README.md +1 -1
  2. package/app/api/bus/spawn/route.ts +21 -3
  3. package/app/api/client-config/route.test.ts +11 -0
  4. package/app/api/client-config/route.ts +10 -0
  5. package/app/api/load/route.test.ts +32 -0
  6. package/app/api/load/route.ts +20 -0
  7. package/app/api/sessions/[id]/pause/route.ts +97 -0
  8. package/app/api/tasks/[id]/priority/route.ts +29 -0
  9. package/app/api/tasks/route.ts +17 -2
  10. package/components/Chat/AccessPicker.tsx +10 -18
  11. package/components/Chat/ChatPanel.tsx +4 -0
  12. package/components/Chat/ModelPicker.tsx +9 -3
  13. package/components/Chat/PausedBanner.tsx +54 -0
  14. package/components/Chat/composer/ComposerBody.tsx +3 -0
  15. package/components/Chat/composer/Toolbar.tsx +3 -0
  16. package/components/Draft/DraftChips.tsx +25 -0
  17. package/components/Draft/DraftPanel.tsx +1 -0
  18. package/components/Recipes/format.ts +19 -30
  19. package/components/SessionList/QueuedRow.tsx +46 -2
  20. package/components/SessionList/RowSubtitle.tsx +21 -3
  21. package/components/SessionList/SessionRowMenu.tsx +49 -1
  22. package/components/SessionList/useShelfActions.ts +39 -2
  23. package/components/Tasks/task-state-labels.ts +16 -16
  24. package/data/chat/useChat.ts +3 -0
  25. package/data/sessions/shelf.ts +50 -0
  26. package/data/tasks/index.ts +2 -0
  27. package/data/tasks/queue.ts +38 -1
  28. package/lib/bus/index.ts +21 -2
  29. package/lib/chat/access-levels.ts +24 -0
  30. package/lib/chat/events.ts +2 -0
  31. package/lib/chat/hold.ts +11 -2
  32. package/lib/chat/settings.ts +6 -2
  33. package/lib/config/client-config-types.ts +29 -0
  34. package/lib/config/client-config.ts +28 -0
  35. package/lib/db/migrations.ts +56 -1
  36. package/lib/db/types.ts +30 -0
  37. package/lib/done/index.ts +2 -0
  38. package/lib/drafts.ts +3 -0
  39. package/lib/hosts/peer-load.ts +120 -0
  40. package/lib/load/machines.ts +124 -0
  41. package/lib/load/monitor.ts +17 -2
  42. package/lib/load/start-gate.ts +95 -0
  43. package/lib/model-catalog.ts +11 -0
  44. package/lib/orchestrator/act.ts +78 -0
  45. package/lib/orchestrator/brakes.ts +2 -0
  46. package/lib/orchestrator/brief.ts +4 -1
  47. package/lib/orchestrator/digest.ts +15 -4
  48. package/lib/orchestrator/serve.ts +18 -0
  49. package/lib/orchestrator/tool-names.ts +3 -0
  50. package/lib/orchestrator/tool-schemas.ts +33 -0
  51. package/lib/orchestrator/tools.ts +7 -1
  52. package/lib/project-config/agentos.schema.json +13 -0
  53. package/lib/project-config/schema.ts +9 -0
  54. package/lib/recipes/status-labels.ts +27 -0
  55. package/lib/sidebar/paused.ts +13 -0
  56. package/lib/sidebar/queued.ts +34 -1
  57. package/lib/sidebar/shelves.ts +21 -1
  58. package/lib/sidebar/snooze.ts +26 -0
  59. package/lib/tasks/finish.ts +7 -0
  60. package/lib/tasks/move-carry.ts +1 -0
  61. package/lib/tasks/move-flow.ts +6 -0
  62. package/lib/tasks/pause-hold.ts +70 -0
  63. package/lib/tasks/pause-policy.ts +130 -0
  64. package/lib/tasks/pause.ts +718 -0
  65. package/lib/tasks/placement.ts +219 -0
  66. package/lib/tasks/queue.ts +522 -64
  67. package/lib/tasks/schedule-types.ts +64 -0
  68. package/lib/tasks/schedule.ts +271 -0
  69. package/lib/tasks/state-labels.ts +21 -0
  70. package/package.json +1 -1
  71. package/server.ts +2 -1
@@ -14,7 +14,7 @@ import { moveSchedulesToRecipes } from "./schedules-move";
14
14
  // integer `id`: `key: "YYYYMMDDTHHMM_snake_name"` (UTC, e.g.
15
15
  // "20261014T0130_share_links"). Hand-picked numbers collide when two tasks run
16
16
  // in parallel and the loser is silently skipped on databases that already
17
- // applied the winner. Ids 1..88 are legacy: they run first, by number, and
17
+ // applied the winner. Ids 1..89 are legacy: they run first, by number, and
18
18
  // never change. Timestamp keys run after them, in lexical order. Never edit a
19
19
  // migration that has shipped; add a new one. lib/db/migrations.test.ts
20
20
  // enforces all of this.
@@ -2187,6 +2187,61 @@ export const migrations: Migration[] = [
2187
2187
  );
2188
2188
  },
2189
2189
  },
2190
+ {
2191
+ key: "20261014T0900_task_scheduling",
2192
+ up: (db) => {
2193
+ // Priority, "after any" consumption, and timed pause. Additive: every
2194
+ // existing row reads as normal priority and not paused.
2195
+ const has = (table: string, col: string) =>
2196
+ (
2197
+ db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
2198
+ ).some((c) => c.name === col);
2199
+ const add = (table: string, col: string, def: string) => {
2200
+ if (!has(table, col))
2201
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`);
2202
+ };
2203
+ add("task_queue", "priority", "TEXT NOT NULL DEFAULT 'normal'");
2204
+ add("task_queue", "machine_needs", "TEXT");
2205
+ add("task_queue", "after_any_used", "TEXT");
2206
+ add("task_queue", "wait_code", "TEXT");
2207
+ db.exec(
2208
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_task_queue_after_any_used
2209
+ ON task_queue(after_any_used) WHERE after_any_used IS NOT NULL`
2210
+ );
2211
+ add("sessions", "priority", "TEXT");
2212
+ add("sessions", "pause_state", "TEXT");
2213
+ add("sessions", "paused_until", "TEXT");
2214
+ add("sessions", "pause_id", "INTEGER");
2215
+ // "Started on <machine> · <why>" for a task the scheduler placed.
2216
+ add("sessions", "placed_note", "TEXT");
2217
+ add("bus_messages", "held_pause_id", "INTEGER");
2218
+ add("projects", "machine_needs", "TEXT");
2219
+ add("projects", "machine_needs_source", "TEXT");
2220
+ db.exec(`
2221
+ CREATE TABLE IF NOT EXISTS task_pauses (
2222
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2223
+ session_id TEXT NOT NULL,
2224
+ mode TEXT NOT NULL DEFAULT 'turn',
2225
+ by_kind TEXT NOT NULL DEFAULT 'person',
2226
+ by_id TEXT,
2227
+ reason TEXT,
2228
+ until_at TEXT,
2229
+ state TEXT NOT NULL DEFAULT 'pausing',
2230
+ requested_at TEXT NOT NULL DEFAULT (datetime('now')),
2231
+ paused_at TEXT,
2232
+ resume_requested_at TEXT,
2233
+ resume_by_kind TEXT,
2234
+ resumed_at TEXT,
2235
+ ended_at TEXT,
2236
+ end_reason TEXT,
2237
+ interrupted INTEGER NOT NULL DEFAULT 0,
2238
+ held_count INTEGER NOT NULL DEFAULT 0
2239
+ );
2240
+ CREATE INDEX IF NOT EXISTS idx_task_pauses_session
2241
+ ON task_pauses(session_id, state);
2242
+ `);
2243
+ },
2244
+ },
2190
2245
  ];
2191
2246
 
2192
2247
  // `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
package/lib/db/types.ts CHANGED
@@ -86,6 +86,13 @@ export interface Session {
86
86
  // Snoozed until this time, from snoozed_at; activity since wakes it early.
87
87
  snoozed_until?: string | null;
88
88
  snoozed_at?: string | null;
89
+ // Task priority (null reads as normal) and timed pause (lib/tasks/pause.ts):
90
+ // pausing, paused or resuming, until when (null: until resumed).
91
+ priority?: string | null;
92
+ pause_state?: "pausing" | "paused" | "resuming" | null;
93
+ paused_until?: string | null;
94
+ pause_id?: number | null;
95
+ placed_note?: string | null;
89
96
  // Set by done: hidden from the sidebar and the orchestrator, never deleted.
90
97
  archived_at: string | null;
91
98
  // On the sidebar's Pinned shelf (0/1 from SQLite).
@@ -172,10 +179,33 @@ export interface Project {
172
179
  lh_board_id: string | null;
173
180
  lh_board_name: string | null;
174
181
  lh_board_page_id: string | null;
182
+ // What its tasks need from a machine (JSON array), and where that came from.
183
+ machine_needs?: string | null;
184
+ machine_needs_source?: string | null;
175
185
  created_at: string;
176
186
  updated_at: string;
177
187
  }
178
188
 
189
+ export interface TaskPause {
190
+ id: number;
191
+ session_id: string;
192
+ mode: "turn" | "now";
193
+ by_kind: "person" | "orchestrator" | "scheduler";
194
+ by_id: string | null;
195
+ reason: string | null;
196
+ until_at: string | null;
197
+ state: "pausing" | "paused" | "resuming" | "ended";
198
+ requested_at: string;
199
+ paused_at: string | null;
200
+ resume_requested_at: string | null;
201
+ resume_by_kind: string | null;
202
+ resumed_at: string | null;
203
+ ended_at: string | null;
204
+ end_reason: "resumed" | "cancelled" | "finished" | "failed" | null;
205
+ interrupted: number;
206
+ held_count: number;
207
+ }
208
+
179
209
  export interface ProjectDevServer {
180
210
  id: string;
181
211
  project_id: string;
package/lib/done/index.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * its own action, archive.ts). What it will do is decided first (plan.ts).
9
9
  */
10
10
 
11
+ import { endPauseOnFinish } from "../tasks/pause-hold";
11
12
  import { db, queries, type Session } from "../db";
12
13
  import { endSessionAgent } from "../sessions/end";
13
14
  import { clearQueue } from "../chat/queued";
@@ -112,6 +113,7 @@ function mark(s: Session, status: "merged" | "done", pr: TaskPR | null) {
112
113
  ? `UPDATE sessions SET task_status = 'merged', pr_status = 'merged' WHERE id = ?`
113
114
  : `UPDATE sessions SET task_status = 'done' WHERE id = ?`
114
115
  ).run(s.id);
116
+ endPauseOnFinish(s.id);
115
117
  syncTaskCardInBackground(s, status, pr);
116
118
  void sessionEnded(s.id, status);
117
119
  if (status === "merged") {
package/lib/drafts.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { AgentType } from "./providers";
7
+ import type { Priority } from "@/lib/tasks/schedule-types";
7
8
  import type { ChatAccess } from "./chat/events";
8
9
  import { resolveModelForAgent } from "./model-catalog";
9
10
  import { projectsInWorkspace } from "./sidebar/shelves";
@@ -26,6 +27,8 @@ export interface Draft {
26
27
  // A task's own model, "provider/model"; unset, its roles' models
27
28
  // (lib/models).
28
29
  taskModel?: string | null;
30
+ // A task's priority in the queue; unset: normal.
31
+ taskPriority?: Priority;
29
32
  createdAt: number;
30
33
  }
31
34
 
@@ -0,0 +1,120 @@
1
+ /**
2
+ * How loaded each linked machine is, for placing tasks. The placing server
3
+ * polls a peer's GET /api/load every PEER_POLL_MS, only while a task is
4
+ * waiting that could go there. A reading older than PEER_STALE_MS counts as
5
+ * red, so nothing is placed on a machine that can't be seen.
6
+ */
7
+
8
+ import { getHost } from "./index";
9
+ import { hostApi, hostLink, linkedHostIds } from "./remote-api";
10
+ import { feedGate } from "../load/start-gate";
11
+ import type { MachineSource } from "../load/machines";
12
+ import type { LoadLevel } from "../load/level";
13
+
14
+ export const PEER_POLL_MS = 15_000;
15
+ export const PEER_STALE_MS = 60_000;
16
+
17
+ export interface PeerReading {
18
+ at: number;
19
+ level: LoadLevel;
20
+ load1: number;
21
+ cores: number;
22
+ memUsedPct: number;
23
+ booted: number;
24
+ capabilities: string[];
25
+ }
26
+
27
+ const g = globalThis as unknown as {
28
+ __agentosPeerLoad?: Map<string, PeerReading>;
29
+ __agentosPeerPolling?: Map<string, Promise<void>>;
30
+ };
31
+ const readings = (g.__agentosPeerLoad ??= new Map<string, PeerReading>());
32
+ const polling = (g.__agentosPeerPolling ??= new Map<string, Promise<void>>());
33
+
34
+ const LEVELS = ["green", "amber", "red"];
35
+ const num = (v: unknown, max = 1e6) =>
36
+ typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= max ? v : null;
37
+
38
+ // Another machine's answer is data, not trust: anything off is no reading.
39
+ export function parseReading(body: unknown, at: number): PeerReading | null {
40
+ if (!body || typeof body !== "object") return null;
41
+ const b = body as Record<string, unknown>;
42
+ const level = b.level;
43
+ const load1 = num(b.load1);
44
+ const cores = num(b.cores, 4096);
45
+ if (typeof level !== "string" || !LEVELS.includes(level)) return null;
46
+ if (load1 === null || !cores) return null;
47
+ const caps = Array.isArray(b.capabilities)
48
+ ? b.capabilities
49
+ .filter((c): c is string => typeof c === "string" && c.length <= 24)
50
+ .slice(0, 12)
51
+ : [];
52
+ return {
53
+ at,
54
+ level: level as LoadLevel,
55
+ load1,
56
+ cores,
57
+ memUsedPct: num(b.memUsedPct, 100) ?? 0,
58
+ booted: num(b.booted, 1000) ?? 0,
59
+ capabilities: caps,
60
+ };
61
+ }
62
+
63
+ export type LoadFetch = (hostId: string) => Promise<unknown>;
64
+
65
+ const fetchLoad: LoadFetch = async (hostId) => {
66
+ const link = hostLink(hostId);
67
+ if (!link) throw new Error("not linked");
68
+ return hostApi<unknown>(link, "/api/load", { timeout: 5000 });
69
+ };
70
+
71
+ // Reads every linked machine whose last reading is older than the poll time.
72
+ // A failure keeps the old reading, which then ages into stale.
73
+ export async function refreshPeers(
74
+ hostIds: Iterable<string> = linkedHostIds(),
75
+ now = Date.now(),
76
+ fetcher: LoadFetch = fetchLoad
77
+ ): Promise<void> {
78
+ await Promise.all(
79
+ [...hostIds].map((hostId) => {
80
+ const last = readings.get(hostId);
81
+ if (last && now - last.at < PEER_POLL_MS) return;
82
+ const running = polling.get(hostId);
83
+ if (running) return running;
84
+ const p = fetcher(hostId)
85
+ .then((body) => {
86
+ const r = parseReading(body, now);
87
+ if (!r) return;
88
+ readings.set(hostId, r);
89
+ feedGate(hostId, r.level, now);
90
+ })
91
+ .catch(() => undefined)
92
+ .finally(() => polling.delete(hostId));
93
+ polling.set(hostId, p);
94
+ return p;
95
+ })
96
+ );
97
+ }
98
+
99
+ export function setPeerReading(hostId: string, r: PeerReading | null): void {
100
+ if (r) readings.set(hostId, r);
101
+ else readings.delete(hostId);
102
+ }
103
+
104
+ export function peerReading(hostId: string): PeerReading | null {
105
+ return readings.get(hostId) ?? null;
106
+ }
107
+
108
+ export function peerSource(hostId: string, now: number): MachineSource {
109
+ const r = readings.get(hostId);
110
+ const stale = !r || now - r.at > PEER_STALE_MS;
111
+ return {
112
+ hostId,
113
+ label: getHost(hostId)?.name ?? "that machine",
114
+ level: r?.level ?? null,
115
+ booted: r?.booted ?? 0,
116
+ maxBooted: 2,
117
+ capabilities: r?.capabilities ?? [],
118
+ stale,
119
+ };
120
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * What the scheduler knows about a machine when it decides to start a task
3
+ * there: how loaded it is (sampled by the monitor every 10s, or polled from a
4
+ * linked machine every 15s), whether it has been calm long enough, how many
5
+ * simulators are booted, and when it last started something. Nothing here
6
+ * samples anything itself: the load is the thing being protected.
7
+ */
8
+
9
+ import os from "os";
10
+ import { bootedCount, maxBooted } from "../simulator/lease";
11
+ import { simulatorAvailable } from "../simulator/available";
12
+ import { currentLevel, loadEnabled } from "./monitor";
13
+ import type { LoadLevel } from "./level";
14
+ import type { MachineLevel, MachineView } from "../tasks/schedule";
15
+ import { HEAVY_BOOT_MS, LOCAL, gateOf, startsBy } from "./start-gate";
16
+
17
+ export {
18
+ HEAVY_BOOT_MS,
19
+ LOCAL,
20
+ StartGate,
21
+ feedGate,
22
+ recordAutoStart,
23
+ resetMachineStarts,
24
+ } from "./start-gate";
25
+
26
+ // Development only: pin the level so the holds can be shown without loading
27
+ // the machine.
28
+ function overrideLevel(): MachineLevel | null {
29
+ if (process.env.NODE_ENV !== "development") return null;
30
+ const v = process.env.AGENTOS_LOAD_LEVEL;
31
+ return v === "red" || v === "amber" || v === "green" ? v : null;
32
+ }
33
+
34
+ export interface LocalFacts {
35
+ level: LoadLevel | null;
36
+ load1: number;
37
+ cores: number;
38
+ memUsedPct: number;
39
+ booted: number;
40
+ capabilities: string[];
41
+ }
42
+
43
+ // What this machine can run. Only a Mac with Xcode's tools gives a simulator.
44
+ export function machineCapabilities(): string[] {
45
+ if (process.platform !== "darwin") return [process.platform];
46
+ return simulatorAvailable() ? ["mac", "xcode", "simulator"] : ["mac"];
47
+ }
48
+
49
+ export function localFacts(): LocalFacts {
50
+ const cores = os.cpus().length || 1;
51
+ const total = os.totalmem();
52
+ return {
53
+ level: overrideLevel() ?? currentLevel(),
54
+ load1: Math.round(os.loadavg()[0] * 10) / 10,
55
+ cores,
56
+ memUsedPct: Math.round(((total - os.freemem()) / total) * 100),
57
+ booted: bootedCount(),
58
+ capabilities: machineCapabilities(),
59
+ };
60
+ }
61
+
62
+ export interface MachineSource {
63
+ hostId: string;
64
+ label: string;
65
+ level: LoadLevel | null;
66
+ booted: number;
67
+ maxBooted: number;
68
+ capabilities: string[];
69
+ // A peer whose reading is missing or older than the stale limit.
70
+ stale?: boolean;
71
+ }
72
+
73
+ // The scheduler's view of a machine, from its readings and the start
74
+ // bookkeeping. Pure given those.
75
+ export function viewOf(src: MachineSource, now: number): MachineView {
76
+ const gate = gateOf(src.hostId).view(now, src.level);
77
+ const s = startsBy.get(src.hostId);
78
+ const level: MachineLevel = src.stale ? "red" : (src.level ?? "green");
79
+ return {
80
+ label: src.label,
81
+ enabled: src.hostId !== LOCAL || loadEnabled(),
82
+ reading: src.stale ? false : gate.reading && src.level === null,
83
+ level,
84
+ settled: src.stale ? false : gate.settled,
85
+ settleLeftMs: gate.settleLeftMs,
86
+ booted: src.booted,
87
+ maxBooted: src.maxBooted,
88
+ heavyStarting: (s?.heavy ?? []).filter((t) => now - t < HEAVY_BOOT_MS)
89
+ .length,
90
+ lastAutoStartAt: s?.auto ?? null,
91
+ lastHeavyStartAt: s?.heavy.length ? Math.max(...s.heavy) : null,
92
+ };
93
+ }
94
+
95
+ export function localView(now = Date.now()): MachineView {
96
+ const facts = localFacts();
97
+ const over = overrideLevel();
98
+ if (over) {
99
+ // The pinned level counts as long observed: calm unless red.
100
+ const view = viewOf(
101
+ {
102
+ hostId: LOCAL,
103
+ label: "this machine",
104
+ level: over,
105
+ booted: facts.booted,
106
+ maxBooted: maxBooted(),
107
+ capabilities: facts.capabilities,
108
+ },
109
+ now
110
+ );
111
+ return { ...view, reading: false, settled: over !== "red" };
112
+ }
113
+ return viewOf(
114
+ {
115
+ hostId: LOCAL,
116
+ label: "this machine",
117
+ level: facts.level,
118
+ booted: facts.booted,
119
+ maxBooted: maxBooted(),
120
+ capabilities: facts.capabilities,
121
+ },
122
+ now
123
+ );
124
+ }
@@ -1,6 +1,7 @@
1
1
  // The machine-load monitor: samples the load every 10s and each session's
2
2
  // process trees every 30s, pushes both over /ws/status, and raises one alert
3
- // when the load stays red. It never stops, pauses or queues anything.
3
+ // when the load stays red. It never stops or pauses anything; it feeds the
4
+ // start gate (start-gate.ts) that holds queued tasks while the load is red.
4
5
  import os from "os";
5
6
  import { db } from "../db";
6
7
  import { publishLoad } from "../status/hub";
@@ -11,6 +12,7 @@ import { readPressure } from "./pressure";
11
12
  import { readUsage, type Usage } from "./usage";
12
13
  import { workerTmuxName } from "../chat/worker/protocol";
13
14
  import { alertText, raiseAlert } from "./alert";
15
+ import { LOCAL, feedGate, gateOpen } from "./start-gate";
14
16
 
15
17
  export const SAMPLE_MS = 10_000;
16
18
  export const USAGE_MS = 30_000;
@@ -53,6 +55,13 @@ const state: State = (g.__agentosLoad ??= {
53
55
  timers: [],
54
56
  });
55
57
 
58
+ // The queue lives above this module (it reads the load); reach it lazily.
59
+ function nudgeQueue(): void {
60
+ void import("../tasks/queue")
61
+ .then((q) => q.tickQueueSoon())
62
+ .catch(() => undefined);
63
+ }
64
+
56
65
  export const heavyRegistry = () => state.registry;
57
66
  // AGENTOS_LOAD=off: no sampling, no notes, no alerts.
58
67
  export const loadEnabled = () => process.env.AGENTOS_LOAD !== "off";
@@ -78,8 +87,14 @@ async function sample(): Promise<void> {
78
87
  const cores = os.cpus().length || 1;
79
88
  const load1 = os.loadavg()[0];
80
89
  const pressure = await readPressure();
81
- const level = nextLevel(state.level ?? "green", load1 / cores, pressure);
90
+ const prev = state.level;
91
+ const level = nextLevel(prev ?? "green", load1 / cores, pressure);
82
92
  state.level = level;
93
+ const t = Date.now();
94
+ const wasOpen = gateOpen(LOCAL, prev, t);
95
+ feedGate(LOCAL, level, t);
96
+ // Starts held for load go ahead within a sample of the gate opening.
97
+ if (prev !== level || wasOpen !== gateOpen(LOCAL, level, t)) nudgeQueue();
83
98
  const total = os.totalmem();
84
99
  const sessions: Record<string, Usage> = {};
85
100
  for (const s of Object.values(state.usage))
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The start gate and the per-machine start bookkeeping, apart from the
3
+ * monitor so the monitor can feed it without a cycle.
4
+ */
5
+
6
+ import type { LoadLevel } from "./level";
7
+ import { LOAD_SETTLE_MS } from "../tasks/schedule";
8
+
9
+ // A simulator takes about this long to boot and hold a lease.
10
+ export const HEAVY_BOOT_MS = 2 * 60_000;
11
+
12
+ // Closed while red; opens once the level has stayed out of red for the
13
+ // settle time. Before the first sample it is closed too ("reading").
14
+ export class StartGate {
15
+ private lastRedAt: number | null = null;
16
+ private seen = false;
17
+
18
+ constructor(private settleMs = LOAD_SETTLE_MS) {}
19
+
20
+ // True when the gate's answer changed (the queue may start something).
21
+ feed(level: LoadLevel, now: number): boolean {
22
+ const before = this.view(now, level);
23
+ this.seen = true;
24
+ if (level === "red") this.lastRedAt = now;
25
+ const after = this.view(now, level);
26
+ return before.reading !== after.reading || before.settled !== after.settled;
27
+ }
28
+
29
+ view(now: number, level: LoadLevel | null) {
30
+ const calm = this.lastRedAt === null ? Infinity : now - this.lastRedAt;
31
+ const settled = this.seen && level !== "red" && calm >= this.settleMs;
32
+ return {
33
+ reading: !this.seen,
34
+ settled,
35
+ settleLeftMs:
36
+ this.lastRedAt === null ? 0 : Math.max(0, this.settleMs - calm),
37
+ };
38
+ }
39
+ }
40
+
41
+ interface Starts {
42
+ auto: number | null;
43
+ heavy: number[];
44
+ }
45
+
46
+ const g = globalThis as unknown as {
47
+ __agentosStartGates?: Map<string, StartGate>;
48
+ __agentosMachineStarts?: Map<string, Starts>;
49
+ };
50
+ const gates = (g.__agentosStartGates ??= new Map<string, StartGate>());
51
+ export const startsBy = (g.__agentosMachineStarts ??= new Map<
52
+ string,
53
+ Starts
54
+ >());
55
+
56
+ export const LOCAL = "local";
57
+
58
+ export const gateOf = (hostId: string) => {
59
+ let gate = gates.get(hostId);
60
+ if (!gate) gates.set(hostId, (gate = new StartGate()));
61
+ return gate;
62
+ };
63
+
64
+ // Fed by the monitor after each sample, and by the peer poll. True when it
65
+ // opened or closed.
66
+ export function feedGate(hostId: string, level: LoadLevel, now = Date.now()) {
67
+ return gateOf(hostId).feed(level, now);
68
+ }
69
+
70
+ // An automatic start on a machine just happened (or was placed there).
71
+ export function recordAutoStart(
72
+ hostId: string,
73
+ heavy: boolean,
74
+ now = Date.now()
75
+ ): void {
76
+ const s = startsBy.get(hostId) ?? { auto: null, heavy: [] };
77
+ s.auto = now;
78
+ if (heavy) s.heavy.push(now);
79
+ startsBy.set(hostId, s);
80
+ }
81
+
82
+ export function resetMachineStarts(): void {
83
+ startsBy.clear();
84
+ gates.clear();
85
+ }
86
+
87
+ // Whether automatic starts may go ahead on a machine, by its gate alone.
88
+ export function gateOpen(
89
+ hostId: string,
90
+ level: LoadLevel | null,
91
+ now: number
92
+ ): boolean {
93
+ const v = gateOf(hostId).view(now, level);
94
+ return v.settled && !v.reading;
95
+ }
@@ -84,3 +84,14 @@ export function resolveModelForAgent(
84
84
 
85
85
  return getDefaultModelForAgent(agentType);
86
86
  }
87
+
88
+ // A readable name for a model id: "claude-opus-5-5" is "Opus 5.5", the alias
89
+ // "opus" is "Opus". An id it can't read passes through unchanged.
90
+ export function modelDisplayName(id: string): string {
91
+ const alias = CLAUDE_MODEL_OPTIONS.find((o) => o.value === id);
92
+ if (alias) return alias.label;
93
+ const m = /^claude-([a-z]+)-(\d{1,2}(?:-\d{1,2})*)(?:-\d{8})?$/.exec(id);
94
+ if (!m) return id;
95
+ const family = m[1][0].toUpperCase() + m[1].slice(1);
96
+ return `${family} ${m[2].replace(/-/g, ".")}`;
97
+ }
@@ -41,6 +41,15 @@ import { getOrchestrator } from "./home";
41
41
  import { addNote } from "./notes";
42
42
  import { findWorkspaceSession } from "./read";
43
43
  import { workspaceProject, workspaceStack, workspaceTask } from "./targets";
44
+ import { setPriority, stampPriority } from "../tasks/queue";
45
+ import {
46
+ PauseRefused,
47
+ activePause,
48
+ requestPause,
49
+ requestResume,
50
+ } from "../tasks/pause";
51
+ import { pauseUntil, type PausePreset } from "../sidebar/snooze";
52
+ import type { Priority } from "../tasks/schedule-types";
44
53
 
45
54
  function orchestratorId(workspaceId: string): string {
46
55
  const o = getOrchestrator(workspaceId);
@@ -104,6 +113,8 @@ export async function startTask(
104
113
  plan?: boolean;
105
114
  planOnly?: boolean;
106
115
  planReason?: string;
116
+ priority?: Priority;
117
+ machine?: "mac" | "any";
107
118
  } = {}
108
119
  ): Promise<string> {
109
120
  if (opts.planOnly && opts.plan === false)
@@ -133,6 +144,8 @@ export async function startTask(
133
144
  baseBranch: opts.base || undefined,
134
145
  after: opts.after,
135
146
  originWorkspaceId: workspaceId,
147
+ priority: opts.priority,
148
+ machine: opts.machine,
136
149
  },
137
150
  () =>
138
151
  braked(
@@ -159,6 +172,7 @@ export async function startTask(
159
172
  return `Queued (position ${queued.position}) task "${queued.name}" (id ${queued.id.slice(0, 8)}) in ${project.name}${queued.after ? `, after ${queued.after}` : ""}. It starts by itself; you'll get an event when it does.`;
160
173
  }
161
174
  const task = out.started;
175
+ if (opts.priority) stampPriority(task.id, opts.priority);
162
176
  const planOnlyNote = opts.planOnly
163
177
  ? " Plan only: it stops after the plan and waits; approve_plan continues it, drop rejects it."
164
178
  : "";
@@ -354,3 +368,67 @@ export async function moveTaskTo(
354
368
 
355
369
  const machineName = (hostId: string) =>
356
370
  getHost(hostId)?.name ?? "the other machine";
371
+
372
+ // A queued task re-ranks at once; a running one only carries the word.
373
+ export function setTaskPriority(
374
+ workspaceId: string,
375
+ ref: string,
376
+ priority: Priority
377
+ ): string {
378
+ const queued = findQueued(workspaceId, ref, { byName: true });
379
+ if (queued) {
380
+ setPriority(queued.id, priority);
381
+ return `${queued.name} is ${priority} in the queue now.`;
382
+ }
383
+ const task = workspaceTask(workspaceId, ref);
384
+ setPriority(task.id, priority);
385
+ const running = task.task_status === "running";
386
+ return running
387
+ ? `${task.name} is ${priority}. It's running, so this only matters if it is paused and waits to resume.`
388
+ : `${task.name} is ${priority}. It's ${task.task_status ?? "not queued"}, so this changes nothing now.`;
389
+ }
390
+
391
+ const PAUSE_FOR: Record<string, PausePreset> = {
392
+ "1h": "1h",
393
+ "2h": "2h",
394
+ tomorrow: "tomorrow",
395
+ until_resumed: "until-resumed",
396
+ };
397
+
398
+ export function pauseTask(
399
+ workspaceId: string,
400
+ ref: string,
401
+ howLong: string,
402
+ now: boolean,
403
+ reason: string
404
+ ): string {
405
+ const task = workspaceTask(workspaceId, ref);
406
+ const preset = PAUSE_FOR[howLong];
407
+ if (!preset) throw new Error("for is 1h, 2h, tomorrow or until_resumed");
408
+ try {
409
+ requestPause(task.id, {
410
+ until: pauseUntil(preset),
411
+ mode: now ? "now" : "turn",
412
+ by: "orchestrator",
413
+ byId: orchestratorId(workspaceId),
414
+ reason,
415
+ });
416
+ } catch (error) {
417
+ if (error instanceof PauseRefused)
418
+ throw new Error(`Can't pause ${task.name}: ${error.message}`);
419
+ throw error;
420
+ }
421
+ return `Pausing ${task.name} ${now ? "now" : "at the end of its step"}. Its slot frees once it has stopped; an event says when.`;
422
+ }
423
+
424
+ export function resumeTask(workspaceId: string, ref: string): string {
425
+ const task = workspaceTask(workspaceId, ref);
426
+ if (!activePause(task.id)) throw new Error(`${task.name} isn't paused`);
427
+ try {
428
+ requestResume(task.id, "orchestrator");
429
+ } catch (error) {
430
+ if (error instanceof PauseRefused) throw new Error(error.message);
431
+ throw error;
432
+ }
433
+ return `${task.name} will resume as soon as it has a slot.`;
434
+ }
@@ -53,6 +53,8 @@ export async function runningCount(
53
53
  const running = new Set<string>();
54
54
  for (const s of workspaceSessions(workspaceId)) {
55
55
  if (s.task_status && s.task_status !== "running") continue;
56
+ // A paused task has stopped its agent and holds no slot.
57
+ if (s.pause_state === "paused") continue;
56
58
  // A task still setting up, or held at launch, has no terminal yet, and
57
59
  // its agent will launch.
58
60
  if (