@saadnvd1/agent-os 0.3.630 → 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
package/lib/tasks/placement.ts
CHANGED
|
@@ -12,12 +12,8 @@ import path from "path";
|
|
|
12
12
|
import { db } from "../db";
|
|
13
13
|
import { loadProjectConfig } from "../project-config";
|
|
14
14
|
import { MACHINE_NEEDS } from "../project-config/schema";
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
type Candidate,
|
|
18
|
-
type Hold,
|
|
19
|
-
type MachineView,
|
|
20
|
-
} from "./schedule";
|
|
15
|
+
import { machineHold, placeable, type MachineView } from "./holds";
|
|
16
|
+
import type { Candidate } from "./schedule";
|
|
21
17
|
|
|
22
18
|
export type Need = (typeof MACHINE_NEEDS)[number];
|
|
23
19
|
export type NeedsSource = "task" | "brief" | "config" | "inferred" | "none";
|
|
@@ -138,8 +134,6 @@ export function unplaceable(r: {
|
|
|
138
134
|
host_id: string | null;
|
|
139
135
|
}): string | null {
|
|
140
136
|
if (r.host_id) return "it was pinned to a machine";
|
|
141
|
-
if (r.plan_only) return "plan-only tasks run on this machine";
|
|
142
|
-
if (r.plan) return "plan-first tasks run on this machine";
|
|
143
137
|
if (r.agent_type && r.agent_type !== "claude")
|
|
144
138
|
return "only Claude tasks run on another machine";
|
|
145
139
|
if (r.model_overrides) return "per-role models run on this machine";
|
|
@@ -153,11 +147,24 @@ export interface PeerOption {
|
|
|
153
147
|
capabilities: string[];
|
|
154
148
|
// It can hold an orchestrator's task (false: an older AgentOS). Unset: yes.
|
|
155
149
|
holds?: boolean;
|
|
150
|
+
// It can run a plan step that stops for this machine (false: an older
|
|
151
|
+
// AgentOS). Unset: yes.
|
|
152
|
+
plans?: boolean;
|
|
156
153
|
// The project exists there (projectMachines said null).
|
|
157
154
|
hasProject: boolean;
|
|
158
155
|
loadPerCore: number;
|
|
159
156
|
}
|
|
160
157
|
|
|
158
|
+
// A start that plans first: the plan phase only reads code, so it needs no
|
|
159
|
+
// simulator or Mac, and the plan-only skip of load holds doesn't apply to
|
|
160
|
+
// where it goes, only to whether it may wait. The candidate as a plan phase
|
|
161
|
+
// sees it.
|
|
162
|
+
export const asPlanPhase = <
|
|
163
|
+
T extends Pick<Candidate, "heavy" | "needsUnmet" | "planOnly">,
|
|
164
|
+
>(
|
|
165
|
+
c: T
|
|
166
|
+
): T => ({ ...c, heavy: false, needsUnmet: false, planOnly: false });
|
|
167
|
+
|
|
161
168
|
export interface Placement {
|
|
162
169
|
hostId: string;
|
|
163
170
|
name: string;
|
|
@@ -165,13 +172,6 @@ export interface Placement {
|
|
|
165
172
|
why: string;
|
|
166
173
|
}
|
|
167
174
|
|
|
168
|
-
const LOAD_HOLDS = new Set<Hold["code"]>([
|
|
169
|
-
"load_red",
|
|
170
|
-
"load_amber",
|
|
171
|
-
"needs",
|
|
172
|
-
"heavy",
|
|
173
|
-
]);
|
|
174
|
-
|
|
175
175
|
// True when the home machine's load or needs rule this start out, which is
|
|
176
176
|
// the only time a linked machine is worth asking.
|
|
177
177
|
export const wantsPlacing = (
|
|
@@ -181,7 +181,7 @@ export const wantsPlacing = (
|
|
|
181
181
|
): boolean => {
|
|
182
182
|
if (c.remote || home.reading) return false;
|
|
183
183
|
const hold = machineHold(c, home, now);
|
|
184
|
-
return !!hold &&
|
|
184
|
+
return !!hold && placeable(hold);
|
|
185
185
|
};
|
|
186
186
|
|
|
187
187
|
// Null: start it at home (or hold it there for a reason placing doesn't
|
package/lib/tasks/plan-view.ts
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
type TaskPlan,
|
|
18
18
|
} from "./loop-types";
|
|
19
19
|
import { expandHome } from "./session";
|
|
20
|
+
import { isRemoteHost } from "../hosts";
|
|
21
|
+
import { remotePlanPath } from "./remote-plan-path";
|
|
20
22
|
|
|
21
23
|
const TITLE_MAX = 120;
|
|
22
24
|
|
|
@@ -131,7 +133,10 @@ export function taskPlan(session: Session): TaskPlan | null {
|
|
|
131
133
|
};
|
|
132
134
|
|
|
133
135
|
if (phase && session.worktree_path) {
|
|
134
|
-
|
|
136
|
+
// A plan written on a linked machine is read from the copy kept here.
|
|
137
|
+
const file = isRemoteHost(session.host_id)
|
|
138
|
+
? remotePlanPath(session.id)
|
|
139
|
+
: path.join(expandHome(session.worktree_path), PLAN_FILE);
|
|
135
140
|
const text = readHead(file, PLAN_TEXT_MAX);
|
|
136
141
|
if (text !== null || phase === "planning") {
|
|
137
142
|
const body = text ?? "";
|
|
@@ -141,13 +146,16 @@ export function taskPlan(session: Session): TaskPlan | null {
|
|
|
141
146
|
status:
|
|
142
147
|
phase === "planning"
|
|
143
148
|
? "writing"
|
|
144
|
-
: phase === "awaiting"
|
|
149
|
+
: phase === "awaiting" || phase === "handoff"
|
|
145
150
|
? "ready"
|
|
146
151
|
: "approved",
|
|
147
152
|
source: "step",
|
|
148
153
|
title: planTitle(body),
|
|
149
154
|
path: text !== null ? PLAN_FILE : null,
|
|
150
|
-
pieces:
|
|
155
|
+
pieces:
|
|
156
|
+
phase === "awaiting" || phase === "handoff"
|
|
157
|
+
? piecesView(session, file)
|
|
158
|
+
: null,
|
|
151
159
|
...capped(body),
|
|
152
160
|
};
|
|
153
161
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building the candidates a look at the line scores: a fresh or retried start
|
|
3
|
+
* from a queue row, and a paused task that came due to resume. Each carries
|
|
4
|
+
* the holds that need the database (`after`, Pause, the usage window); the
|
|
5
|
+
* machine's holds are applied when the line is planned.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getProject } from "../projects";
|
|
9
|
+
import { isRemoteHost } from "../hosts";
|
|
10
|
+
import { machineCapabilities } from "../load/machines";
|
|
11
|
+
import {
|
|
12
|
+
claimResume,
|
|
13
|
+
resumeHoldFor,
|
|
14
|
+
setPauseNote,
|
|
15
|
+
type DueResume,
|
|
16
|
+
} from "./pause";
|
|
17
|
+
import {
|
|
18
|
+
heavyNeeds,
|
|
19
|
+
meetsNeeds,
|
|
20
|
+
needsOf,
|
|
21
|
+
parseNeeds,
|
|
22
|
+
type Need,
|
|
23
|
+
} from "./placement";
|
|
24
|
+
import { DEFAULT_PRIORITY, isPriority } from "./schedule-types";
|
|
25
|
+
import type { Candidate, Group } from "./schedule";
|
|
26
|
+
import { expandHome } from "./session";
|
|
27
|
+
import { afterHold, autoHold } from "./hold-checks";
|
|
28
|
+
import {
|
|
29
|
+
groupFor,
|
|
30
|
+
groupKey,
|
|
31
|
+
sessionOf,
|
|
32
|
+
sqliteMs,
|
|
33
|
+
stillWorking,
|
|
34
|
+
workspaceOf,
|
|
35
|
+
} from "./queue-rows";
|
|
36
|
+
import type { QueueRowWithWorkspace } from "./queue-types";
|
|
37
|
+
|
|
38
|
+
export function candidateOf(
|
|
39
|
+
r: QueueRowWithWorkspace,
|
|
40
|
+
used: string[]
|
|
41
|
+
): { c: Candidate; needs: Need[] } {
|
|
42
|
+
const project = getProject(r.project_id);
|
|
43
|
+
const hostId = r.host_id ?? project?.host_id;
|
|
44
|
+
const remote = isRemoteHost(hostId);
|
|
45
|
+
const needs = remote
|
|
46
|
+
? []
|
|
47
|
+
: (parseNeeds(r.machine_needs) ??
|
|
48
|
+
(project
|
|
49
|
+
? needsOf({
|
|
50
|
+
projectId: project.id,
|
|
51
|
+
projectPath: expandHome(project.working_directory),
|
|
52
|
+
prompt: r.prompt,
|
|
53
|
+
}).needs
|
|
54
|
+
: []));
|
|
55
|
+
let any: Candidate["any"] = null;
|
|
56
|
+
if (r.after_any) {
|
|
57
|
+
const ids = JSON.parse(r.after_any) as string[];
|
|
58
|
+
any = {
|
|
59
|
+
ids,
|
|
60
|
+
finished: ids.filter((id) => !stillWorking(id)),
|
|
61
|
+
used: used.filter((id) => id !== r.after_any_used),
|
|
62
|
+
released: !!r.after_any_used,
|
|
63
|
+
label:
|
|
64
|
+
ids
|
|
65
|
+
.filter(stillWorking)
|
|
66
|
+
.map((id) => sessionOf(id)?.name ?? id.slice(0, 8))
|
|
67
|
+
.join(", ") || "its set",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
needs,
|
|
72
|
+
c: {
|
|
73
|
+
id: r.id,
|
|
74
|
+
kind: "start",
|
|
75
|
+
group: groupKey(r.workspace_id, r.project_id),
|
|
76
|
+
priority: isPriority(r.priority) ? r.priority : DEFAULT_PRIORITY,
|
|
77
|
+
position: r.position,
|
|
78
|
+
waitingSinceMs: sqliteMs(r.created_at),
|
|
79
|
+
heavy: heavyNeeds(needs),
|
|
80
|
+
needsUnmet: !remote && !meetsNeeds(needs, machineCapabilities()),
|
|
81
|
+
remote,
|
|
82
|
+
planOnly: !!r.plan_only,
|
|
83
|
+
before: afterHold(r),
|
|
84
|
+
auto: autoHold(r.workspace_id, !!r.plan_only),
|
|
85
|
+
any,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A paused task that came due, as a candidate to score against new arrivals.
|
|
91
|
+
export function resumeCandidate(d: DueResume): Candidate {
|
|
92
|
+
const workspaceId = workspaceOf(d.projectId);
|
|
93
|
+
return {
|
|
94
|
+
id: `resume:${d.sessionId}`,
|
|
95
|
+
kind: "resume",
|
|
96
|
+
group: groupKey(workspaceId, d.projectId),
|
|
97
|
+
priority: d.priority,
|
|
98
|
+
position: -1_000_000 + d.pauseId,
|
|
99
|
+
waitingSinceMs: d.dueAtMs,
|
|
100
|
+
heavy: false,
|
|
101
|
+
needsUnmet: false,
|
|
102
|
+
remote: false,
|
|
103
|
+
before: null,
|
|
104
|
+
// A person's timed resume goes ahead of an orchestrator Pause.
|
|
105
|
+
auto: d.byKind === "person" ? null : autoHold(workspaceId),
|
|
106
|
+
any: null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A deliberate resume starts past the slot limit; only a red machine holds
|
|
111
|
+
// it, and the task says so.
|
|
112
|
+
export async function forceDueResumes(due: DueResume[]): Promise<void> {
|
|
113
|
+
for (const d of due.filter((x) => x.resumeBy)) {
|
|
114
|
+
const s = sessionOf(d.sessionId);
|
|
115
|
+
const why = s ? resumeHoldFor(s, d.resumeBy!) : null;
|
|
116
|
+
if (why) setPauseNote(d.sessionId, `held: ${why}`);
|
|
117
|
+
else await claimResume(d.sessionId, { reason: d.resumeBy!, force: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Each line's running count and limit, for the lines the candidates are in.
|
|
122
|
+
export function buildGroups(
|
|
123
|
+
candidates: Candidate[],
|
|
124
|
+
rows: QueueRowWithWorkspace[],
|
|
125
|
+
resumes: DueResume[]
|
|
126
|
+
): Map<string, Group> {
|
|
127
|
+
const groups = new Map<string, Group>();
|
|
128
|
+
for (const c of candidates) {
|
|
129
|
+
if (groups.has(c.group)) continue;
|
|
130
|
+
const r = rows.find(
|
|
131
|
+
(x) => groupKey(x.workspace_id, x.project_id) === c.group
|
|
132
|
+
);
|
|
133
|
+
const projectId =
|
|
134
|
+
r?.project_id ??
|
|
135
|
+
resumes.find((d) => `resume:${d.sessionId}` === c.id)!.projectId;
|
|
136
|
+
const workspaceId = r?.workspace_id ?? workspaceOf(projectId);
|
|
137
|
+
groups.set(
|
|
138
|
+
c.group,
|
|
139
|
+
groupFor(
|
|
140
|
+
workspaceId,
|
|
141
|
+
projectId,
|
|
142
|
+
rows.filter(
|
|
143
|
+
(x) =>
|
|
144
|
+
x.status === "starting" &&
|
|
145
|
+
groupKey(x.workspace_id, x.project_id) === c.group
|
|
146
|
+
).length
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return groups;
|
|
151
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placing starts on linked machines: which machines could take which task,
|
|
3
|
+
* then the best machine for each start this one's load rules out, best score
|
|
4
|
+
* first, one per machine per look. The rules are pure, in placement.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getProject } from "../projects";
|
|
8
|
+
import { hostLink, linkedHostIds } from "../hosts/remote-api";
|
|
9
|
+
import { peerReading, peerSource, refreshPeers } from "../hosts/peer-load";
|
|
10
|
+
import { projectMachines } from "../hosts/project-machines";
|
|
11
|
+
import { viewOf } from "../load/machines";
|
|
12
|
+
import { usesGitCrypt } from "../worktree-add";
|
|
13
|
+
import { hostTasks } from "./remote";
|
|
14
|
+
import {
|
|
15
|
+
asPlanPhase,
|
|
16
|
+
place,
|
|
17
|
+
unplaceable,
|
|
18
|
+
wantsPlacing,
|
|
19
|
+
type Need,
|
|
20
|
+
type PeerOption,
|
|
21
|
+
type Placement,
|
|
22
|
+
} from "./placement";
|
|
23
|
+
import { scoreParts, type Candidate, type MachineView } from "./schedule";
|
|
24
|
+
import { expandHome } from "./session";
|
|
25
|
+
import { orchestratorOf } from "./starter";
|
|
26
|
+
import { lineSignature, row } from "./queue-rows";
|
|
27
|
+
import type { QueueRow, QueueRowWithWorkspace } from "./queue-types";
|
|
28
|
+
|
|
29
|
+
// A plan placed elsewhere may have to come back here to be built, and a
|
|
30
|
+
// git-crypt repository never leaves this machine (the key stays here).
|
|
31
|
+
function placementBarred(r: Pick<QueueRow, "project_id">): boolean {
|
|
32
|
+
const project = getProject(r.project_id);
|
|
33
|
+
return !project || usesGitCrypt(expandHome(project.working_directory));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The linked machines a task could be placed on, with what they report.
|
|
37
|
+
async function peerOptions(
|
|
38
|
+
rows: QueueRowWithWorkspace[],
|
|
39
|
+
now: number
|
|
40
|
+
): Promise<Map<string, PeerOption[]>> {
|
|
41
|
+
const out = new Map<string, PeerOption[]>();
|
|
42
|
+
const hostIds = [...linkedHostIds()];
|
|
43
|
+
if (!hostIds.length || !rows.length) return out;
|
|
44
|
+
await refreshPeers(hostIds, now);
|
|
45
|
+
// Whether each machine can hold an orchestrator's task (an older AgentOS
|
|
46
|
+
// can't, and would fail its start); unreadable counts as no.
|
|
47
|
+
const holds = new Map<string, boolean>();
|
|
48
|
+
const plans = new Map<string, boolean>();
|
|
49
|
+
await Promise.all(
|
|
50
|
+
hostIds.map(async (hostId) => {
|
|
51
|
+
const link = hostLink(hostId);
|
|
52
|
+
const caps = link
|
|
53
|
+
? await hostTasks(link)
|
|
54
|
+
.then((t) => t.capabilities ?? [])
|
|
55
|
+
.catch(() => [])
|
|
56
|
+
: [];
|
|
57
|
+
holds.set(hostId, caps.includes("held-start"));
|
|
58
|
+
plans.set(hostId, caps.includes("remote-plan"));
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
const perProject = new Map<string, Record<string, string | null>>();
|
|
62
|
+
for (const r of rows) {
|
|
63
|
+
if (out.has(r.project_id)) continue;
|
|
64
|
+
const project = getProject(r.project_id);
|
|
65
|
+
let can: Record<string, string | null> = {};
|
|
66
|
+
if (project) {
|
|
67
|
+
can =
|
|
68
|
+
perProject.get(project.id) ??
|
|
69
|
+
(await projectMachines(project).catch(() => ({})));
|
|
70
|
+
perProject.set(project.id, can);
|
|
71
|
+
}
|
|
72
|
+
out.set(
|
|
73
|
+
r.project_id,
|
|
74
|
+
hostIds.map((hostId) => {
|
|
75
|
+
const src = peerSource(hostId, now);
|
|
76
|
+
const reading = peerReading(hostId);
|
|
77
|
+
return {
|
|
78
|
+
hostId,
|
|
79
|
+
name: src.label,
|
|
80
|
+
view: viewOf(src, now),
|
|
81
|
+
capabilities: src.capabilities,
|
|
82
|
+
holds: holds.get(hostId) ?? false,
|
|
83
|
+
plans: plans.get(hostId) ?? false,
|
|
84
|
+
hasProject: can[hostId] === null,
|
|
85
|
+
loadPerCore: reading ? reading.load1 / reading.cores : 99,
|
|
86
|
+
};
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A start that plans first goes by its plan phase: it needs no Mac or
|
|
94
|
+
// simulator, and a plan-only one that no machine can take starts here
|
|
95
|
+
// as ever (it never waits on load).
|
|
96
|
+
const plansFirst = (r: QueueRow) => !!(r.plan || r.plan_only);
|
|
97
|
+
|
|
98
|
+
// Marks each start the home machine's load rules out as remote and returns
|
|
99
|
+
// where it goes. "stale": the line moved while the machines were asked, so
|
|
100
|
+
// this look is over, and the one a change during it asks for sees the line
|
|
101
|
+
// as it is now.
|
|
102
|
+
export async function placeCandidates(
|
|
103
|
+
candidates: Candidate[],
|
|
104
|
+
rows: QueueRowWithWorkspace[],
|
|
105
|
+
needsOf: Map<string, Need[]>,
|
|
106
|
+
home: MachineView,
|
|
107
|
+
now: number
|
|
108
|
+
): Promise<Map<string, Placement> | "stale"> {
|
|
109
|
+
const placements = new Map<string, Placement>();
|
|
110
|
+
const asked = (c: Candidate) => {
|
|
111
|
+
const r = row(c.id)!;
|
|
112
|
+
return plansFirst(r) ? asPlanPhase(c) : c;
|
|
113
|
+
};
|
|
114
|
+
const toPlace = candidates.filter((c) => {
|
|
115
|
+
if (c.kind !== "start" || c.remote) return false;
|
|
116
|
+
const r = row(c.id)!;
|
|
117
|
+
if (unplaceable(r)) return false;
|
|
118
|
+
if (plansFirst(r) && placementBarred(r)) return false;
|
|
119
|
+
return wantsPlacing(asked(c), home, now);
|
|
120
|
+
});
|
|
121
|
+
if (!toPlace.length) return placements;
|
|
122
|
+
const before = lineSignature();
|
|
123
|
+
const peers = await peerOptions(
|
|
124
|
+
toPlace.map((c) => rows.find((r) => r.id === c.id)!),
|
|
125
|
+
now
|
|
126
|
+
);
|
|
127
|
+
if (lineSignature() !== before) return "stale";
|
|
128
|
+
const taken = new Set<string>();
|
|
129
|
+
for (const c of [...toPlace].sort(
|
|
130
|
+
(a, b) =>
|
|
131
|
+
scoreParts(b, now).score - scoreParts(a, now).score ||
|
|
132
|
+
a.position - b.position
|
|
133
|
+
)) {
|
|
134
|
+
const r = rows.find((x) => x.id === c.id)!;
|
|
135
|
+
const plan = plansFirst(r);
|
|
136
|
+
const options = (peers.get(r.project_id) ?? []).filter(
|
|
137
|
+
(p) =>
|
|
138
|
+
!taken.has(p.hostId) &&
|
|
139
|
+
(!orchestratorOf(r.started_by) || p.holds !== false) &&
|
|
140
|
+
(!plan || p.plans)
|
|
141
|
+
);
|
|
142
|
+
const spot = place(
|
|
143
|
+
asked(c),
|
|
144
|
+
home,
|
|
145
|
+
options,
|
|
146
|
+
plan ? [] : (needsOf.get(c.id) ?? []),
|
|
147
|
+
now
|
|
148
|
+
);
|
|
149
|
+
if (!spot) continue;
|
|
150
|
+
taken.add(spot.hostId);
|
|
151
|
+
placements.set(c.id, spot);
|
|
152
|
+
c.remote = true;
|
|
153
|
+
}
|
|
154
|
+
return placements;
|
|
155
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line's rows and slot counts: the small reads and writes everything else
|
|
3
|
+
* in the queue builds on.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { db, type Session } from "../db";
|
|
7
|
+
import { getProject } from "../projects";
|
|
8
|
+
import { getWorkspace } from "../workspaces";
|
|
9
|
+
import { queuedLabel } from "./queue-held";
|
|
10
|
+
import type { Group } from "./schedule";
|
|
11
|
+
import type { QueueRow } from "./queue-types";
|
|
12
|
+
|
|
13
|
+
export const IN_FLIGHT = ["running", "moving"];
|
|
14
|
+
|
|
15
|
+
export const row = (id: string) =>
|
|
16
|
+
db.prepare(`SELECT * FROM task_queue WHERE id = ?`).get(id) as
|
|
17
|
+
| QueueRow
|
|
18
|
+
| undefined;
|
|
19
|
+
|
|
20
|
+
export const sessionOf = (id: string) =>
|
|
21
|
+
db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
|
|
22
|
+
| Session
|
|
23
|
+
| undefined;
|
|
24
|
+
|
|
25
|
+
export function patch(id: string, fields: Partial<QueueRow>): void {
|
|
26
|
+
const keys = Object.keys(fields);
|
|
27
|
+
if (!keys.length) return;
|
|
28
|
+
db.prepare(
|
|
29
|
+
`UPDATE task_queue SET ${keys.map((k) => `${k} = @${k}`).join(", ")} WHERE id = @id`
|
|
30
|
+
).run({ ...fields, id });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const workspaceOf = (projectId: string) =>
|
|
34
|
+
getProject(projectId)?.workspace_id ?? null;
|
|
35
|
+
|
|
36
|
+
// Direct starts under way, by workspace: their task rows don't exist until
|
|
37
|
+
// createTask gets that far, so they hold their slot here meanwhile.
|
|
38
|
+
// On globalThis: the custom server's watcher and the route bundles each load
|
|
39
|
+
// their own copy of this module, and must see one set of reservations.
|
|
40
|
+
const g = globalThis as unknown as {
|
|
41
|
+
__agentosQueueReserved?: Map<string, number>;
|
|
42
|
+
};
|
|
43
|
+
export const reserved = (g.__agentosQueueReserved ??= new Map<
|
|
44
|
+
string,
|
|
45
|
+
number
|
|
46
|
+
>());
|
|
47
|
+
|
|
48
|
+
// Running tasks hold their slot until they finish (merged, dropped, done),
|
|
49
|
+
// PR open or not; a queued task being started counts too. A paused task
|
|
50
|
+
// gives its slot back until it resumes. A quick change
|
|
51
|
+
// runs no agent, so it holds none. Without a
|
|
52
|
+
// workspace, the project is the scope.
|
|
53
|
+
export function runningIn(
|
|
54
|
+
scope: { workspaceId: string | null; projectId: string },
|
|
55
|
+
forSlots = false
|
|
56
|
+
) {
|
|
57
|
+
const where = scope.workspaceId
|
|
58
|
+
? `p.workspace_id = @ws`
|
|
59
|
+
: `p.id = @project AND p.workspace_id IS NULL`;
|
|
60
|
+
return (
|
|
61
|
+
db
|
|
62
|
+
.prepare(
|
|
63
|
+
`SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
64
|
+
WHERE ${where} AND s.archived_at IS NULL AND s.quick_of IS NULL
|
|
65
|
+
AND (s.pause_state IS NULL OR s.pause_state != 'paused')
|
|
66
|
+
${forSlots ? `AND NOT (COALESCE(s.plan_only, 0) = 1 AND COALESCE(s.plan_phase, '') != 'implementing')` : ""}
|
|
67
|
+
AND s.task_status IN (${IN_FLIGHT.map((s) => `'${s}'`).join(", ")})`
|
|
68
|
+
)
|
|
69
|
+
.all({ ws: scope.workspaceId, project: scope.projectId }) as {
|
|
70
|
+
id: string;
|
|
71
|
+
}[]
|
|
72
|
+
).map((r) => r.id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function startingIn(workspaceId: string): number {
|
|
76
|
+
return (
|
|
77
|
+
(reserved.get(workspaceId) ?? 0) +
|
|
78
|
+
(
|
|
79
|
+
db
|
|
80
|
+
.prepare(
|
|
81
|
+
`SELECT COUNT(*) AS n FROM task_queue q JOIN projects p ON p.id = q.project_id
|
|
82
|
+
WHERE q.status = 'starting' AND p.workspace_id = ?
|
|
83
|
+
AND q.plan_only IS NULL`
|
|
84
|
+
)
|
|
85
|
+
.get(workspaceId) as { n: number }
|
|
86
|
+
).n
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const limitOf = (workspaceId: string | null) =>
|
|
91
|
+
workspaceId ? (getWorkspace(workspaceId)?.max_running_tasks ?? null) : null;
|
|
92
|
+
|
|
93
|
+
// Finished, as far as a wait on it goes: merged, dropped, done or handed to
|
|
94
|
+
// another machine, or a queued task that was removed or failed to start.
|
|
95
|
+
export function hasFinished(taskId: string): boolean {
|
|
96
|
+
const s = sessionOf(taskId);
|
|
97
|
+
if (s) return !!s.archived_at || !IN_FLIGHT.includes(s.task_status ?? "");
|
|
98
|
+
const q = row(taskId);
|
|
99
|
+
return !q || (q.status !== "queued" && q.status !== "starting");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Still something to wait for under "any": in flight, and not a task whose
|
|
103
|
+
// PR was closed or merged and which nobody finished (that one never will).
|
|
104
|
+
export function stillWorking(taskId: string): boolean {
|
|
105
|
+
if (hasFinished(taskId)) return false;
|
|
106
|
+
const s = sessionOf(taskId);
|
|
107
|
+
return !s || (s.pr_status !== "closed" && s.pr_status !== "merged");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function afterMet(r: QueueRow): boolean {
|
|
111
|
+
if (r.after_task && !hasFinished(r.after_task)) return false;
|
|
112
|
+
if (r.after_any && !r.after_any_used) {
|
|
113
|
+
const ids = JSON.parse(r.after_any) as string[];
|
|
114
|
+
if (ids.length && !ids.some((id) => !stillWorking(id))) return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A task named by id, id prefix or name, running or queued; in one
|
|
120
|
+
// workspace when given (the orchestrator's).
|
|
121
|
+
export function resolveAfter(
|
|
122
|
+
ref: string,
|
|
123
|
+
workspaceId: string | null
|
|
124
|
+
): { id: string; name: string } {
|
|
125
|
+
const want = ref.trim();
|
|
126
|
+
const lower = want.toLowerCase();
|
|
127
|
+
const scope = workspaceId ? `AND p.workspace_id = @ws` : "";
|
|
128
|
+
const candidates = db
|
|
129
|
+
.prepare(
|
|
130
|
+
`SELECT s.id, s.name FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
131
|
+
WHERE s.archived_at IS NULL AND s.task_status IN ('running', 'moving') ${scope}
|
|
132
|
+
UNION ALL
|
|
133
|
+
SELECT q.id, COALESCE(q.name, q.prompt) AS name FROM task_queue q
|
|
134
|
+
JOIN projects p ON p.id = q.project_id
|
|
135
|
+
WHERE q.status IN ('queued', 'starting') ${scope}`
|
|
136
|
+
)
|
|
137
|
+
.all({ ws: workspaceId }) as { id: string; name: string }[];
|
|
138
|
+
const exact = candidates.filter(
|
|
139
|
+
(c) => c.id === want || c.name.toLowerCase() === lower
|
|
140
|
+
);
|
|
141
|
+
const hits = exact.length
|
|
142
|
+
? exact
|
|
143
|
+
: want.length >= 4
|
|
144
|
+
? candidates.filter((c) => c.id.startsWith(want))
|
|
145
|
+
: [];
|
|
146
|
+
if (hits.length === 1) return hits[0];
|
|
147
|
+
if (hits.length > 1)
|
|
148
|
+
throw new Error(
|
|
149
|
+
`"${ref}" matches ${hits.length} tasks: ${hits.map((h) => `${h.name} (${h.id.slice(0, 8)})`).join(", ")}`
|
|
150
|
+
);
|
|
151
|
+
throw new Error(`No running or queued task matches "${ref}"`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// How full the workspace's running-task limit is for this project.
|
|
155
|
+
export function slotState(projectId: string): {
|
|
156
|
+
running: number;
|
|
157
|
+
limit: number | null;
|
|
158
|
+
full: boolean;
|
|
159
|
+
} {
|
|
160
|
+
const workspaceId = workspaceOf(projectId);
|
|
161
|
+
const limit = limitOf(workspaceId);
|
|
162
|
+
if (!workspaceId || limit === null)
|
|
163
|
+
return { running: 0, limit: null, full: false };
|
|
164
|
+
const running =
|
|
165
|
+
runningIn({ workspaceId, projectId }, true).length +
|
|
166
|
+
startingIn(workspaceId);
|
|
167
|
+
return { running, limit, full: running >= limit };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const waitingIn = (workspaceId: string) =>
|
|
171
|
+
(
|
|
172
|
+
db
|
|
173
|
+
.prepare(
|
|
174
|
+
`SELECT COUNT(*) AS n FROM task_queue q JOIN projects p ON p.id = q.project_id
|
|
175
|
+
WHERE q.status = 'queued' AND p.workspace_id = ?
|
|
176
|
+
AND q.after_task IS NULL AND q.after_any IS NULL
|
|
177
|
+
AND q.plan_only IS NULL`
|
|
178
|
+
)
|
|
179
|
+
.get(workspaceId) as { n: number }
|
|
180
|
+
).n;
|
|
181
|
+
|
|
182
|
+
export const label = queuedLabel;
|
|
183
|
+
|
|
184
|
+
export function afterLabel(r: QueueRow): string | null {
|
|
185
|
+
if (r.after_task) {
|
|
186
|
+
const s = sessionOf(r.after_task);
|
|
187
|
+
const q = s ? null : row(r.after_task);
|
|
188
|
+
return s?.name ?? (q ? label(q) : r.after_task.slice(0, 8));
|
|
189
|
+
}
|
|
190
|
+
return r.after_any ? "any running task" : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// SQLite's datetime('now') is UTC without a zone.
|
|
194
|
+
export const sqliteMs = (v: string) =>
|
|
195
|
+
Date.parse(v.includes("T") ? v : `${v.replace(" ", "T")}Z`) || 0;
|
|
196
|
+
|
|
197
|
+
export const groupKey = (workspaceId: string | null, projectId: string) =>
|
|
198
|
+
workspaceId ?? `project:${projectId}`;
|
|
199
|
+
|
|
200
|
+
export function groupFor(
|
|
201
|
+
workspaceId: string | null,
|
|
202
|
+
projectId: string,
|
|
203
|
+
startingRows: number
|
|
204
|
+
): Group {
|
|
205
|
+
const scope = { workspaceId, projectId };
|
|
206
|
+
return {
|
|
207
|
+
inFlight:
|
|
208
|
+
runningIn(scope, true).length +
|
|
209
|
+
(workspaceId ? (reserved.get(workspaceId) ?? 0) : 0) +
|
|
210
|
+
startingRows,
|
|
211
|
+
limit: limitOf(workspaceId) ?? Number.POSITIVE_INFINITY,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Finishes other waiters have taken, so one finish lets one waiter go.
|
|
216
|
+
export const usedFinishes = (): string[] =>
|
|
217
|
+
(
|
|
218
|
+
db
|
|
219
|
+
.prepare(
|
|
220
|
+
`SELECT after_any_used AS id FROM task_queue WHERE after_any_used IS NOT NULL`
|
|
221
|
+
)
|
|
222
|
+
.all() as { id: string }[]
|
|
223
|
+
).map((x) => x.id);
|
|
224
|
+
|
|
225
|
+
export function lineSignature(): string {
|
|
226
|
+
const r = db
|
|
227
|
+
.prepare(
|
|
228
|
+
`SELECT COUNT(*) AS n, COALESCE(MAX(position), 0) AS p,
|
|
229
|
+
COALESCE(SUM(priority = 'normal'), 0) AS pr,
|
|
230
|
+
(SELECT COUNT(*) FROM sessions WHERE task_status IS NOT NULL
|
|
231
|
+
AND task_status NOT IN ('merged', 'dropped')) AS running
|
|
232
|
+
FROM task_queue WHERE status IN ('queued', 'starting')`
|
|
233
|
+
)
|
|
234
|
+
.get() as { n: number; p: number; pr: number; running: number };
|
|
235
|
+
return `${r.n}:${r.p}:${r.pr}:${r.running}`;
|
|
236
|
+
}
|