@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.
- package/README.md +1 -1
- package/app/api/bus/spawn/route.ts +21 -3
- package/app/api/client-config/route.test.ts +11 -0
- package/app/api/client-config/route.ts +10 -0
- package/app/api/load/route.test.ts +32 -0
- package/app/api/load/route.ts +20 -0
- package/app/api/sessions/[id]/pause/route.ts +97 -0
- package/app/api/tasks/[id]/priority/route.ts +29 -0
- package/app/api/tasks/route.ts +17 -2
- package/components/Chat/AccessPicker.tsx +10 -18
- package/components/Chat/ChatPanel.tsx +4 -0
- package/components/Chat/ModelPicker.tsx +9 -3
- package/components/Chat/PausedBanner.tsx +54 -0
- package/components/Chat/composer/ComposerBody.tsx +3 -0
- package/components/Chat/composer/Toolbar.tsx +3 -0
- package/components/Draft/DraftChips.tsx +25 -0
- package/components/Draft/DraftPanel.tsx +1 -0
- package/components/Recipes/format.ts +19 -30
- package/components/SessionList/QueuedRow.tsx +46 -2
- package/components/SessionList/RowSubtitle.tsx +21 -3
- package/components/SessionList/SessionRowMenu.tsx +49 -1
- package/components/SessionList/useShelfActions.ts +39 -2
- package/components/Tasks/task-state-labels.ts +16 -16
- package/data/chat/useChat.ts +3 -0
- package/data/sessions/shelf.ts +50 -0
- package/data/tasks/index.ts +2 -0
- package/data/tasks/queue.ts +38 -1
- package/lib/bus/index.ts +21 -2
- package/lib/chat/access-levels.ts +24 -0
- package/lib/chat/events.ts +2 -0
- package/lib/chat/hold.ts +11 -2
- package/lib/chat/settings.ts +6 -2
- package/lib/config/client-config-types.ts +29 -0
- package/lib/config/client-config.ts +28 -0
- package/lib/db/migrations.ts +56 -1
- package/lib/db/types.ts +30 -0
- package/lib/done/index.ts +2 -0
- package/lib/drafts.ts +3 -0
- package/lib/hosts/peer-load.ts +120 -0
- package/lib/load/machines.ts +124 -0
- package/lib/load/monitor.ts +17 -2
- package/lib/load/start-gate.ts +95 -0
- package/lib/model-catalog.ts +11 -0
- package/lib/orchestrator/act.ts +78 -0
- package/lib/orchestrator/brakes.ts +2 -0
- package/lib/orchestrator/brief.ts +4 -1
- package/lib/orchestrator/digest.ts +15 -4
- package/lib/orchestrator/serve.ts +18 -0
- package/lib/orchestrator/tool-names.ts +3 -0
- package/lib/orchestrator/tool-schemas.ts +33 -0
- package/lib/orchestrator/tools.ts +7 -1
- package/lib/project-config/agentos.schema.json +13 -0
- package/lib/project-config/schema.ts +9 -0
- package/lib/recipes/status-labels.ts +27 -0
- package/lib/sidebar/paused.ts +13 -0
- package/lib/sidebar/queued.ts +34 -1
- package/lib/sidebar/shelves.ts +21 -1
- package/lib/sidebar/snooze.ts +26 -0
- package/lib/tasks/finish.ts +7 -0
- package/lib/tasks/move-carry.ts +1 -0
- package/lib/tasks/move-flow.ts +6 -0
- package/lib/tasks/pause-hold.ts +70 -0
- package/lib/tasks/pause-policy.ts +130 -0
- package/lib/tasks/pause.ts +718 -0
- package/lib/tasks/placement.ts +219 -0
- package/lib/tasks/queue.ts +522 -64
- package/lib/tasks/schedule-types.ts +64 -0
- package/lib/tasks/schedule.ts +271 -0
- package/lib/tasks/state-labels.ts +21 -0
- package/package.json +1 -1
- package/server.ts +2 -1
package/lib/tasks/queue.ts
CHANGED
|
@@ -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),
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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 (
|
|
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):
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
)
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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")
|
|
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 ??=
|
|
598
|
-
|
|
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(
|