@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,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting one queued row: the claim, the brakes, the task's creation, and
|
|
3
|
+
* what is recorded and announced when it started or failed.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ModelOverride } from "../models/resolve";
|
|
7
|
+
import type { AgentType } from "../providers";
|
|
8
|
+
import { db, type Session } from "../db";
|
|
9
|
+
import { getProject } from "../projects";
|
|
10
|
+
import { isRemoteHost } from "../hosts";
|
|
11
|
+
import { BrakeRefused, braked, recordStart } from "../orchestrator/brakes";
|
|
12
|
+
import { queueEvent } from "../orchestrator/events";
|
|
13
|
+
import { createTask } from "./index";
|
|
14
|
+
import {
|
|
15
|
+
markQueuedHeldDelivered,
|
|
16
|
+
queuedHeldMessages,
|
|
17
|
+
queuedMessagesSection,
|
|
18
|
+
} from "./queue-held";
|
|
19
|
+
import { DEFAULT_PRIORITY } from "./schedule-types";
|
|
20
|
+
import { LOCAL, recordAutoStart } from "../load/machines";
|
|
21
|
+
import { autoHold } from "./hold-checks";
|
|
22
|
+
import type { Placement } from "./placement";
|
|
23
|
+
import type { StartVia } from "./starter";
|
|
24
|
+
import { limitOf, patch, runningIn, sessionOf, startingIn } from "./queue-rows";
|
|
25
|
+
import {
|
|
26
|
+
START_ATTEMPTS,
|
|
27
|
+
type QueueRow,
|
|
28
|
+
type StartOutcome,
|
|
29
|
+
} from "./queue-types";
|
|
30
|
+
|
|
31
|
+
// Tells the orchestrator that queued it that it started.
|
|
32
|
+
export function announce(
|
|
33
|
+
r: Pick<QueueRow, "id" | "origin_workspace_id">,
|
|
34
|
+
session: Pick<Session, "name" | "branch_name">,
|
|
35
|
+
placed?: { text: string } | null
|
|
36
|
+
): void {
|
|
37
|
+
if (!r.origin_workspace_id) return;
|
|
38
|
+
queueEvent(
|
|
39
|
+
r.origin_workspace_id,
|
|
40
|
+
`queued-start:${r.id}`,
|
|
41
|
+
r.id,
|
|
42
|
+
placed
|
|
43
|
+
? `Queued task "${session.name}" (id ${r.id.slice(0, 8)}) ${placed.text}.`
|
|
44
|
+
: `Queued task "${session.name}" (id ${r.id.slice(0, 8)}) started on ${session.branch_name ?? "its branch"}.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface StartOpts {
|
|
49
|
+
force?: boolean;
|
|
50
|
+
// The finish an "after any" start takes (null: none left to take).
|
|
51
|
+
consume?: string | null;
|
|
52
|
+
// Start on this linked machine instead of the project's.
|
|
53
|
+
placement?: Placement | null;
|
|
54
|
+
heavy?: boolean;
|
|
55
|
+
// Pass this workspace's brakes even for a row no orchestrator queued.
|
|
56
|
+
brakeAs?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function startRow(
|
|
60
|
+
r: QueueRow,
|
|
61
|
+
opts: StartOpts = {}
|
|
62
|
+
): Promise<StartOutcome> {
|
|
63
|
+
const project = getProject(r.project_id);
|
|
64
|
+
const workspaceId = project?.workspace_id ?? null;
|
|
65
|
+
const origin = r.origin_workspace_id;
|
|
66
|
+
// The orchestrator starts only in its own workspace's projects on this
|
|
67
|
+
// machine; the project may have moved since it was queued.
|
|
68
|
+
if (
|
|
69
|
+
origin &&
|
|
70
|
+
(!project ||
|
|
71
|
+
workspaceId !== origin ||
|
|
72
|
+
isRemoteHost(r.host_id ?? project.host_id))
|
|
73
|
+
) {
|
|
74
|
+
patch(r.id, {
|
|
75
|
+
status: "failed",
|
|
76
|
+
error:
|
|
77
|
+
"Not started: its project left the orchestrator's workspace or this machine",
|
|
78
|
+
});
|
|
79
|
+
return "failed";
|
|
80
|
+
}
|
|
81
|
+
if (!opts.force) {
|
|
82
|
+
const held = autoHold(workspaceId, !!r.plan_only);
|
|
83
|
+
if (held) {
|
|
84
|
+
if (r.note !== held.text)
|
|
85
|
+
patch(r.id, { note: held.text, wait_code: held.code });
|
|
86
|
+
return "held";
|
|
87
|
+
}
|
|
88
|
+
// A start that came in during this tick's earlier starts may have taken
|
|
89
|
+
// the slot it planned on. The check and the claim are both synchronous.
|
|
90
|
+
const limit = limitOf(workspaceId);
|
|
91
|
+
if (
|
|
92
|
+
workspaceId &&
|
|
93
|
+
limit !== null &&
|
|
94
|
+
!r.plan_only &&
|
|
95
|
+
runningIn({ workspaceId, projectId: r.project_id }, true).length +
|
|
96
|
+
startingIn(workspaceId) >=
|
|
97
|
+
limit
|
|
98
|
+
)
|
|
99
|
+
return "held";
|
|
100
|
+
}
|
|
101
|
+
let claimed = 0;
|
|
102
|
+
try {
|
|
103
|
+
claimed = db
|
|
104
|
+
.prepare(
|
|
105
|
+
`UPDATE task_queue SET status = 'starting', note = NULL, wait_code = NULL,
|
|
106
|
+
after_any_used = COALESCE(after_any_used, @used)
|
|
107
|
+
WHERE id = @id AND status = 'queued'`
|
|
108
|
+
)
|
|
109
|
+
.run({ id: r.id, used: opts.consume ?? null }).changes;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// Another waiter took that finish between the plan and the claim.
|
|
112
|
+
if (String(error).includes("UNIQUE")) return "held";
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
if (!claimed) return "held";
|
|
116
|
+
const placed = opts.placement ?? null;
|
|
117
|
+
if (!opts.force && !r.plan_only)
|
|
118
|
+
recordAutoStart(placed?.hostId ?? LOCAL, !!opts.heavy, Date.now());
|
|
119
|
+
// A linked machine's start never sees this machine's held messages: they
|
|
120
|
+
// go with the brief it is sent.
|
|
121
|
+
const remote = isRemoteHost(placed?.hostId ?? r.host_id ?? project?.host_id);
|
|
122
|
+
const remoteHeld = remote ? queuedHeldMessages(r.id) : [];
|
|
123
|
+
const task = {
|
|
124
|
+
id: r.id,
|
|
125
|
+
projectId: r.project_id,
|
|
126
|
+
prompt: r.prompt + queuedMessagesSection(remoteHeld),
|
|
127
|
+
name: r.name ?? undefined,
|
|
128
|
+
model: r.model_overrides
|
|
129
|
+
? (JSON.parse(r.model_overrides) as ModelOverride)
|
|
130
|
+
: (r.model ?? undefined),
|
|
131
|
+
plan: r.plan == null ? undefined : !!r.plan,
|
|
132
|
+
planReason: r.plan_reason,
|
|
133
|
+
planOnly: !!r.plan_only,
|
|
134
|
+
startedBy: r.started_by,
|
|
135
|
+
parentSessionId: r.parent_session_id,
|
|
136
|
+
startedByDevice: r.started_by_device,
|
|
137
|
+
startedVia: (r.started_via ??
|
|
138
|
+
(r.started_by ? "session" : "person")) as StartVia,
|
|
139
|
+
view: r.view ?? undefined,
|
|
140
|
+
agent: (r.agent_type as AgentType | null) ?? undefined,
|
|
141
|
+
baseBranch: r.base_branch ?? undefined,
|
|
142
|
+
hostId: placed?.hostId ?? r.host_id ?? undefined,
|
|
143
|
+
reclaim: true,
|
|
144
|
+
};
|
|
145
|
+
const placedText = placed
|
|
146
|
+
? `started on ${placed.name} · ${placed.why}`
|
|
147
|
+
: null;
|
|
148
|
+
try {
|
|
149
|
+
// The orchestrator's queued starts pass its brakes when they start,
|
|
150
|
+
// a person's "start now" included: it skips only the limit and `after`.
|
|
151
|
+
const brakeWs = origin ?? opts.brakeAs;
|
|
152
|
+
const session = brakeWs
|
|
153
|
+
? await braked(
|
|
154
|
+
brakeWs,
|
|
155
|
+
"task",
|
|
156
|
+
() => createTask(task),
|
|
157
|
+
(s) => s.id,
|
|
158
|
+
{ spendApproval: false }
|
|
159
|
+
)
|
|
160
|
+
: await createTask(task);
|
|
161
|
+
if (remoteHeld.length) markQueuedHeldDelivered(r.id);
|
|
162
|
+
patch(r.id, {
|
|
163
|
+
status: "started",
|
|
164
|
+
started_at: new Date().toISOString(),
|
|
165
|
+
error: null,
|
|
166
|
+
note: null,
|
|
167
|
+
});
|
|
168
|
+
stampStarted(r, placed);
|
|
169
|
+
announce(r, session, placedText ? { text: placedText } : null);
|
|
170
|
+
return "started";
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (error instanceof BrakeRefused) {
|
|
173
|
+
patch(r.id, { status: "queued", note: `Waiting: ${error.reason}` });
|
|
174
|
+
if (opts.force) throw error;
|
|
175
|
+
return "held";
|
|
176
|
+
}
|
|
177
|
+
const message = (
|
|
178
|
+
error instanceof Error ? error.message : String(error)
|
|
179
|
+
).slice(0, 300);
|
|
180
|
+
// Its row exists: the task is there, only its launch failed.
|
|
181
|
+
// The brakes never got to count it: count and announce it here.
|
|
182
|
+
const session = sessionOf(r.id);
|
|
183
|
+
if (session?.task_status) {
|
|
184
|
+
patch(r.id, { status: "started", started_at: new Date().toISOString() });
|
|
185
|
+
if (origin) recordStart(origin, "task", r.id);
|
|
186
|
+
announce(r, session);
|
|
187
|
+
return "started";
|
|
188
|
+
}
|
|
189
|
+
const attempts = r.attempts + 1;
|
|
190
|
+
const retry = attempts < START_ATTEMPTS && !opts.force;
|
|
191
|
+
patch(r.id, {
|
|
192
|
+
status: retry ? "queued" : "failed",
|
|
193
|
+
attempts,
|
|
194
|
+
error: retry
|
|
195
|
+
? `Start failed (try ${attempts} of ${START_ATTEMPTS}), trying again: ${message}`
|
|
196
|
+
: `Could not start: ${message}`,
|
|
197
|
+
});
|
|
198
|
+
return retry ? "retry" : "failed";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Marks the task a queue row became: its priority, and where it was placed.
|
|
203
|
+
function stampStarted(r: QueueRow, placed: Placement | null): void {
|
|
204
|
+
const note = placed ? `Started on ${placed.name} · ${placed.why}` : null;
|
|
205
|
+
const priority =
|
|
206
|
+
r.priority && r.priority !== DEFAULT_PRIORITY ? r.priority : null;
|
|
207
|
+
if (!note && !priority) return;
|
|
208
|
+
db.prepare(
|
|
209
|
+
`UPDATE sessions SET priority = COALESCE(?, priority), placed_note = COALESCE(?, placed_note) WHERE id = ?`
|
|
210
|
+
).run(priority, note, r.id);
|
|
211
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loop that wires the queue: one look at the line scores every queued
|
|
3
|
+
* task and every paused task that came due, places the starts this machine's
|
|
4
|
+
* load rules out, plans who starts, writes why the rest wait (from the
|
|
5
|
+
* holds), and starts what fits. One look at a time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db } from "../db";
|
|
9
|
+
import { localView } from "../load/machines";
|
|
10
|
+
import { claimResume, dueResumes, setPauseNote } from "./pause";
|
|
11
|
+
import { unplaceable } from "./placement";
|
|
12
|
+
import { planStarts, type Candidate } from "./schedule";
|
|
13
|
+
import { placeable, queueNote, resumeNote, type Hold } from "./holds";
|
|
14
|
+
import {
|
|
15
|
+
buildGroups,
|
|
16
|
+
candidateOf,
|
|
17
|
+
forceDueResumes,
|
|
18
|
+
resumeCandidate,
|
|
19
|
+
} from "./queue-candidates";
|
|
20
|
+
import { placeCandidates } from "./queue-placement";
|
|
21
|
+
import { patch, row, slotState, usedFinishes } from "./queue-rows";
|
|
22
|
+
import { startRow } from "./queue-start";
|
|
23
|
+
import type { QueueRow, QueueRowWithWorkspace } from "./queue-types";
|
|
24
|
+
|
|
25
|
+
const g = globalThis as unknown as {
|
|
26
|
+
__agentosQueueTick?: { ticking: Promise<void> | null };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// One look at the line: every queued task and every paused task that came
|
|
30
|
+
// due, scored, held or started. Machine readings come from the monitor and
|
|
31
|
+
// the peer poll; the clock is passed in.
|
|
32
|
+
export async function tickOnce(now = Date.now()): Promise<"stale" | void> {
|
|
33
|
+
const rows = db
|
|
34
|
+
.prepare(
|
|
35
|
+
`SELECT q.*, p.workspace_id AS workspace_id FROM task_queue q
|
|
36
|
+
JOIN projects p ON p.id = q.project_id
|
|
37
|
+
WHERE q.status IN ('queued', 'starting') ORDER BY q.position`
|
|
38
|
+
)
|
|
39
|
+
.all() as QueueRowWithWorkspace[];
|
|
40
|
+
const due = dueResumes();
|
|
41
|
+
await forceDueResumes(due);
|
|
42
|
+
const resumes = due.filter((x) => !x.resumeBy);
|
|
43
|
+
if (!rows.length && !resumes.length) return;
|
|
44
|
+
const queued = rows.filter((r) => r.status === "queued");
|
|
45
|
+
const used = usedFinishes();
|
|
46
|
+
const built = new Map(
|
|
47
|
+
queued.map((r) => [r.id, candidateOf(r, used)] as const)
|
|
48
|
+
);
|
|
49
|
+
const candidates: Candidate[] = [...built.values()].map((b) => b.c);
|
|
50
|
+
for (const d of resumes) candidates.push(resumeCandidate(d));
|
|
51
|
+
|
|
52
|
+
// A start the home machine's load rules out goes to a free linked
|
|
53
|
+
// machine, best score first, one per machine per look.
|
|
54
|
+
const home = localView(now);
|
|
55
|
+
const placements = await placeCandidates(
|
|
56
|
+
candidates,
|
|
57
|
+
rows,
|
|
58
|
+
new Map([...built].map(([id, b]) => [id, b.needs])),
|
|
59
|
+
home,
|
|
60
|
+
now
|
|
61
|
+
);
|
|
62
|
+
if (placements === "stale") return "stale";
|
|
63
|
+
|
|
64
|
+
const plan = planStarts(
|
|
65
|
+
candidates,
|
|
66
|
+
buildGroups(candidates, rows, resumes),
|
|
67
|
+
home,
|
|
68
|
+
now
|
|
69
|
+
);
|
|
70
|
+
for (const r of queued) writeQueueNote(r, stayHeld(r, plan.holds.get(r.id)));
|
|
71
|
+
for (const d of resumes)
|
|
72
|
+
setPauseNote(
|
|
73
|
+
d.sessionId,
|
|
74
|
+
resumeNote(
|
|
75
|
+
plan.holds.get(`resume:${d.sessionId}`),
|
|
76
|
+
slotState(d.projectId)
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
for (const id of plan.start) {
|
|
81
|
+
if (id.startsWith("resume:")) {
|
|
82
|
+
await claimResume(id.slice("resume:".length), { reason: "due" });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const r = row(id);
|
|
86
|
+
if (r?.status === "queued")
|
|
87
|
+
await startRow(r, {
|
|
88
|
+
consume: plan.consume.get(id) ?? null,
|
|
89
|
+
placement: placements.get(id) ?? null,
|
|
90
|
+
heavy: built.get(id)?.c.heavy,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A hold on this machine's load says, for a task that can't go elsewhere,
|
|
96
|
+
// why it waits here instead of starting on a linked machine.
|
|
97
|
+
function stayHeld(r: QueueRow, holds: Hold[] | undefined): Hold[] | undefined {
|
|
98
|
+
const head = holds?.[0];
|
|
99
|
+
const why = unplaceable(r);
|
|
100
|
+
if (!holds || !head || !why || !placeable(head) || !("machine" in head))
|
|
101
|
+
return holds;
|
|
102
|
+
return [{ ...head, stay: why }, ...holds.slice(1)];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Writes why a row waits when that changed: its first hold's text and code.
|
|
106
|
+
// A bare rank isn't stored (the view computes it), so the stream stays quiet.
|
|
107
|
+
function writeQueueNote(r: QueueRow, holds: Hold[] | undefined): void {
|
|
108
|
+
const explain = queueNote(holds);
|
|
109
|
+
if (!explain) return;
|
|
110
|
+
const hold = explain.code && explain.code !== "slot" ? explain : null;
|
|
111
|
+
if (hold) {
|
|
112
|
+
if (r.note !== hold.text || r.wait_code !== hold.code)
|
|
113
|
+
patch(r.id, { note: hold.text, wait_code: hold.code });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// A hold we wrote before is cleared; a brake's note isn't ours to clear.
|
|
117
|
+
if (r.wait_code && r.wait_code !== "slot")
|
|
118
|
+
patch(r.id, { note: null, wait_code: explain.code });
|
|
119
|
+
else if (r.wait_code !== explain.code)
|
|
120
|
+
patch(r.id, { wait_code: explain.code });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// One look at a time; a call during one shares it, and a look that went
|
|
124
|
+
// stale (the line changed while it waited on a machine) is run again.
|
|
125
|
+
const tickState = (g.__agentosQueueTick ??= { ticking: null });
|
|
126
|
+
|
|
127
|
+
export function tickQueue(): Promise<void> {
|
|
128
|
+
tickState.ticking ??= (async () => {
|
|
129
|
+
try {
|
|
130
|
+
// Plans written on a linked machine, and approved plans coming here.
|
|
131
|
+
await import("./remote-plan")
|
|
132
|
+
.then((m) => m.syncRemotePlans())
|
|
133
|
+
.catch((error: unknown) => console.error("[queue] plans:", error));
|
|
134
|
+
for (let i = 0; i < 3; i++) {
|
|
135
|
+
if ((await tickOnce()) !== "stale") break;
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
tickState.ticking = null;
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
return tickState.ticking;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function tickQueueSoon(): void {
|
|
145
|
+
void tickQueue().catch((error: unknown) =>
|
|
146
|
+
console.error("[queue] tick:", error)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** The shapes of the start queue: its rows, what clients see, what a request says. */
|
|
2
|
+
|
|
3
|
+
import type { ModelOverride } from "../models/resolve";
|
|
4
|
+
import type { AgentType } from "../providers";
|
|
5
|
+
import type { Priority, WaitCode } from "./schedule-types";
|
|
6
|
+
import type { StartVia } from "./starter";
|
|
7
|
+
|
|
8
|
+
export type QueueStatus =
|
|
9
|
+
| "queued"
|
|
10
|
+
| "starting"
|
|
11
|
+
| "started"
|
|
12
|
+
| "failed"
|
|
13
|
+
| "removed";
|
|
14
|
+
|
|
15
|
+
export interface QueueRow {
|
|
16
|
+
id: string;
|
|
17
|
+
project_id: string;
|
|
18
|
+
prompt: string;
|
|
19
|
+
name: string | null;
|
|
20
|
+
model: string | null;
|
|
21
|
+
// A per-role model map (lib/models), when that's what it was given.
|
|
22
|
+
model_overrides: string | null;
|
|
23
|
+
// The starter's plan decision (1/0, null: not stated), its reason, and the
|
|
24
|
+
// orchestrator session that started it.
|
|
25
|
+
plan: number | null;
|
|
26
|
+
plan_reason: string | null;
|
|
27
|
+
plan_only: number | null;
|
|
28
|
+
started_by: string | null;
|
|
29
|
+
parent_session_id: string | null;
|
|
30
|
+
started_by_device: string | null;
|
|
31
|
+
started_via: string | null;
|
|
32
|
+
view: "chat" | "terminal" | null;
|
|
33
|
+
agent_type: string | null;
|
|
34
|
+
base_branch: string | null;
|
|
35
|
+
host_id: string | null;
|
|
36
|
+
// A task (or queued task) it waits on, by id.
|
|
37
|
+
after_task: string | null;
|
|
38
|
+
// "after any": the tasks running when it was queued, as JSON; the first
|
|
39
|
+
// of them to finish lets it go.
|
|
40
|
+
after_any: string | null;
|
|
41
|
+
// The finish (a task id) this "after any" waiter took; one finish lets one
|
|
42
|
+
// waiter go (a unique index keeps it so).
|
|
43
|
+
after_any_used: string | null;
|
|
44
|
+
priority: Priority;
|
|
45
|
+
// What it needs from a machine, as JSON, when the starter said.
|
|
46
|
+
machine_needs: string | null;
|
|
47
|
+
// The code behind `note`, set by the scheduler.
|
|
48
|
+
wait_code: WaitCode | null;
|
|
49
|
+
// The orchestrator's workspace, when the orchestrator queued it: its
|
|
50
|
+
// start goes through that orchestrator's brakes.
|
|
51
|
+
origin_workspace_id: string | null;
|
|
52
|
+
position: number;
|
|
53
|
+
status: QueueStatus;
|
|
54
|
+
note: string | null;
|
|
55
|
+
error: string | null;
|
|
56
|
+
attempts: number;
|
|
57
|
+
created_at: string;
|
|
58
|
+
started_at: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// What the UI and tools see of a queued task. No server imports needed.
|
|
62
|
+
export interface QueuedTaskView {
|
|
63
|
+
id: string;
|
|
64
|
+
name: string;
|
|
65
|
+
prompt: string;
|
|
66
|
+
projectId: string;
|
|
67
|
+
projectName: string | null;
|
|
68
|
+
// The workspace whose line it's in, or null for a project without one.
|
|
69
|
+
workspaceId: string | null;
|
|
70
|
+
status: QueueStatus;
|
|
71
|
+
// 1-based place in its workspace's line.
|
|
72
|
+
position: number;
|
|
73
|
+
// What it waits on: a task's name, or "any running task".
|
|
74
|
+
after: string | null;
|
|
75
|
+
// The task it waits on, by id, when it waits on one.
|
|
76
|
+
afterId: string | null;
|
|
77
|
+
note: string | null;
|
|
78
|
+
error: string | null;
|
|
79
|
+
// The orchestrator session that queued it, when one did.
|
|
80
|
+
startedBy: string | null;
|
|
81
|
+
createdAt: string;
|
|
82
|
+
priority: Priority;
|
|
83
|
+
// Why it isn't starting, from the scheduler; null: nothing but its turn.
|
|
84
|
+
wait: WaitCode | null;
|
|
85
|
+
// Its place in the score order within its line, and how many are in it.
|
|
86
|
+
rank: number;
|
|
87
|
+
of: number;
|
|
88
|
+
// Messages held for it until it starts.
|
|
89
|
+
held: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const START_ATTEMPTS = 3;
|
|
93
|
+
|
|
94
|
+
export interface QueueRequest {
|
|
95
|
+
projectId: string;
|
|
96
|
+
prompt: string;
|
|
97
|
+
name?: string;
|
|
98
|
+
model?: ModelOverride;
|
|
99
|
+
plan?: boolean;
|
|
100
|
+
planReason?: string | null;
|
|
101
|
+
planOnly?: boolean;
|
|
102
|
+
startedBy?: string | null;
|
|
103
|
+
parentSessionId?: string | null;
|
|
104
|
+
startedByDevice?: string | null;
|
|
105
|
+
startedVia: StartVia;
|
|
106
|
+
// The waiting plan piece it builds, claimed for its queue id.
|
|
107
|
+
pieceId?: number;
|
|
108
|
+
view?: "chat" | "terminal";
|
|
109
|
+
agent?: AgentType;
|
|
110
|
+
baseBranch?: string;
|
|
111
|
+
hostId?: string;
|
|
112
|
+
// A task's id or name, or "any" for whichever running task finishes first.
|
|
113
|
+
after?: string;
|
|
114
|
+
// Set when the orchestrator starts it.
|
|
115
|
+
originWorkspaceId?: string;
|
|
116
|
+
// Where it sits among the queued: urgent, blocking, normal (the default)
|
|
117
|
+
// or background.
|
|
118
|
+
priority?: Priority;
|
|
119
|
+
// "mac" pins it to a Mac; "any" says it needs nothing in particular.
|
|
120
|
+
machine?: "any" | "mac";
|
|
121
|
+
// Start at once, past the running-task limit, instead of queueing for a
|
|
122
|
+
// slot. Still needs the machine it needs, and an orchestrator's start
|
|
123
|
+
// still passes its brakes.
|
|
124
|
+
now?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type StartOutcome = "started" | "held" | "retry" | "failed";
|
|
128
|
+
|
|
129
|
+
export interface ForcedStart {
|
|
130
|
+
outcome: "started" | "held" | "failed";
|
|
131
|
+
// Why it is held, or what failed; empty when it started.
|
|
132
|
+
text: string;
|
|
133
|
+
// The running-task limit was full when it started.
|
|
134
|
+
wasFull: boolean;
|
|
135
|
+
running: number;
|
|
136
|
+
limit: number | null;
|
|
137
|
+
// It skipped an `after`.
|
|
138
|
+
skippedAfter: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type QueueRowWithWorkspace = QueueRow & { workspace_id: string | null };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** What clients see of the line: each task's place, rank and why it waits. */
|
|
2
|
+
|
|
3
|
+
import { db } from "../db";
|
|
4
|
+
import { DEFAULT_PRIORITY, isPriority } from "./schedule-types";
|
|
5
|
+
import { scoreParts } from "./schedule";
|
|
6
|
+
import { afterLabel, label, sqliteMs } from "./queue-rows";
|
|
7
|
+
import type { QueueRow, QueuedTaskView } from "./queue-types";
|
|
8
|
+
|
|
9
|
+
// Every task still in line (and any that failed to start), in line order.
|
|
10
|
+
export function listQueue(): QueuedTaskView[] {
|
|
11
|
+
const rows = db
|
|
12
|
+
.prepare(
|
|
13
|
+
`SELECT q.*, p.name AS project_name, p.workspace_id AS workspace_id,
|
|
14
|
+
(SELECT COUNT(*) FROM bus_messages b WHERE b.to_id = q.id
|
|
15
|
+
AND b.held_queued = 1 AND b.delivered_at IS NULL AND b.read_at IS NULL) AS held_count
|
|
16
|
+
FROM task_queue q LEFT JOIN projects p ON p.id = q.project_id
|
|
17
|
+
WHERE q.status IN ('queued', 'starting', 'failed')
|
|
18
|
+
ORDER BY q.position`
|
|
19
|
+
)
|
|
20
|
+
.all() as (QueueRow & {
|
|
21
|
+
project_name: string | null;
|
|
22
|
+
workspace_id: string | null;
|
|
23
|
+
held_count: number;
|
|
24
|
+
})[];
|
|
25
|
+
const places = new Map<string, number>();
|
|
26
|
+
// The score order within each line, by priority and age alone: holds
|
|
27
|
+
// don't move a task's rank.
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const ranked = new Map<string, { rank: number; of: number }>();
|
|
30
|
+
const lines = new Map<string, typeof rows>();
|
|
31
|
+
for (const r of rows.filter((r) => r.status === "queued")) {
|
|
32
|
+
const key = r.workspace_id ?? `project:${r.project_id}`;
|
|
33
|
+
lines.set(key, [...(lines.get(key) ?? []), r]);
|
|
34
|
+
}
|
|
35
|
+
for (const line of lines.values()) {
|
|
36
|
+
const order = line
|
|
37
|
+
.map((r) => ({ r, score: scoreOf(r, now) }))
|
|
38
|
+
.sort((a, b) => b.score - a.score || a.r.position - b.r.position);
|
|
39
|
+
order.forEach(({ r }, i) =>
|
|
40
|
+
ranked.set(r.id, { rank: i + 1, of: order.length })
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return rows.map((r) => {
|
|
44
|
+
const key = r.workspace_id ?? `project:${r.project_id}`;
|
|
45
|
+
const place = (places.get(key) ?? 0) + 1;
|
|
46
|
+
places.set(key, place);
|
|
47
|
+
const rank = ranked.get(r.id);
|
|
48
|
+
return {
|
|
49
|
+
id: r.id,
|
|
50
|
+
name: label(r),
|
|
51
|
+
prompt: r.prompt,
|
|
52
|
+
projectId: r.project_id,
|
|
53
|
+
projectName: r.project_name,
|
|
54
|
+
workspaceId: r.workspace_id,
|
|
55
|
+
status: r.status,
|
|
56
|
+
position: place,
|
|
57
|
+
after: afterLabel(r),
|
|
58
|
+
afterId: r.after_task,
|
|
59
|
+
note: r.note,
|
|
60
|
+
error: r.error,
|
|
61
|
+
startedBy: r.started_by,
|
|
62
|
+
createdAt: r.created_at,
|
|
63
|
+
priority: isPriority(r.priority) ? r.priority : DEFAULT_PRIORITY,
|
|
64
|
+
wait: r.wait_code,
|
|
65
|
+
rank: rank?.rank ?? place,
|
|
66
|
+
of: rank?.of ?? place,
|
|
67
|
+
held: r.held_count,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function scoreOf(r: QueueRow, now: number): number {
|
|
73
|
+
return scoreParts(
|
|
74
|
+
{
|
|
75
|
+
kind: "start",
|
|
76
|
+
priority: isPriority(r.priority) ? r.priority : DEFAULT_PRIORITY,
|
|
77
|
+
waitingSinceMs: sqliteMs(r.created_at),
|
|
78
|
+
},
|
|
79
|
+
now
|
|
80
|
+
).score;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const queuedView = (id: string) =>
|
|
84
|
+
listQueue().find((q) => q.id === id) ?? null;
|
|
85
|
+
|
|
86
|
+
// A task still waiting in the line (or failed to start) by id, an id prefix
|
|
87
|
+
// of 4+ characters or, when asked, its name; in one workspace.
|
|
88
|
+
export function findQueued(
|
|
89
|
+
workspaceId: string,
|
|
90
|
+
ref: string,
|
|
91
|
+
opts: { byName?: boolean } = {}
|
|
92
|
+
): QueueRow | null {
|
|
93
|
+
const want = ref.trim();
|
|
94
|
+
const rows = db
|
|
95
|
+
.prepare(
|
|
96
|
+
`SELECT q.* FROM task_queue q JOIN projects p ON p.id = q.project_id
|
|
97
|
+
WHERE p.workspace_id = ? AND q.status IN ('queued', 'failed')`
|
|
98
|
+
)
|
|
99
|
+
.all(workspaceId) as QueueRow[];
|
|
100
|
+
const lower = want.toLowerCase();
|
|
101
|
+
const hits = rows.filter(
|
|
102
|
+
(r) =>
|
|
103
|
+
r.id === want ||
|
|
104
|
+
(want.length >= 4 && r.id.startsWith(want)) ||
|
|
105
|
+
(opts.byName && label(r).toLowerCase() === lower)
|
|
106
|
+
);
|
|
107
|
+
if (hits.length > 1)
|
|
108
|
+
throw new Error(
|
|
109
|
+
`"${ref}" matches ${hits.length} queued tasks: ${hits.map((h) => `${label(h)} (${h.id.slice(0, 8)})`).join(", ")}`
|
|
110
|
+
);
|
|
111
|
+
return hits[0] ?? null;
|
|
112
|
+
}
|