@saadnvd1/agent-os 0.3.631 → 0.3.637
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/app/api/tasks/[id]/approve-plan/route.ts +6 -1
- package/app/api/tasks/[id]/export/route.ts +3 -2
- package/app/api/tasks/[id]/plan/route.ts +37 -0
- package/app/api/tasks/route.ts +4 -1
- package/app/globals.css +28 -0
- package/components/Chat/ChatPanel.tsx +2 -11
- package/components/Chat/EventTurns.tsx +1 -7
- package/components/Chat/Markdown.tsx +1 -0
- package/components/Chat/Messages.tsx +1 -1
- package/lib/approvals/enable.ts +9 -2
- package/lib/chat/events.ts +4 -0
- package/lib/chat/group.ts +3 -1
- package/lib/chat/reading-order.ts +50 -0
- package/lib/chat/turns.ts +22 -3
- package/lib/chat/worker/host.ts +3 -0
- package/lib/db/migrations/20261015T1600_plan_waits_for_caller.ts +19 -0
- package/lib/db/types.ts +6 -1
- package/lib/env-setup.ts +17 -4
- package/lib/models/plan-step.ts +75 -24
- package/lib/tasks/held.ts +23 -0
- package/lib/tasks/hold-checks.ts +134 -0
- package/lib/tasks/holds.ts +281 -0
- package/lib/tasks/import.ts +11 -3
- package/lib/tasks/index.ts +25 -3
- package/lib/tasks/move-bundle.ts +17 -2
- package/lib/tasks/move-guard.ts +9 -1
- package/lib/tasks/move.ts +5 -4
- package/lib/tasks/pause.ts +2 -31
- package/lib/tasks/placement.ts +16 -16
- package/lib/tasks/plan-view.ts +11 -3
- package/lib/tasks/queue-candidates.ts +151 -0
- package/lib/tasks/queue-placement.ts +155 -0
- package/lib/tasks/queue-rows.ts +236 -0
- package/lib/tasks/queue-start.ts +211 -0
- package/lib/tasks/queue-tick.ts +148 -0
- package/lib/tasks/queue-types.ts +141 -0
- package/lib/tasks/queue-view.ts +112 -0
- package/lib/tasks/queue.ts +53 -1096
- package/lib/tasks/remote-plan-path.ts +7 -0
- package/lib/tasks/remote-plan.ts +308 -0
- package/lib/tasks/remote.ts +18 -1
- package/lib/tasks/schedule.ts +44 -147
- package/package.json +1 -1
- package/scripts/check +55 -7
- package/lib/tasks/auto-hold.ts +0 -17
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { plansDir } from "../plans/features";
|
|
3
|
+
|
|
4
|
+
// A plan written on a linked machine, copied here so this machine's
|
|
5
|
+
// orchestrator and plan view can read it (remote-plan.ts).
|
|
6
|
+
export const remotePlanPath = (sessionId: string) =>
|
|
7
|
+
path.join(plansDir(), "remote", `${sessionId}.md`);
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plan placed on a linked machine (queue placement, remote.ts). Planning
|
|
3
|
+
* only reads code, so it runs wherever there is room; what follows the plan
|
|
4
|
+
* is decided here, on the machine whose scheduler, needs and workspace apply.
|
|
5
|
+
* The other machine plans, stops, and waits. This machine then copies the
|
|
6
|
+
* plan, says it is written, and either approves it there (the task is built
|
|
7
|
+
* where it planned) or moves the task here once this machine can take it
|
|
8
|
+
* (it needs a Mac, or its plan is split into pieces, which register here).
|
|
9
|
+
* Moving the task reuses the ordinary move (move-flow.ts).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import { db, type Session } from "../db";
|
|
15
|
+
import { isRemoteHost } from "../hosts";
|
|
16
|
+
import { peerSource } from "../hosts/peer-load";
|
|
17
|
+
import { hostApi, hostLink, requireHostLink } from "../hosts/remote-api";
|
|
18
|
+
import { LOCAL, machineCapabilities } from "../load/machines";
|
|
19
|
+
import { noApprovalsOn, recordAutoApproval } from "../approvals/no-approvals";
|
|
20
|
+
import { implementArrived, planNoteFor } from "../models/plan-step";
|
|
21
|
+
import { sessionWorkspace } from "../models/resolve";
|
|
22
|
+
import { queueEvent } from "../orchestrator/events";
|
|
23
|
+
import { brakesOn } from "../orchestrator/brakes";
|
|
24
|
+
import { isPaused } from "../orchestrator/pause";
|
|
25
|
+
import { getProject } from "../projects";
|
|
26
|
+
import { planFor, splitOf } from "../plans/features";
|
|
27
|
+
import { forgetHostTasks, hostTasks } from "./remote";
|
|
28
|
+
import { moveTask } from "./move-flow";
|
|
29
|
+
import { meetsNeeds, needsOf } from "./placement";
|
|
30
|
+
import { remotePlanPath } from "./remote-plan-path";
|
|
31
|
+
import { DEFAULT_PRIORITY, isPriority } from "./schedule-types";
|
|
32
|
+
import { localHolds } from "./hold-checks";
|
|
33
|
+
import { expandHome } from "./session";
|
|
34
|
+
import { orchestratorOf } from "./starter";
|
|
35
|
+
|
|
36
|
+
export { remotePlanPath };
|
|
37
|
+
|
|
38
|
+
const getSession = (id: string) =>
|
|
39
|
+
db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
|
|
40
|
+
| Session
|
|
41
|
+
| undefined;
|
|
42
|
+
|
|
43
|
+
function readCopy(id: string): string | null {
|
|
44
|
+
try {
|
|
45
|
+
return fs.readFileSync(remotePlanPath(id), "utf8");
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function checkedSplit(s: Session, text: string | null) {
|
|
52
|
+
try {
|
|
53
|
+
return text ? splitOf(s, text) : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where a written plan is built: "there" (approved on the machine that
|
|
61
|
+
* planned it) or "home" (moved here first). Pieces are checked against this
|
|
62
|
+
* machine's projects, and work that needs a Mac can't be built on a machine
|
|
63
|
+
* without one, so those come home; everything else stays where it is, since
|
|
64
|
+
* the machine that planned it is the one with room.
|
|
65
|
+
*/
|
|
66
|
+
export function implementTarget(
|
|
67
|
+
s: Session,
|
|
68
|
+
split: ReturnType<typeof splitOf>
|
|
69
|
+
): "there" | "home" {
|
|
70
|
+
const project = s.project_id ? getProject(s.project_id) : null;
|
|
71
|
+
// The project lives there: nothing here to build it in.
|
|
72
|
+
if (!project || isRemoteHost(project.host_id)) return "there";
|
|
73
|
+
if (split) return "home";
|
|
74
|
+
const { needs } = needsOf({
|
|
75
|
+
projectId: project.id,
|
|
76
|
+
projectPath: expandHome(project.working_directory),
|
|
77
|
+
prompt: s.task_prompt ?? "",
|
|
78
|
+
});
|
|
79
|
+
const caps = peerSource(s.host_id!, Date.now()).capabilities;
|
|
80
|
+
if (meetsNeeds(needs, caps)) return "there";
|
|
81
|
+
return meetsNeeds(needs, machineCapabilities()) ? "home" : "there";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// One look at a mirror that is planning: if its plan is written there, copy
|
|
85
|
+
// it, say so, and carry on for a plan-first task.
|
|
86
|
+
async function planWritten(s: Session): Promise<void> {
|
|
87
|
+
const link = hostLink(s.host_id);
|
|
88
|
+
if (!link) return;
|
|
89
|
+
const view = await hostTasks(link)
|
|
90
|
+
.then(({ tasks }) => tasks.find((t) => t?.id === s.id))
|
|
91
|
+
.catch(() => undefined);
|
|
92
|
+
if (!view?.planReady) return;
|
|
93
|
+
const { text } = await hostApi<{ text: string | null }>(
|
|
94
|
+
link,
|
|
95
|
+
`/api/tasks/${encodeURIComponent(s.id)}/plan`,
|
|
96
|
+
{ timeout: 30000 }
|
|
97
|
+
);
|
|
98
|
+
const claimed = db
|
|
99
|
+
.prepare(
|
|
100
|
+
`UPDATE sessions SET plan_phase = 'awaiting' WHERE id = ? AND plan_phase = 'planning'`
|
|
101
|
+
)
|
|
102
|
+
.run(s.id).changes;
|
|
103
|
+
if (!claimed) return;
|
|
104
|
+
if (text !== null) {
|
|
105
|
+
fs.mkdirSync(path.dirname(remotePlanPath(s.id)), { recursive: true });
|
|
106
|
+
fs.writeFileSync(remotePlanPath(s.id), text);
|
|
107
|
+
}
|
|
108
|
+
const fresh = getSession(s.id)!;
|
|
109
|
+
const split = checkedSplit(fresh, text);
|
|
110
|
+
const errors = split?.errors ?? [];
|
|
111
|
+
// As for a task planned here: a plan-first task goes on by itself, a
|
|
112
|
+
// plan-only one waits unless no-approvals is on, and pieces that couldn't
|
|
113
|
+
// be registered always hold it.
|
|
114
|
+
const auto = !!fresh.plan_only && noApprovalsOn();
|
|
115
|
+
const goOn = (!fresh.plan_only || auto) && !errors.length;
|
|
116
|
+
const target = goOn ? implementTarget(fresh, split) : null;
|
|
117
|
+
const next =
|
|
118
|
+
target === "there"
|
|
119
|
+
? `implementing on ${hostLink(s.host_id)?.hostName ?? "that machine"}`
|
|
120
|
+
: target === "home"
|
|
121
|
+
? "moving here to implement once this machine is free"
|
|
122
|
+
: "waiting for approval, not implementing (approve_plan to go ahead, drop to reject)";
|
|
123
|
+
const note = planNoteFor(fresh, remotePlanPath(s.id), next);
|
|
124
|
+
db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(note, s.id);
|
|
125
|
+
const workspaceId = sessionWorkspace(fresh);
|
|
126
|
+
if (orchestratorOf(fresh.started_by) && workspaceId) {
|
|
127
|
+
queueEvent(workspaceId, `plan-done:${s.id}`, s.id, note);
|
|
128
|
+
if (errors.length)
|
|
129
|
+
queueEvent(
|
|
130
|
+
workspaceId,
|
|
131
|
+
`plan-pieces:${s.id}`,
|
|
132
|
+
s.id,
|
|
133
|
+
`Plan of "${s.name}": couldn't register its pieces: ${errors.join("; ")}. It is held, not building. Message the task to fix the plan, or approve_plan to build it as one piece.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (!goOn) {
|
|
137
|
+
try {
|
|
138
|
+
const planned = text ? planFor(fresh, text) : null;
|
|
139
|
+
(await import("../notify/push/events")).pushPlanAwaiting(
|
|
140
|
+
s.id,
|
|
141
|
+
s.name,
|
|
142
|
+
planned?.pieces.length ?? 1
|
|
143
|
+
);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
console.error("plan push failed:", e);
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
await continueRemotePlan(fresh);
|
|
150
|
+
if (auto)
|
|
151
|
+
recordAutoApproval({
|
|
152
|
+
source: "plan",
|
|
153
|
+
workspaceId,
|
|
154
|
+
sessionId: s.id,
|
|
155
|
+
subject: `plan for ${s.name}`,
|
|
156
|
+
detail: "implementing without waiting for approve_plan",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Carries a written plan on: approves it on the machine that planned it, or
|
|
162
|
+
* hands it to the move home. Returns what it will implement on.
|
|
163
|
+
*/
|
|
164
|
+
export async function continueRemotePlan(
|
|
165
|
+
s: Session,
|
|
166
|
+
message?: string
|
|
167
|
+
): Promise<string> {
|
|
168
|
+
const link = requireHostLink(s.host_id!);
|
|
169
|
+
const split = checkedSplit(s, readCopy(s.id));
|
|
170
|
+
if (implementTarget(s, split) === "there") {
|
|
171
|
+
const out = await hostApi<{ model?: string }>(
|
|
172
|
+
link,
|
|
173
|
+
`/api/tasks/${encodeURIComponent(s.id)}/approve-plan`,
|
|
174
|
+
{ body: message ? { message } : {}, timeout: 60000 }
|
|
175
|
+
);
|
|
176
|
+
db.prepare(
|
|
177
|
+
`UPDATE sessions SET plan_phase = 'implementing' WHERE id = ? AND plan_phase = 'awaiting'`
|
|
178
|
+
).run(s.id);
|
|
179
|
+
forgetHostTasks(link.hostId);
|
|
180
|
+
return out.model ?? link.hostName;
|
|
181
|
+
}
|
|
182
|
+
db.prepare(
|
|
183
|
+
`UPDATE sessions SET plan_phase = 'handoff', plan_handoff_message = ?
|
|
184
|
+
WHERE id = ? AND plan_phase = 'awaiting'`
|
|
185
|
+
).run(message ?? null, s.id);
|
|
186
|
+
const workspaceId = sessionWorkspace(s);
|
|
187
|
+
if (orchestratorOf(s.started_by) && workspaceId)
|
|
188
|
+
queueEvent(
|
|
189
|
+
workspaceId,
|
|
190
|
+
`plan-handoff:${s.id}`,
|
|
191
|
+
s.id,
|
|
192
|
+
`Task "${s.name}": plan approved; it moves here to be built once this machine is free (${split ? "its plan is in pieces" : "it needs this machine"}).`
|
|
193
|
+
);
|
|
194
|
+
void handoffHome(s.id).catch((e) =>
|
|
195
|
+
console.error(`Moving ${s.id} here failed:`, e)
|
|
196
|
+
);
|
|
197
|
+
return "this machine, once it is free";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const handingOff = new Set<string>();
|
|
201
|
+
|
|
202
|
+
async function finishArrival(id: string, message?: string): Promise<boolean> {
|
|
203
|
+
await implementArrived(id, message || undefined);
|
|
204
|
+
db.prepare(
|
|
205
|
+
`UPDATE sessions SET plan_handoff_message = NULL WHERE id = ?`
|
|
206
|
+
).run(id);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Fired, not awaited: a move takes minutes and the queue tick must not wait.
|
|
211
|
+
function fire(id: string, run: () => Promise<unknown>) {
|
|
212
|
+
if (handingOff.has(id)) return;
|
|
213
|
+
void run().catch((e) => console.error(`Moving ${id} here failed:`, e));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Moves an approved plan's task here, when this machine can take it, and
|
|
218
|
+
* starts implementing. False while the machine is loaded (tried again on the
|
|
219
|
+
* next look) or another call has it.
|
|
220
|
+
*/
|
|
221
|
+
export async function handoffHome(
|
|
222
|
+
id: string,
|
|
223
|
+
now = Date.now()
|
|
224
|
+
): Promise<boolean> {
|
|
225
|
+
const s = getSession(id);
|
|
226
|
+
if (!s || s.plan_phase !== "handoff" || !isRemoteHost(s.host_id))
|
|
227
|
+
return false;
|
|
228
|
+
if (handingOff.has(id)) return false;
|
|
229
|
+
const project = s.project_id ? getProject(s.project_id) : null;
|
|
230
|
+
if (!project) return false;
|
|
231
|
+
// What an orchestrator started stays under its Pause and brakes.
|
|
232
|
+
const workspaceId = sessionWorkspace(s);
|
|
233
|
+
if (workspaceId && orchestratorOf(s.started_by)) {
|
|
234
|
+
if (isPaused(workspaceId)) return false;
|
|
235
|
+
if ((await brakesOn(workspaceId)).length) return false;
|
|
236
|
+
}
|
|
237
|
+
const { needs } = needsOf({
|
|
238
|
+
projectId: project.id,
|
|
239
|
+
projectPath: expandHome(project.working_directory),
|
|
240
|
+
prompt: s.task_prompt ?? "",
|
|
241
|
+
});
|
|
242
|
+
const holds = localHolds(
|
|
243
|
+
{
|
|
244
|
+
needs,
|
|
245
|
+
priority: isPriority(s.priority) ? s.priority : DEFAULT_PRIORITY,
|
|
246
|
+
},
|
|
247
|
+
now
|
|
248
|
+
);
|
|
249
|
+
if (holds.length) return false;
|
|
250
|
+
handingOff.add(id);
|
|
251
|
+
try {
|
|
252
|
+
const message = s.plan_handoff_message ?? undefined;
|
|
253
|
+
const edits = s.plan_edits ?? null;
|
|
254
|
+
const arrived = await moveTask(id, LOCAL);
|
|
255
|
+
// The arrived row keeps the pending hand-off until implementing has
|
|
256
|
+
// started, so a restart in between is picked up by syncRemotePlans.
|
|
257
|
+
db.prepare(
|
|
258
|
+
`UPDATE sessions SET plan_edits = COALESCE(?, plan_edits), plan_handoff_message = ? WHERE id = ?`
|
|
259
|
+
).run(edits, message ?? "", arrived.id);
|
|
260
|
+
handingOff.add(arrived.id);
|
|
261
|
+
try {
|
|
262
|
+
return await finishArrival(arrived.id, message);
|
|
263
|
+
} finally {
|
|
264
|
+
handingOff.delete(arrived.id);
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
handingOff.delete(id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Every mirror whose plan is being written, or approved and coming here. */
|
|
272
|
+
export async function syncRemotePlans(): Promise<void> {
|
|
273
|
+
const rows = db
|
|
274
|
+
.prepare(
|
|
275
|
+
`SELECT * FROM sessions
|
|
276
|
+
WHERE plan_waits_for_caller = 1 AND plan_phase IN ('planning', 'handoff')
|
|
277
|
+
AND task_status = 'running' AND archived_at IS NULL`
|
|
278
|
+
)
|
|
279
|
+
.all() as Session[];
|
|
280
|
+
for (const s of rows) {
|
|
281
|
+
if (!isRemoteHost(s.host_id)) continue;
|
|
282
|
+
try {
|
|
283
|
+
if (s.plan_phase === "planning") await planWritten(s);
|
|
284
|
+
else fire(s.id, () => handoffHome(s.id));
|
|
285
|
+
} catch (e) {
|
|
286
|
+
console.error(`Plan on ${s.host_id} for ${s.id} failed:`, e);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// A task that arrived but never began implementing (a restart between).
|
|
290
|
+
const arrived = db
|
|
291
|
+
.prepare(
|
|
292
|
+
`SELECT id, plan_handoff_message FROM sessions
|
|
293
|
+
WHERE plan_phase = 'awaiting' AND plan_handoff_message IS NOT NULL
|
|
294
|
+
AND task_status = 'running' AND archived_at IS NULL AND peer_mirror IS NOT 1`
|
|
295
|
+
)
|
|
296
|
+
.all() as { id: string; plan_handoff_message: string }[];
|
|
297
|
+
for (const a of arrived) {
|
|
298
|
+
if (isRemoteHost(getSession(a.id)?.host_id)) continue;
|
|
299
|
+
fire(a.id, async () => {
|
|
300
|
+
handingOff.add(a.id);
|
|
301
|
+
try {
|
|
302
|
+
await finishArrival(a.id, a.plan_handoff_message);
|
|
303
|
+
} finally {
|
|
304
|
+
handingOff.delete(a.id);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
package/lib/tasks/remote.ts
CHANGED
|
@@ -38,6 +38,9 @@ export const TASK_CAPABILITIES = [
|
|
|
38
38
|
// A keyed start that carries an orchestrator's description holds the task
|
|
39
39
|
// for the device that sent it, as a move does (lib/tasks/held.ts).
|
|
40
40
|
"held-start",
|
|
41
|
+
// A plan placed here by another machine stops at the plan for that machine
|
|
42
|
+
// to decide what follows (lib/tasks/remote-plan.ts).
|
|
43
|
+
"remote-plan",
|
|
41
44
|
] as const;
|
|
42
45
|
|
|
43
46
|
export interface HostTasks {
|
|
@@ -138,10 +141,12 @@ export async function startRemoteTask(
|
|
|
138
141
|
view?: "chat" | "terminal";
|
|
139
142
|
// The orchestrator that started it: that machine holds the task for it.
|
|
140
143
|
startedBy?: string | null;
|
|
144
|
+
// It plans first: there it plans, stops, and waits for this machine.
|
|
145
|
+
plan?: { only: boolean; reason: string | null };
|
|
141
146
|
}
|
|
142
147
|
): Promise<Session> {
|
|
143
148
|
const link = requireHostLink(hostId);
|
|
144
|
-
const { startedBy: by, ...rest } = opts;
|
|
149
|
+
const { startedBy: by, plan, ...rest } = opts;
|
|
145
150
|
const startedBy = orchestratorOf(by);
|
|
146
151
|
if (startedBy)
|
|
147
152
|
await requireCapability(
|
|
@@ -150,17 +155,29 @@ export async function startRemoteTask(
|
|
|
150
155
|
"an orchestrator's task",
|
|
151
156
|
"hold"
|
|
152
157
|
);
|
|
158
|
+
if (plan) await requireCapability(link, "remote-plan", "a plan step", "plan");
|
|
153
159
|
const { session } = await postIdempotent<{ session: Session }>(
|
|
154
160
|
link,
|
|
155
161
|
"/api/tasks",
|
|
156
162
|
{
|
|
157
163
|
project: await projectRef(project),
|
|
158
164
|
...rest,
|
|
165
|
+
...(plan && {
|
|
166
|
+
plan: true,
|
|
167
|
+
plan_only: true,
|
|
168
|
+
plan_waits: true,
|
|
169
|
+
plan_reason: plan.reason ?? undefined,
|
|
170
|
+
}),
|
|
159
171
|
orchestrated: startedBy ? orchestratedStart(startedBy) : undefined,
|
|
160
172
|
},
|
|
161
173
|
180000
|
|
162
174
|
);
|
|
163
175
|
mirrorTask(hostId, project.id, session, startedBy);
|
|
176
|
+
if (plan)
|
|
177
|
+
db.prepare(
|
|
178
|
+
`UPDATE sessions SET plan_phase = 'planning', plan_only = ?, plan_decided = 1,
|
|
179
|
+
plan_reason = ?, plan_waits_for_caller = 1 WHERE id = ?`
|
|
180
|
+
).run(plan.only ? 1 : null, plan.reason, session.id);
|
|
164
181
|
forgetHostTasks(hostId);
|
|
165
182
|
return db
|
|
166
183
|
.prepare(`SELECT * FROM sessions WHERE id = ?`)
|
package/lib/tasks/schedule.ts
CHANGED
|
@@ -13,59 +13,33 @@ import {
|
|
|
13
13
|
PRIORITY_LABEL,
|
|
14
14
|
type Priority,
|
|
15
15
|
type QueueExplainView,
|
|
16
|
-
type WaitCode,
|
|
17
16
|
} from "./schedule-types";
|
|
17
|
+
import {
|
|
18
|
+
PRIORITY_POINTS,
|
|
19
|
+
holdsFor,
|
|
20
|
+
type Hold,
|
|
21
|
+
type MachineView,
|
|
22
|
+
} from "./holds";
|
|
18
23
|
|
|
19
24
|
export { PRIORITIES, DEFAULT_PRIORITY } from "./schedule-types";
|
|
20
25
|
export type { Priority, WaitCode } from "./schedule-types";
|
|
26
|
+
export {
|
|
27
|
+
AMBER_MIN_PRIORITY,
|
|
28
|
+
HEAVY_START_GAP_MS,
|
|
29
|
+
LOAD_SETTLE_MS,
|
|
30
|
+
PRIORITY_POINTS,
|
|
31
|
+
START_GAP_MS,
|
|
32
|
+
machineHold,
|
|
33
|
+
slotText,
|
|
34
|
+
type Hold,
|
|
35
|
+
type MachineLevel,
|
|
36
|
+
type MachineView,
|
|
37
|
+
} from "./holds";
|
|
21
38
|
|
|
22
|
-
export const PRIORITY_POINTS: Record<Priority, number> = {
|
|
23
|
-
urgent: 400,
|
|
24
|
-
blocking: 300,
|
|
25
|
-
normal: 200,
|
|
26
|
-
background: 100,
|
|
27
|
-
};
|
|
28
39
|
export const AGE_POINTS_PER_MIN = 0.5;
|
|
29
40
|
// Reached after five hours of waiting.
|
|
30
41
|
export const AGE_CAP = 150;
|
|
31
42
|
export const RESUME_BONUS = 150;
|
|
32
|
-
// Out of red this long before automatic starts resume.
|
|
33
|
-
export const LOAD_SETTLE_MS = 120_000;
|
|
34
|
-
// On amber, only this priority and above start.
|
|
35
|
-
export const AMBER_MIN_PRIORITY: Priority = "blocking";
|
|
36
|
-
// Between automatic starts on one machine.
|
|
37
|
-
export const START_GAP_MS = 60_000;
|
|
38
|
-
// Between starts of tasks that boot a simulator.
|
|
39
|
-
export const HEAVY_START_GAP_MS = 5 * 60_000;
|
|
40
|
-
|
|
41
|
-
export type MachineLevel = "green" | "amber" | "red";
|
|
42
|
-
|
|
43
|
-
// What the machine a candidate would start on looks like right now.
|
|
44
|
-
export interface MachineView {
|
|
45
|
-
// Shown in hold texts, e.g. "this machine".
|
|
46
|
-
label: string;
|
|
47
|
-
// False: no load holds at all (AGENTOS_LOAD=off).
|
|
48
|
-
enabled: boolean;
|
|
49
|
-
// No sample read yet since AgentOS started.
|
|
50
|
-
reading: boolean;
|
|
51
|
-
level: MachineLevel;
|
|
52
|
-
// Not red, and calm for LOAD_SETTLE_MS since it last was.
|
|
53
|
-
settled: boolean;
|
|
54
|
-
// Seconds of calm still needed, when not settled and not red.
|
|
55
|
-
settleLeftMs: number;
|
|
56
|
-
booted: number;
|
|
57
|
-
maxBooted: number;
|
|
58
|
-
// Heavy tasks started within HEAVY_START_GAP_MS that hold no lease yet.
|
|
59
|
-
heavyStarting: number;
|
|
60
|
-
lastAutoStartAt: number | null;
|
|
61
|
-
lastHeavyStartAt: number | null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface Hold {
|
|
65
|
-
code: WaitCode;
|
|
66
|
-
text: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
43
|
export interface Candidate {
|
|
70
44
|
id: string;
|
|
71
45
|
kind: "start" | "resume";
|
|
@@ -88,7 +62,7 @@ export interface Candidate {
|
|
|
88
62
|
// `after X` hold, from the caller (it needs the database).
|
|
89
63
|
before: Hold | null;
|
|
90
64
|
// Orchestrator Pause and the usage window, which hold after `after`.
|
|
91
|
-
auto: Hold | null;
|
|
65
|
+
auto: Hold | readonly Hold[] | null;
|
|
92
66
|
// "After any": its set, the ones that have finished, the ones other waiters
|
|
93
67
|
// already used, and whether it was already released (a retry).
|
|
94
68
|
any: {
|
|
@@ -110,6 +84,8 @@ export interface Plan {
|
|
|
110
84
|
// The finish an "after any" waiter takes, by candidate id (null: none left).
|
|
111
85
|
consume: Map<string, string | null>;
|
|
112
86
|
explain: Map<string, QueueExplainView>;
|
|
87
|
+
// Every hold on each candidate, in order (empty: it starts).
|
|
88
|
+
holds: Map<string, Hold[]>;
|
|
113
89
|
}
|
|
114
90
|
|
|
115
91
|
export function scoreParts(
|
|
@@ -124,75 +100,6 @@ export function scoreParts(
|
|
|
124
100
|
return { priority, age, resume, score: priority + age + resume };
|
|
125
101
|
}
|
|
126
102
|
|
|
127
|
-
const priorityRank = (p: Priority) => PRIORITY_POINTS[p] ?? 0;
|
|
128
|
-
|
|
129
|
-
const seconds = (ms: number) => Math.max(1, Math.ceil(ms / 1000));
|
|
130
|
-
|
|
131
|
-
// The machine holds, in order. Null: it may start here now.
|
|
132
|
-
export function machineHold(
|
|
133
|
-
c: Pick<Candidate, "heavy" | "needsUnmet" | "remote" | "priority"> &
|
|
134
|
-
Partial<Pick<Candidate, "planOnly">>,
|
|
135
|
-
m: MachineView,
|
|
136
|
-
now: number
|
|
137
|
-
): Hold | null {
|
|
138
|
-
if (c.remote || c.planOnly) return null;
|
|
139
|
-
if (c.needsUnmet) return { code: "needs", text: `Waiting: needs a Mac` };
|
|
140
|
-
if (!m.enabled) return null;
|
|
141
|
-
if (m.reading)
|
|
142
|
-
return { code: "load_red", text: "Waiting: reading machine load" };
|
|
143
|
-
if (m.level === "red")
|
|
144
|
-
return { code: "load_red", text: `Waiting: ${m.label} load red` };
|
|
145
|
-
if (!m.settled)
|
|
146
|
-
return {
|
|
147
|
-
code: "load_red",
|
|
148
|
-
text: `Waiting: ${m.label} load was red; calm for ${seconds(m.settleLeftMs)}s more`,
|
|
149
|
-
};
|
|
150
|
-
if (
|
|
151
|
-
m.level === "amber" &&
|
|
152
|
-
priorityRank(c.priority) < priorityRank(AMBER_MIN_PRIORITY)
|
|
153
|
-
)
|
|
154
|
-
return {
|
|
155
|
-
code: "load_amber",
|
|
156
|
-
text: `Waiting: ${m.label} load amber; ${c.priority} work starts on green`,
|
|
157
|
-
};
|
|
158
|
-
if (c.heavy) {
|
|
159
|
-
if (m.level !== "green")
|
|
160
|
-
return {
|
|
161
|
-
code: "heavy",
|
|
162
|
-
text: `Waiting: ${m.label} load amber; simulator work starts on green`,
|
|
163
|
-
};
|
|
164
|
-
if (m.heavyStarting > 0)
|
|
165
|
-
return {
|
|
166
|
-
code: "heavy",
|
|
167
|
-
text: `Waiting: a simulator is booting on ${m.label}`,
|
|
168
|
-
};
|
|
169
|
-
if (m.booted >= m.maxBooted)
|
|
170
|
-
return {
|
|
171
|
-
code: "heavy",
|
|
172
|
-
text: `Waiting: ${m.booted} of ${m.maxBooted} simulators in use`,
|
|
173
|
-
};
|
|
174
|
-
if (
|
|
175
|
-
m.lastHeavyStartAt !== null &&
|
|
176
|
-
now - m.lastHeavyStartAt < HEAVY_START_GAP_MS
|
|
177
|
-
)
|
|
178
|
-
return {
|
|
179
|
-
code: "heavy",
|
|
180
|
-
text: `Waiting: a simulator task just started on ${m.label} (next in ${seconds(HEAVY_START_GAP_MS - (now - m.lastHeavyStartAt))}s)`,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
if (m.lastAutoStartAt !== null && now - m.lastAutoStartAt < START_GAP_MS)
|
|
184
|
-
return {
|
|
185
|
-
code: "spacing",
|
|
186
|
-
text: `Waiting: spacing starts (next in ${seconds(START_GAP_MS - (now - m.lastAutoStartAt))}s)`,
|
|
187
|
-
};
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function slotText(rank: number, of: number, priority: Priority): string {
|
|
192
|
-
const word = priority === DEFAULT_PRIORITY ? "" : ` · ${priority}`;
|
|
193
|
-
return `#${rank} of ${of}${word}`;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
103
|
export function priorityWord(priority: Priority): string {
|
|
197
104
|
return PRIORITY_LABEL[priority];
|
|
198
105
|
}
|
|
@@ -204,7 +111,12 @@ export function planStarts(
|
|
|
204
111
|
machine: MachineView,
|
|
205
112
|
now: number
|
|
206
113
|
): Plan {
|
|
207
|
-
const plan: Plan = {
|
|
114
|
+
const plan: Plan = {
|
|
115
|
+
start: [],
|
|
116
|
+
consume: new Map(),
|
|
117
|
+
explain: new Map(),
|
|
118
|
+
holds: new Map(),
|
|
119
|
+
};
|
|
208
120
|
const m: MachineView = { ...machine };
|
|
209
121
|
const byGroup = new Map<string, Candidate[]>();
|
|
210
122
|
for (const c of candidates)
|
|
@@ -218,26 +130,27 @@ export function planStarts(
|
|
|
218
130
|
const scored = list
|
|
219
131
|
.map((c) => ({ c, ...scoreParts(c, now) }))
|
|
220
132
|
.sort((a, b) => b.score - a.score || a.c.position - b.c.position);
|
|
221
|
-
scored.forEach(({ c, score
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
133
|
+
scored.forEach(({ c, score }, index) => {
|
|
134
|
+
const holds = holdsFor(c, {
|
|
135
|
+
machine: m,
|
|
136
|
+
now,
|
|
137
|
+
used,
|
|
138
|
+
inFlight,
|
|
139
|
+
limit: group.limit,
|
|
140
|
+
rank: index + 1,
|
|
141
|
+
of: scored.length,
|
|
142
|
+
});
|
|
143
|
+
const hold = holds[0] ?? null;
|
|
144
|
+
plan.holds.set(c.id, holds);
|
|
145
|
+
plan.explain.set(c.id, {
|
|
146
|
+
code: hold?.code ?? null,
|
|
147
|
+
text: hold?.text ?? null,
|
|
227
148
|
score: Math.round(score * 10) / 10,
|
|
228
|
-
rank,
|
|
149
|
+
rank: index + 1,
|
|
229
150
|
of: scored.length,
|
|
230
151
|
priority: c.priority,
|
|
231
152
|
});
|
|
232
|
-
if (hold) return
|
|
233
|
-
if (!c.planOnly && inFlight >= group.limit)
|
|
234
|
-
return void plan.explain.set(
|
|
235
|
-
c.id,
|
|
236
|
-
view({
|
|
237
|
-
code: "slot",
|
|
238
|
-
text: slotText(rank, scored.length, c.priority),
|
|
239
|
-
})
|
|
240
|
-
);
|
|
153
|
+
if (hold) return;
|
|
241
154
|
// Passed every hold: it starts.
|
|
242
155
|
if (c.any && !c.any.released) {
|
|
243
156
|
const free = c.any.finished.find((id) => !used.has(id)) ?? null;
|
|
@@ -253,23 +166,7 @@ export function planStarts(
|
|
|
253
166
|
}
|
|
254
167
|
}
|
|
255
168
|
plan.start.push(c.id);
|
|
256
|
-
plan.explain.set(c.id, view(null));
|
|
257
169
|
});
|
|
258
170
|
}
|
|
259
171
|
return plan;
|
|
260
|
-
|
|
261
|
-
function holdOf(c: Candidate): Hold | null {
|
|
262
|
-
if (c.before) return c.before;
|
|
263
|
-
if (c.any && !c.any.released) {
|
|
264
|
-
const free = c.any.finished.some((id) => !used.has(id));
|
|
265
|
-
const spent =
|
|
266
|
-
c.any.ids.length > 0 && c.any.finished.length === c.any.ids.length;
|
|
267
|
-
if (!free && !spent)
|
|
268
|
-
return {
|
|
269
|
-
code: "after_any",
|
|
270
|
-
text: `Waiting: after any running task (${c.any.label})`,
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
return c.auto ?? machineHold(c, m, now);
|
|
274
|
-
}
|
|
275
172
|
}
|