@saadnvd1/agent-os 0.3.571 → 0.3.573
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
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import { toast } from "sonner";
|
|
2
2
|
import type { Session } from "@/lib/db";
|
|
3
|
-
import { useSettleSession, useSnoozeSession } from "@/data/sessions/shelf";
|
|
4
3
|
import {
|
|
4
|
+
usePauseSession,
|
|
5
|
+
useResumeSession,
|
|
6
|
+
useSettleSession,
|
|
7
|
+
useSnoozeSession,
|
|
8
|
+
} from "@/data/sessions/shelf";
|
|
9
|
+
import {
|
|
10
|
+
pauseUntil,
|
|
5
11
|
snoozePreset,
|
|
6
12
|
wakeLabel,
|
|
13
|
+
type PausePreset,
|
|
7
14
|
type SnoozePreset,
|
|
8
15
|
} from "@/lib/sidebar/snooze";
|
|
9
16
|
|
|
10
|
-
// A row menu's Snooze, Unsnooze, Mark done and Reopen
|
|
17
|
+
// A row menu's Snooze, Unsnooze, Pause, Resume, Mark done and Reopen.
|
|
11
18
|
export function useShelfActions() {
|
|
12
19
|
const snooze = useSnoozeSession();
|
|
13
20
|
const settle = useSettleSession();
|
|
21
|
+
const pause = usePauseSession();
|
|
22
|
+
const resume = useResumeSession();
|
|
14
23
|
const unsnooze = (session: Session) =>
|
|
15
24
|
snooze.mutate({ id: session.id, until: null });
|
|
16
25
|
return {
|
|
@@ -28,6 +37,34 @@ export function useShelfActions() {
|
|
|
28
37
|
);
|
|
29
38
|
},
|
|
30
39
|
unsnooze,
|
|
40
|
+
// Stops its agent at the end of the step (or now) and starts it again at
|
|
41
|
+
// the time. A refusal (it is waiting on you, merging) is the toast.
|
|
42
|
+
pause: (session: Session, preset: PausePreset, now = false) => {
|
|
43
|
+
const until = pauseUntil(preset);
|
|
44
|
+
pause.mutate(
|
|
45
|
+
{ id: session.id, until, mode: now ? "now" : "turn" },
|
|
46
|
+
{
|
|
47
|
+
onSuccess: () =>
|
|
48
|
+
toast.success(
|
|
49
|
+
until
|
|
50
|
+
? `Pausing until ${wakeLabel(until.getTime())}`
|
|
51
|
+
: "Pausing until you resume",
|
|
52
|
+
{
|
|
53
|
+
action: {
|
|
54
|
+
label: "Keep running",
|
|
55
|
+
onClick: () => resume.mutate({ id: session.id }),
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
onError: (e) => toast.error(e.message),
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
},
|
|
63
|
+
resume: (session: Session) =>
|
|
64
|
+
resume.mutate(
|
|
65
|
+
{ id: session.id },
|
|
66
|
+
{ onError: (e) => toast.error(e.message) }
|
|
67
|
+
),
|
|
31
68
|
settle: (session: Session) =>
|
|
32
69
|
settle.mutate(
|
|
33
70
|
{ id: session.id, settled: true },
|
package/data/sessions/shelf.ts
CHANGED
|
@@ -83,3 +83,53 @@ export function useSettleSession() {
|
|
|
83
83
|
: { settled_at: null, active_at: now }
|
|
84
84
|
);
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
async function send(method: string, url: string, body?: object) {
|
|
88
|
+
const res = await fetch(url, {
|
|
89
|
+
method,
|
|
90
|
+
headers: { "Content-Type": "application/json" },
|
|
91
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok)
|
|
94
|
+
throw new Error(
|
|
95
|
+
(await res.json().catch(() => null))?.error ?? "Request failed"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// until null: until resumed. The row moves to "pausing" at once; the server
|
|
100
|
+
// stops the agent at the end of its step (or now) and the push settles it.
|
|
101
|
+
export function usePauseSession() {
|
|
102
|
+
return useShelfMutation<{
|
|
103
|
+
id: string;
|
|
104
|
+
until: Date | null;
|
|
105
|
+
mode: "turn" | "now";
|
|
106
|
+
}>(
|
|
107
|
+
({ id, until, mode }) =>
|
|
108
|
+
send("POST", `/api/sessions/${id}/pause`, {
|
|
109
|
+
until: until?.toISOString() ?? null,
|
|
110
|
+
mode,
|
|
111
|
+
}),
|
|
112
|
+
({ until }) => ({
|
|
113
|
+
pause_state: "pausing",
|
|
114
|
+
paused_until: until ? toSqliteTime(until) : null,
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Resume now, or keep running while it is still pausing.
|
|
120
|
+
export function useResumeSession() {
|
|
121
|
+
return useShelfMutation<{ id: string }>(
|
|
122
|
+
({ id }) => send("DELETE", `/api/sessions/${id}/pause`),
|
|
123
|
+
() => ({ pause_state: "resuming", paused_until: null })
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function useChangeResumeTime() {
|
|
128
|
+
return useShelfMutation<{ id: string; until: Date | null }>(
|
|
129
|
+
({ id, until }) =>
|
|
130
|
+
send("PATCH", `/api/sessions/${id}/pause`, {
|
|
131
|
+
until: until?.toISOString() ?? null,
|
|
132
|
+
}),
|
|
133
|
+
({ until }) => ({ paused_until: until ? toSqliteTime(until) : null })
|
|
134
|
+
);
|
|
135
|
+
}
|
package/data/tasks/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import type { TaskView } from "@/lib/tasks";
|
|
3
3
|
import type { QueuedTaskView } from "@/lib/tasks/queue";
|
|
4
|
+
import type { Priority } from "@/lib/tasks/schedule-types";
|
|
4
5
|
import { sessionKeys } from "../sessions/keys";
|
|
5
6
|
import { taskKeys } from "./keys";
|
|
6
7
|
import { usePollWhenOffline } from "../push/connection";
|
|
@@ -43,6 +44,7 @@ export function useCreateTask() {
|
|
|
43
44
|
projectId: string;
|
|
44
45
|
prompt: string;
|
|
45
46
|
model?: string;
|
|
47
|
+
priority?: Priority;
|
|
46
48
|
baseBranch?: string;
|
|
47
49
|
hostId?: string;
|
|
48
50
|
view?: "chat" | "terminal";
|
package/data/tasks/queue.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import type { QueuedTaskView, StartOutcome } from "@/lib/tasks/queue";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
priorityRequest,
|
|
5
|
+
queueRequest,
|
|
6
|
+
type QueueMenuAction,
|
|
7
|
+
} from "@/lib/sidebar/queued";
|
|
8
|
+
import type { Priority } from "@/lib/tasks/schedule-types";
|
|
4
9
|
import { sessionKeys } from "../sessions/keys";
|
|
5
10
|
import { taskKeys } from "./keys";
|
|
6
11
|
import { usePollWhenOffline } from "../push/connection";
|
|
@@ -40,3 +45,35 @@ export function useQueueAction() {
|
|
|
40
45
|
},
|
|
41
46
|
});
|
|
42
47
|
}
|
|
48
|
+
|
|
49
|
+
// Changes where a waiting task stands; the line re-ranks from the push.
|
|
50
|
+
export function useSetPriority() {
|
|
51
|
+
const queryClient = useQueryClient();
|
|
52
|
+
return useMutation({
|
|
53
|
+
mutationFn: async ({
|
|
54
|
+
id,
|
|
55
|
+
priority,
|
|
56
|
+
}: {
|
|
57
|
+
id: string;
|
|
58
|
+
priority: Priority;
|
|
59
|
+
}) => {
|
|
60
|
+
const { url, init } = priorityRequest(id, priority);
|
|
61
|
+
return json<unknown>(await fetch(url, init));
|
|
62
|
+
},
|
|
63
|
+
onMutate: async ({ id, priority }) => {
|
|
64
|
+
await queryClient.cancelQueries({ queryKey: taskKeys.queue() });
|
|
65
|
+
const previous = queryClient.getQueryData<QueuedTaskView[]>(
|
|
66
|
+
taskKeys.queue()
|
|
67
|
+
);
|
|
68
|
+
queryClient.setQueryData<QueuedTaskView[]>(taskKeys.queue(), (old) =>
|
|
69
|
+
old?.map((q) => (q.id === id ? { ...q, priority } : q))
|
|
70
|
+
);
|
|
71
|
+
return { previous };
|
|
72
|
+
},
|
|
73
|
+
onError: (_e, _v, context) => {
|
|
74
|
+
if (context?.previous)
|
|
75
|
+
queryClient.setQueryData(taskKeys.queue(), context.previous);
|
|
76
|
+
},
|
|
77
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: taskKeys.all }),
|
|
78
|
+
});
|
|
79
|
+
}
|
package/lib/bus/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { deliverToPane, type Delivery } from "./delivery";
|
|
|
15
15
|
import { resolveRef, wasNames, type Candidate } from "./resolve";
|
|
16
16
|
import { tmuxPane } from "./tmux-pane";
|
|
17
17
|
import { hostLink } from "../hosts/remote-api";
|
|
18
|
+
import { pauseHoldOf, pausedNote } from "../tasks/pause-hold";
|
|
18
19
|
import { relayToHeldTask } from "../tasks/held-remote";
|
|
19
20
|
import { heldOrchestrator, heldTarget } from "./held-reply";
|
|
20
21
|
import { HUMAN, overRateLimit, wakeLine, type BusMessageView } from "./format";
|
|
@@ -215,6 +216,16 @@ export async function deliver(
|
|
|
215
216
|
return { state: "failed", why };
|
|
216
217
|
}
|
|
217
218
|
}
|
|
219
|
+
// A paused terminal has no agent: the message waits for the resume prompt.
|
|
220
|
+
const paused = pauseHoldOf(to.id);
|
|
221
|
+
if (paused) {
|
|
222
|
+
if (from.messageId)
|
|
223
|
+
db.prepare(`UPDATE bus_messages SET held_pause_id = ? WHERE id = ?`).run(
|
|
224
|
+
paused.pauseId,
|
|
225
|
+
from.messageId
|
|
226
|
+
);
|
|
227
|
+
return { state: "queued" };
|
|
228
|
+
}
|
|
218
229
|
if (!statusDetector.sessionExists(to.tmux_name))
|
|
219
230
|
return { state: "failed", why: "its terminal isn't running" };
|
|
220
231
|
try {
|
|
@@ -298,7 +309,10 @@ export async function sendMessage(opts: {
|
|
|
298
309
|
: opts.origin,
|
|
299
310
|
}
|
|
300
311
|
);
|
|
301
|
-
|
|
312
|
+
// A message held for a paused terminal isn't delivered until it resumes.
|
|
313
|
+
const hold = pauseHoldOf(to.id);
|
|
314
|
+
const parked = !!hold && to.view !== "chat";
|
|
315
|
+
if (delivery.state !== "failed" && !parked) {
|
|
302
316
|
db.prepare(
|
|
303
317
|
`UPDATE bus_messages SET delivered_at = datetime('now') WHERE id = ?`
|
|
304
318
|
).run(id);
|
|
@@ -308,7 +322,12 @@ export async function sendMessage(opts: {
|
|
|
308
322
|
`UPDATE bus_messages SET read_at = datetime('now') WHERE id = ?`
|
|
309
323
|
).run(id);
|
|
310
324
|
const message = toView(db.prepare(`${SELECT} WHERE m.id = ?`).get(id) as Row);
|
|
311
|
-
|
|
325
|
+
const heldNote = hold ? pausedNote(to.name, hold) : null;
|
|
326
|
+
return {
|
|
327
|
+
message,
|
|
328
|
+
delivery,
|
|
329
|
+
note: [note, heldNote].filter(Boolean).join("; ") || undefined,
|
|
330
|
+
};
|
|
312
331
|
}
|
|
313
332
|
|
|
314
333
|
function storeForHolder(
|
package/lib/chat/hold.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Holding a chat still while something outside it works on its task: a
|
|
3
3
|
* move to another machine (its row is 'moving'), or a Land of the stack it
|
|
4
|
-
* sits in (the stack is 'landing')
|
|
4
|
+
* sits in (the stack is 'landing'), or a timed pause (lib/tasks/pause.ts).
|
|
5
|
+
* All are facts in SQLite, so the worker
|
|
5
6
|
* process sees the hold too: it neither starts what's queued (queued.ts) nor
|
|
6
7
|
* a turn for a message sent meanwhile, which waits in the queue instead.
|
|
7
8
|
*
|
|
@@ -17,6 +18,8 @@ export const HELD_SQL = `(
|
|
|
17
18
|
EXISTS (SELECT 1 FROM sessions WHERE id = @sid AND task_status = 'moving')
|
|
18
19
|
OR EXISTS (SELECT 1 FROM stack_items i JOIN stacks s ON s.id = i.stack_id
|
|
19
20
|
WHERE i.session_id = @sid AND s.status = 'landing')
|
|
21
|
+
OR EXISTS (SELECT 1 FROM sessions WHERE id = @sid
|
|
22
|
+
AND pause_state IN ('pausing', 'paused'))
|
|
20
23
|
)`;
|
|
21
24
|
|
|
22
25
|
export const isHeld = (sessionId: string): boolean =>
|
|
@@ -45,12 +48,14 @@ interface HoldRow {
|
|
|
45
48
|
moved_to: string | null;
|
|
46
49
|
quick_of: string | null;
|
|
47
50
|
landing: number;
|
|
51
|
+
pause_state: string | null;
|
|
52
|
+
paused_until: string | null;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
const holdRow = (sessionId: string) =>
|
|
51
56
|
db
|
|
52
57
|
.prepare(
|
|
53
|
-
`SELECT task_status, moved_to, quick_of,
|
|
58
|
+
`SELECT task_status, moved_to, quick_of, pause_state, paused_until,
|
|
54
59
|
EXISTS (SELECT 1 FROM stack_items i JOIN stacks s ON s.id = i.stack_id
|
|
55
60
|
WHERE i.session_id = @sid AND s.status = 'landing') AS landing
|
|
56
61
|
FROM sessions WHERE id = @sid`
|
|
@@ -63,6 +68,10 @@ export function chatHold(sessionId: string): string | null {
|
|
|
63
68
|
if (row?.task_status === "moving" && collecting.has(sessionId))
|
|
64
69
|
return "It's sent once the move to another machine finishes";
|
|
65
70
|
if (row?.landing) return "It's sent once its stack has landed";
|
|
71
|
+
if (row?.pause_state === "pausing" || row?.pause_state === "paused")
|
|
72
|
+
return row.paused_until
|
|
73
|
+
? "It's sent when the session resumes, with anything else held meanwhile"
|
|
74
|
+
: "It's sent when the session is resumed, with anything else held meanwhile";
|
|
66
75
|
return null;
|
|
67
76
|
}
|
|
68
77
|
|
package/lib/db/migrations.ts
CHANGED
|
@@ -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..
|
|
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
|
+
}
|