@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.
- package/README.md +1 -1
- package/app/api/bus/spawn/route.ts +21 -3
- 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/ChatPanel.tsx +2 -0
- package/components/Chat/PausedBanner.tsx +54 -0
- package/components/Draft/DraftChips.tsx +25 -0
- package/components/Draft/DraftPanel.tsx +1 -0
- 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/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/hold.ts +11 -2
- 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/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/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/package.json +1 -1
- package/server.ts +2 -1
package/lib/load/monitor.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -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 (
|
|
@@ -119,7 +119,7 @@ Reading:
|
|
|
119
119
|
Acting:
|
|
120
120
|
- \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
|
|
121
121
|
- \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
|
|
122
|
-
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
122
|
+
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, priority?, machine?, model?, plan, plan_only?, plan_reason): a task in its own worktree that ends in a PR. priority (urgent, blocking, normal, background) sets where it stands in the queue; machine mac pins work that needs this Mac. A busy or loaded machine holds a queued start and says why; don't retry it. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); State plan and plan_reason for every task (see "Triage every job"). Pass model ("provider/model", or a map of role to one) only when Saad asks for one, or to put implementation on Opus when the work needs deep reasoning (say why in plan_reason or a note); another provider than anthropic only when Saad asks.
|
|
123
123
|
- \`${TOOL_NAMES.plan_pieces}\` (task, merge?, drop?): when a plan split into pieces (the plan-done event names them), list them; merge or drop edits the ones still waiting. A piece starts by itself once what it waits on has merged (or deployed); you don't start it.
|
|
124
124
|
- \`${TOOL_NAMES.approve_plan}\` (task, message?): continue a plan_only task whose plan is ready; it implements on the implementation model, once. message amends the plan and is recorded on the task's brief as a scope change. Reject a plan with drop.
|
|
125
125
|
- \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
|
|
@@ -134,6 +134,9 @@ Acting:
|
|
|
134
134
|
- \`${TOOL_NAMES.land}\` (id): merge a stack bottom-up through the gates.
|
|
135
135
|
- \`${TOOL_NAMES.move}\` (task, machine): carry a running task to a linked machine (heavy test or build work), or back with machine "here". It keeps its branch, conversation and plan, and everything you do with a task works the same there: its events come to you, read, send, stop, done and drop reach it, and the gates judge its PR at its head. The machine it lands on holds it for you and can't merge it. It runs in the background; an event says when it lands. Refused while paused, and for a task waiting on Saad.
|
|
136
136
|
- \`${TOOL_NAMES.drop}\` (task, reason): close a task's PR and remove its worktree (on whichever machine holds it).
|
|
137
|
+
- \`${TOOL_NAMES.set_priority}\` (task, priority): re-rank a queued task (urgent, blocking, normal, background), or tag a running one. Urgent is a bug Saad hit; background starts last.
|
|
138
|
+
- \`${TOOL_NAMES.pause_task}\` (task, for, now?, reason): pause a running task for 1h, 2h, until tomorrow 9 AM or until resumed. Its agent ends at the end of its step (now interrupts the step); its worktree and conversation stay and its slot frees for the next queued task. It resumes by itself, ahead of new arrivals, with what was held (CI and review notes, messages) in one prompt. Use it to make room for urgent work or to wait out a loaded machine. Refused for a task that is merging, moving, setting up or waiting on Saad.
|
|
139
|
+
- \`${TOOL_NAMES.resume_task}\` (task): resume a pause you set now. A pause Saad set is his to lift.
|
|
137
140
|
- \`${TOOL_NAMES.stop}\` (session): stop a session's agent, keeping its work.
|
|
138
141
|
- \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session lands on the sidebar's Done shelf (it is not archived). Not a rejection: use drop for that.
|
|
139
142
|
- \`${TOOL_NAMES.archive}\` (session): put a session away, out of the sidebar, when Saad asks. Nothing is merged or cleaned up; refused while it works or has an open PR.
|
|
@@ -56,6 +56,9 @@ interface TaskRow {
|
|
|
56
56
|
pr_status: string | null;
|
|
57
57
|
view: string;
|
|
58
58
|
armed_at: string | null;
|
|
59
|
+
pause_state: string | null;
|
|
60
|
+
paused_until: string | null;
|
|
61
|
+
placed_note: string | null;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
export interface CheckRow {
|
|
@@ -162,7 +165,7 @@ function tasksSection(
|
|
|
162
165
|
const tasks = db
|
|
163
166
|
.prepare(
|
|
164
167
|
`SELECT s.id, s.name, p.name AS project, s.pr_number, s.pr_status, s.view,
|
|
165
|
-
s.merge_armed_at AS armed_at
|
|
168
|
+
s.merge_armed_at AS armed_at, s.pause_state, s.paused_until, s.placed_note
|
|
166
169
|
FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
167
170
|
WHERE p.workspace_id = ? AND s.role IS NULL AND s.archived_at IS NULL
|
|
168
171
|
AND s.task_status = 'running'
|
|
@@ -177,7 +180,11 @@ function tasksSection(
|
|
|
177
180
|
: "no PR yet";
|
|
178
181
|
const gates = gateState(checks.get(t.id), failures.get(t.id));
|
|
179
182
|
const arm = t.armed_at ? " | armed to merge" : "";
|
|
180
|
-
|
|
183
|
+
const paused = t.pause_state
|
|
184
|
+
? ` | ${t.pause_state}${t.paused_until ? ` until ${t.paused_until} UTC` : t.pause_state === "paused" ? " until resumed" : ""}`
|
|
185
|
+
: "";
|
|
186
|
+
const placed = t.placed_note ? ` | ${t.placed_note}` : "";
|
|
187
|
+
return `- ${clip(t.name, 80)} (${t.project ?? "?"}, ${t.view}, id ${shortId(t.id)}): ${pr}${arm}${paused}${placed}${gates ? ` | ${gates}` : ""}`;
|
|
181
188
|
}),
|
|
182
189
|
empty: "- None.",
|
|
183
190
|
more: "the sessions tool",
|
|
@@ -210,7 +217,7 @@ function externalSection(
|
|
|
210
217
|
function queueSection(workspaceId: string): Section {
|
|
211
218
|
const rows = db
|
|
212
219
|
.prepare(
|
|
213
|
-
`SELECT q.id, q.name, q.prompt, q.status, q.after_task, q.after_any,
|
|
220
|
+
`SELECT q.id, q.name, q.prompt, q.status, q.after_task, q.after_any, q.priority, q.note,
|
|
214
221
|
p.name AS project, a.name AS after_name
|
|
215
222
|
FROM task_queue q JOIN projects p ON p.id = q.project_id
|
|
216
223
|
LEFT JOIN sessions a ON a.id = q.after_task
|
|
@@ -226,6 +233,8 @@ function queueSection(workspaceId: string): Section {
|
|
|
226
233
|
after_any: string | null;
|
|
227
234
|
project: string;
|
|
228
235
|
after_name: string | null;
|
|
236
|
+
priority: string;
|
|
237
|
+
note: string | null;
|
|
229
238
|
}[];
|
|
230
239
|
return {
|
|
231
240
|
title: "Queued tasks, in line order",
|
|
@@ -236,7 +245,9 @@ function queueSection(workspaceId: string): Section {
|
|
|
236
245
|
? " | after any running task"
|
|
237
246
|
: "";
|
|
238
247
|
const status = r.status === "queued" ? "" : ` | ${r.status}`;
|
|
239
|
-
|
|
248
|
+
const priority = r.priority === "normal" ? "" : ` | ${r.priority}`;
|
|
249
|
+
const why = r.note ? ` | ${clip(r.note, 100)}` : "";
|
|
250
|
+
return `- ${i + 1}. ${clip(r.name ?? r.prompt, 80)} (${r.project}, id ${shortId(r.id)})${after}${priority}${status}${why}`;
|
|
240
251
|
}),
|
|
241
252
|
more: "the sessions tool",
|
|
242
253
|
};
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
stackStatus,
|
|
13
13
|
startSession,
|
|
14
14
|
startTask,
|
|
15
|
+
setTaskPriority,
|
|
16
|
+
pauseTask,
|
|
17
|
+
resumeTask,
|
|
15
18
|
stop,
|
|
16
19
|
} from "./act";
|
|
17
20
|
import { readCards } from "./cards";
|
|
@@ -63,6 +66,9 @@ const ACTING = new Set<ToolName>([
|
|
|
63
66
|
"move",
|
|
64
67
|
"approve_plan",
|
|
65
68
|
"plan_pieces",
|
|
69
|
+
"set_priority",
|
|
70
|
+
"pause_task",
|
|
71
|
+
"resume_task",
|
|
66
72
|
"stop",
|
|
67
73
|
"done",
|
|
68
74
|
"review",
|
|
@@ -131,8 +137,20 @@ export async function runTool(
|
|
|
131
137
|
plan: a.plan,
|
|
132
138
|
planOnly: a.plan_only,
|
|
133
139
|
planReason: a.plan_reason,
|
|
140
|
+
priority: a.priority,
|
|
141
|
+
machine: a.machine,
|
|
134
142
|
});
|
|
135
143
|
}
|
|
144
|
+
case "set_priority": {
|
|
145
|
+
const a = p(tool, raw);
|
|
146
|
+
return setTaskPriority(w, a.task, a.priority);
|
|
147
|
+
}
|
|
148
|
+
case "pause_task": {
|
|
149
|
+
const a = p(tool, raw);
|
|
150
|
+
return pauseTask(w, a.task, a.for, a.now ?? false, a.reason);
|
|
151
|
+
}
|
|
152
|
+
case "resume_task":
|
|
153
|
+
return resumeTask(w, p(tool, raw).task);
|
|
136
154
|
case "approve_plan": {
|
|
137
155
|
const a = p(tool, raw);
|
|
138
156
|
const task = workspaceTask(w, a.task);
|
|
@@ -29,6 +29,9 @@ export const TOOL_NAMES = {
|
|
|
29
29
|
plan_pieces: name("plan_pieces"),
|
|
30
30
|
drop: name("drop"),
|
|
31
31
|
move: name("move"),
|
|
32
|
+
set_priority: name("set_priority"),
|
|
33
|
+
pause_task: name("pause_task"),
|
|
34
|
+
resume_task: name("resume_task"),
|
|
32
35
|
stop: name("stop"),
|
|
33
36
|
done: name("done"),
|
|
34
37
|
archive: name("archive"),
|
|
@@ -7,6 +7,7 @@ import { RAISED_KINDS } from "./asks";
|
|
|
7
7
|
import { MAX_RULE_CHARS } from "./rules";
|
|
8
8
|
import { PROJECT_NAME } from "../project-add/name";
|
|
9
9
|
import { TASK_ROLES, type ModelRole } from "../models/roles";
|
|
10
|
+
import { PRIORITIES } from "../tasks/schedule-types";
|
|
10
11
|
|
|
11
12
|
const ref = (what: string) => z.string().trim().min(1).max(300).describe(what);
|
|
12
13
|
const session = ref("Session name, project/name, or id from sessions");
|
|
@@ -115,6 +116,18 @@ export const TOOL_SHAPES = {
|
|
|
115
116
|
.describe(
|
|
116
117
|
'Queue it until this task finishes (id or name), or "any" for whichever running task finishes first'
|
|
117
118
|
),
|
|
119
|
+
priority: z
|
|
120
|
+
.enum(PRIORITIES)
|
|
121
|
+
.optional()
|
|
122
|
+
.describe(
|
|
123
|
+
"Where it stands in the queue: urgent (a bug Saad hit), blocking (something waits on it), normal (the default), background"
|
|
124
|
+
),
|
|
125
|
+
machine: z
|
|
126
|
+
.enum(["mac", "any"])
|
|
127
|
+
.optional()
|
|
128
|
+
.describe(
|
|
129
|
+
'Where it may run: "mac" for work that needs this Mac (Xcode, a simulator); "any" if no machine need applies. Unset: the project\'s setting, else decided from its files'
|
|
130
|
+
),
|
|
118
131
|
model: modelOverride,
|
|
119
132
|
plan: z
|
|
120
133
|
.boolean()
|
|
@@ -233,6 +246,26 @@ export const TOOL_SHAPES = {
|
|
|
233
246
|
'A linked machine\'s name (see Machines), or "here" to bring the task back'
|
|
234
247
|
),
|
|
235
248
|
},
|
|
249
|
+
set_priority: {
|
|
250
|
+
task: ref("A queued or running task: name, project/name or id"),
|
|
251
|
+
priority: z.enum(PRIORITIES),
|
|
252
|
+
},
|
|
253
|
+
pause_task: {
|
|
254
|
+
task,
|
|
255
|
+
for: z
|
|
256
|
+
.enum(["1h", "2h", "tomorrow", "until_resumed"])
|
|
257
|
+
.describe(
|
|
258
|
+
"How long: 1h, 2h, tomorrow (9 AM, Saad's time) or until_resumed (until you or Saad resume it)"
|
|
259
|
+
),
|
|
260
|
+
now: z
|
|
261
|
+
.boolean()
|
|
262
|
+
.optional()
|
|
263
|
+
.describe(
|
|
264
|
+
"Interrupt its step now. Default: let the step finish, then pause"
|
|
265
|
+
),
|
|
266
|
+
reason: z.string().trim().min(1).max(300),
|
|
267
|
+
},
|
|
268
|
+
resume_task: { task },
|
|
236
269
|
stop: { session },
|
|
237
270
|
done: { session },
|
|
238
271
|
archive: { session },
|
|
@@ -62,7 +62,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
62
62
|
message_orchestrator:
|
|
63
63
|
"Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
|
|
64
64
|
start_task:
|
|
65
|
-
|
|
65
|
+
'Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). priority (urgent, blocking, normal, background) says where it stands in the queue; machine "mac" pins work that needs this Mac. A busy machine holds a start and the result says why. With after (a task\'s id or name, or "any"), or over the workspace\'s running task limit, it\'s queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Always state plan (true or false) and plan_reason (plan_only true plans, then stops and waits for approve_plan instead of implementing): plan true for a big, unclear or cross-cutting job, false for a small, clear one whose steps you write into the prompt. Refused while a brake holds.',
|
|
66
66
|
set_task_model:
|
|
67
67
|
'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
|
|
68
68
|
start_session:
|
|
@@ -81,6 +81,12 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
81
81
|
"A task whose plan split into pieces: each piece, its repo, what it waits on and its state. With merge or drop (position numbers) it edits the pieces still waiting: a merged piece's slice goes to the piece before it, a dropped one is left out and anything waiting on it is held. It can't add pieces or change reasons.",
|
|
82
82
|
drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
|
|
83
83
|
move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
|
|
84
|
+
set_priority:
|
|
85
|
+
"Change the priority of a queued task (it re-ranks the line at once) or a running one (it only matters if it is paused and waits to resume). urgent starts first even on a busy machine; background starts last.",
|
|
86
|
+
pause_task:
|
|
87
|
+
"Pause a running task for a set time: its agent ends at the end of its current step (now true interrupts the step), its worktree and conversation stay, and it frees its slot so the next queued task can start. It resumes by itself at the time, ahead of new arrivals of the same priority, with whatever was held (CI and review notes, messages) in one continue prompt. Refused for a task that is merging, moving, setting up or waiting on Saad. Give a reason; it is shown with the pause.",
|
|
88
|
+
resume_task:
|
|
89
|
+
"Resume a task you paused (or the scheduler did) now. A pause Saad set is his to lift: this refuses and says so.",
|
|
84
90
|
stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
|
|
85
91
|
done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it lands on the sidebar's Done shelf; it no longer archives (use archive for that). Not a rejection.",
|
|
86
92
|
archive:
|
|
@@ -151,6 +151,19 @@
|
|
|
151
151
|
"required": ["start"],
|
|
152
152
|
"additionalProperties": false
|
|
153
153
|
},
|
|
154
|
+
"machine": {
|
|
155
|
+
"type": "object",
|
|
156
|
+
"properties": {
|
|
157
|
+
"needs": {
|
|
158
|
+
"type": "array",
|
|
159
|
+
"items": {
|
|
160
|
+
"type": "string",
|
|
161
|
+
"enum": ["mac", "xcode", "simulator"]
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
"additionalProperties": false
|
|
166
|
+
},
|
|
154
167
|
"database": {
|
|
155
168
|
"type": "object",
|
|
156
169
|
"properties": {
|
|
@@ -69,6 +69,14 @@ const simulator = z.strictObject({
|
|
|
69
69
|
device: z.string().min(1).optional(),
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
// What a project's tasks need from a machine (lib/tasks/placement.ts): `mac`,
|
|
73
|
+
// `xcode`, `simulator`. A task whose needs a machine can't meet never starts
|
|
74
|
+
// there; with none listed, repo files decide (an Xcode project implies all).
|
|
75
|
+
export const MACHINE_NEEDS = ["mac", "xcode", "simulator"] as const;
|
|
76
|
+
const machine = z.strictObject({
|
|
77
|
+
needs: z.array(z.enum(MACHINE_NEEDS)).optional(),
|
|
78
|
+
});
|
|
79
|
+
|
|
72
80
|
const database = z.strictObject({
|
|
73
81
|
from: z.string().min(1),
|
|
74
82
|
env: projectEnvName.optional(),
|
|
@@ -119,6 +127,7 @@ const shape = {
|
|
|
119
127
|
notes: notes.optional(),
|
|
120
128
|
browse: browse.optional(),
|
|
121
129
|
simulator: simulator.optional(),
|
|
130
|
+
machine: machine.optional(),
|
|
122
131
|
database: database.optional(),
|
|
123
132
|
mcp: z.array(z.string().min(1)).optional(),
|
|
124
133
|
model: z.string().min(1).optional(),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { wakeLabel } from "./snooze";
|
|
2
|
+
|
|
3
|
+
// What a paused row says under its title.
|
|
4
|
+
export function pauseSubtitle(
|
|
5
|
+
paused: { state: "pausing" | "paused" | "resuming"; until: number | null },
|
|
6
|
+
now: number = Date.now(),
|
|
7
|
+
timeZone?: string
|
|
8
|
+
): string {
|
|
9
|
+
if (paused.state === "pausing") return "Pausing · finishing its step";
|
|
10
|
+
if (paused.state === "resuming") return "Resuming · waiting for a slot";
|
|
11
|
+
if (paused.until === null) return "Paused until you resume";
|
|
12
|
+
return `Paused · resumes ${wakeLabel(paused.until, now, timeZone)}`;
|
|
13
|
+
}
|
package/lib/sidebar/queued.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { QueuedTaskView } from "@/lib/tasks/queue";
|
|
2
|
+
import {
|
|
3
|
+
PRIORITIES,
|
|
4
|
+
PRIORITY_LABEL,
|
|
5
|
+
type Priority,
|
|
6
|
+
} from "@/lib/tasks/schedule-types";
|
|
2
7
|
|
|
3
8
|
export interface QueuedRow {
|
|
4
9
|
item: QueuedTaskView;
|
|
@@ -71,7 +76,35 @@ export function queuedSubtitle(item: QueuedTaskView): {
|
|
|
71
76
|
// Why a start it tried is held: the orchestrator paused, a brake.
|
|
72
77
|
if (item.note) return { lead: item.note, after: null };
|
|
73
78
|
if (item.after) return { lead: "After", after: item.after };
|
|
74
|
-
|
|
79
|
+
const priority =
|
|
80
|
+
item.priority === "normal" ? "" : ` · ${PRIORITY_LABEL[item.priority]}`;
|
|
81
|
+
return { lead: `#${item.rank} of ${item.of}${priority}`, after: null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The priority choices for a waiting task's menu, the current one marked.
|
|
85
|
+
export function priorityMenu(
|
|
86
|
+
item: Pick<QueuedTaskView, "priority" | "status">
|
|
87
|
+
): { priority: Priority; label: string; current: boolean }[] {
|
|
88
|
+
if (item.status !== "queued") return [];
|
|
89
|
+
return PRIORITIES.map((priority) => ({
|
|
90
|
+
priority,
|
|
91
|
+
label: PRIORITY_LABEL[priority],
|
|
92
|
+
current: priority === item.priority,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function priorityRequest(
|
|
97
|
+
id: string,
|
|
98
|
+
priority: Priority
|
|
99
|
+
): { url: string; init: RequestInit } {
|
|
100
|
+
return {
|
|
101
|
+
url: `/api/tasks/${encodeURIComponent(id)}/priority`,
|
|
102
|
+
init: {
|
|
103
|
+
method: "PATCH",
|
|
104
|
+
headers: { "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify({ priority }),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
75
108
|
}
|
|
76
109
|
|
|
77
110
|
export type QueueMenuAction = "start" | "up" | "down" | "remove";
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -67,6 +67,10 @@ export interface SidebarRow {
|
|
|
67
67
|
wakesAt?: number;
|
|
68
68
|
// A plain session in Done: marked by hand, or settled by itself.
|
|
69
69
|
settled?: "manual" | "auto";
|
|
70
|
+
// Its agent is being stopped, is stopped, or is starting again
|
|
71
|
+
// (lib/tasks/pause): until is when it resumes by itself (ms), null: when
|
|
72
|
+
// someone resumes it.
|
|
73
|
+
paused?: { state: "pausing" | "paused" | "resuming"; until: number | null };
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
export interface Shelves {
|
|
@@ -234,6 +238,7 @@ export function recipeRunOpen(session: Pick<Session, "recipe_run">): boolean {
|
|
|
234
238
|
function toRow(input: ShelfInput, session: Session, now: number): SidebarRow {
|
|
235
239
|
const status = input.statuses[session.id];
|
|
236
240
|
const wakesAt = snoozedUntil(session, now);
|
|
241
|
+
const pauseState = session.pause_state;
|
|
237
242
|
return {
|
|
238
243
|
session,
|
|
239
244
|
status,
|
|
@@ -241,6 +246,12 @@ function toRow(input: ShelfInput, session: Session, now: number): SidebarRow {
|
|
|
241
246
|
unread: !!status?.unread || wokeUnread(session, now),
|
|
242
247
|
activeAt: activeMs(session, now),
|
|
243
248
|
...(wakesAt && { wakesAt }),
|
|
249
|
+
...(pauseState && {
|
|
250
|
+
paused: {
|
|
251
|
+
state: pauseState,
|
|
252
|
+
until: session.paused_until ? ms(session.paused_until) : null,
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
244
255
|
working:
|
|
245
256
|
status?.status === "running" ||
|
|
246
257
|
(status?.background ?? 0) > 0 ||
|
|
@@ -305,6 +316,8 @@ export function buildShelves(input: ShelfInput): Shelves {
|
|
|
305
316
|
if (pinned && current) shelves.orchestrators.push(row);
|
|
306
317
|
else if (pinned && role !== "orchestrator") shelves.pinned.push(row);
|
|
307
318
|
else if (row.need) shelves.needsYou.push(row);
|
|
319
|
+
// A stopped agent is not working, whatever its last status said.
|
|
320
|
+
else if (row.paused?.state === "paused") shelves.snoozed.push(row);
|
|
308
321
|
else if (row.working) shelves.working.push(row);
|
|
309
322
|
else if (row.wakesAt) shelves.snoozed.push(row);
|
|
310
323
|
else if (row.task || row.session.task_status)
|
|
@@ -323,7 +336,12 @@ export function buildShelves(input: ShelfInput): Shelves {
|
|
|
323
336
|
shelves.working.sort(newestFirst);
|
|
324
337
|
shelves.inReview.sort(newestFirst);
|
|
325
338
|
shelves.ongoing.sort((a, b) => b.activeAt - a.activeAt);
|
|
326
|
-
|
|
339
|
+
// By when each comes back; a pause with no end goes last.
|
|
340
|
+
const comesBack = (r: SidebarRow) =>
|
|
341
|
+
r.paused?.state === "paused"
|
|
342
|
+
? (r.paused.until ?? Number.POSITIVE_INFINITY)
|
|
343
|
+
: r.wakesAt!;
|
|
344
|
+
shelves.snoozed.sort((a, b) => comesBack(a) - comesBack(b));
|
|
327
345
|
shelves.done.sort((a, b) => doneAtMs(b) - doneAtMs(a));
|
|
328
346
|
return shelves;
|
|
329
347
|
}
|
|
@@ -390,6 +408,8 @@ export function sameRow(a: SidebarRow, b: SidebarRow): boolean {
|
|
|
390
408
|
a.task === b.task &&
|
|
391
409
|
a.activeAt === b.activeAt &&
|
|
392
410
|
a.wakesAt === b.wakesAt &&
|
|
411
|
+
a.paused?.state === b.paused?.state &&
|
|
412
|
+
a.paused?.until === b.paused?.until &&
|
|
393
413
|
a.settled === b.settled &&
|
|
394
414
|
a.workers.length === b.workers.length &&
|
|
395
415
|
a.workers.every((w, i) => sameRow(w, b.workers[i])) &&
|