@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/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
|
+
}
|
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/model-catalog.ts
CHANGED
|
@@ -84,3 +84,14 @@ export function resolveModelForAgent(
|
|
|
84
84
|
|
|
85
85
|
return getDefaultModelForAgent(agentType);
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
// A readable name for a model id: "claude-opus-5-5" is "Opus 5.5", the alias
|
|
89
|
+
// "opus" is "Opus". An id it can't read passes through unchanged.
|
|
90
|
+
export function modelDisplayName(id: string): string {
|
|
91
|
+
const alias = CLAUDE_MODEL_OPTIONS.find((o) => o.value === id);
|
|
92
|
+
if (alias) return alias.label;
|
|
93
|
+
const m = /^claude-([a-z]+)-(\d{1,2}(?:-\d{1,2})*)(?:-\d{8})?$/.exec(id);
|
|
94
|
+
if (!m) return id;
|
|
95
|
+
const family = m[1][0].toUpperCase() + m[1].slice(1);
|
|
96
|
+
return `${family} ${m[2].replace(/-/g, ".")}`;
|
|
97
|
+
}
|
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 (
|