@saadnvd1/agent-os 0.3.631 → 0.3.639
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/sessions/[id]/pr/route.ts +3 -3
- package/app/api/sessions/[id]/setup/route.ts +3 -3
- package/app/api/tasks/[id]/approve-plan/route.ts +5 -1
- package/app/api/tasks/[id]/arm/route.ts +6 -5
- package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
- package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
- package/app/api/tasks/[id]/checks/route.ts +1 -1
- package/app/api/tasks/[id]/diff/route.ts +1 -1
- package/app/api/tasks/[id]/done/route.ts +2 -2
- package/app/api/tasks/[id]/drop/route.ts +6 -2
- package/app/api/tasks/[id]/export/route.ts +3 -4
- package/app/api/tasks/[id]/merge/route.ts +3 -3
- package/app/api/tasks/[id]/message/route.ts +2 -2
- package/app/api/tasks/[id]/model/route.ts +5 -1
- package/app/api/tasks/[id]/move/route.ts +5 -1
- package/app/api/tasks/[id]/moved/route.ts +6 -8
- package/app/api/tasks/[id]/plan/route.ts +35 -0
- package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
- package/app/api/tasks/[id]/priority/route.ts +4 -1
- package/app/api/tasks/[id]/resume/route.ts +3 -4
- package/app/api/tasks/[id]/stop/route.ts +2 -2
- package/app/api/tasks/route.ts +5 -2
- 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/away/report.ts +4 -4
- package/lib/away/run.ts +2 -2
- package/lib/bookmarks/store.ts +3 -3
- package/lib/bus/held-reply.ts +2 -2
- package/lib/bus/index.ts +3 -3
- package/lib/chat/compact-context.ts +1 -1
- package/lib/chat/events.ts +4 -0
- package/lib/chat/group.ts +3 -1
- package/lib/chat/hold.ts +3 -3
- package/lib/chat/reading-order.ts +50 -0
- package/lib/chat/refresh.ts +1 -1
- package/lib/chat/registry.ts +3 -3
- package/lib/chat/runner.ts +1 -1
- package/lib/chat/turns.ts +22 -3
- package/lib/chat/worker/host.ts +3 -0
- package/lib/chat/worker/main.ts +1 -1
- package/lib/db/index.ts +5 -1
- package/lib/db/lumifyhub.ts +8 -9
- package/lib/db/machine-scope.ts +12 -0
- package/lib/db/migrations/20261015T1600_plan_waits_for_caller.ts +19 -0
- package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
- package/lib/db/migrations/README.md +7 -0
- package/lib/db/queries.ts +16 -31
- package/lib/db/session-parts/columns.ts +186 -0
- package/lib/db/session-parts/fill.ts +33 -0
- package/lib/db/session-parts/index.ts +9 -0
- package/lib/db/session-parts/pauses.ts +11 -0
- package/lib/db/session-parts/placement.ts +11 -0
- package/lib/db/session-parts/plans.ts +31 -0
- package/lib/db/session-parts/prs.ts +31 -0
- package/lib/db/session-parts/tasks.ts +27 -0
- package/lib/db/session-parts/view.ts +43 -0
- package/lib/db/session-parts/write.ts +69 -0
- package/lib/db/types.ts +6 -1
- package/lib/done/archive.ts +2 -2
- package/lib/done/auto-archive.ts +1 -1
- package/lib/done/bulk.ts +1 -1
- package/lib/done/index.ts +8 -14
- package/lib/env-setup.ts +17 -4
- package/lib/health/evidence.ts +1 -1
- package/lib/health/facts.ts +1 -1
- package/lib/health/pieces.ts +1 -1
- package/lib/hosts/peer-actions.ts +2 -2
- package/lib/hosts/peer-mirror.ts +2 -2
- package/lib/hosts/peer-sync.ts +69 -32
- package/lib/learning-cards/input.ts +1 -1
- package/lib/learning-cards/store.ts +1 -1
- package/lib/models/plan-step.ts +83 -46
- package/lib/models/resume.ts +5 -7
- package/lib/notify/index.ts +1 -1
- package/lib/notify/push/events.ts +1 -1
- package/lib/notify/push/index.ts +1 -1
- package/lib/orchestrator/act.ts +1 -1
- package/lib/orchestrator/armed.ts +17 -15
- package/lib/orchestrator/ask-approvals.ts +1 -1
- package/lib/orchestrator/ask-heads.ts +1 -1
- package/lib/orchestrator/ask-settle.ts +1 -1
- package/lib/orchestrator/brakes.ts +1 -1
- package/lib/orchestrator/digest.ts +2 -2
- package/lib/orchestrator/external-pr.ts +2 -2
- package/lib/orchestrator/facts.ts +4 -28
- package/lib/orchestrator/held-asks.ts +3 -3
- package/lib/orchestrator/home.ts +2 -2
- package/lib/orchestrator/overview.ts +1 -1
- package/lib/orchestrator/pr-wake.ts +3 -5
- package/lib/orchestrator/questions.ts +1 -1
- package/lib/orchestrator/quick-store.ts +1 -1
- package/lib/orchestrator/quick.ts +10 -7
- package/lib/orchestrator/read.ts +1 -1
- package/lib/orchestrator/rebase.ts +8 -11
- package/lib/orchestrator/reconcile.ts +2 -2
- package/lib/orchestrator/signoff.ts +3 -1
- package/lib/orchestrator/targets.ts +1 -1
- package/lib/orchestrator/task-state.ts +4 -2
- package/lib/plans/features.ts +1 -1
- package/lib/plans/release.ts +1 -1
- package/lib/ports.ts +1 -1
- package/lib/resurrect/find.ts +1 -1
- package/lib/resurrect/index.ts +24 -18
- package/lib/schedules/store.ts +1 -1
- package/lib/session-audit/sources.ts +3 -3
- package/lib/session-logs/index.ts +1 -1
- package/lib/session-titles.ts +1 -1
- package/lib/sessions/setup-progress.ts +1 -1
- package/lib/sessions/stop.ts +1 -1
- package/lib/sessions/worktree-setup.ts +2 -1
- package/lib/simulator/index.ts +1 -1
- package/lib/simulator/task-devices.ts +1 -1
- package/lib/stacks/board.ts +1 -1
- package/lib/stacks/land.ts +1 -1
- package/lib/stacks/restack.ts +1 -1
- package/lib/stacks/start.ts +2 -2
- package/lib/stacks/tick.ts +1 -1
- package/lib/stacks/view.ts +1 -1
- package/lib/status/incremental.ts +1 -1
- package/lib/tasks/contract/actions.ts +35 -0
- package/lib/tasks/contract/authorize.ts +82 -0
- package/lib/tasks/contract/client.ts +296 -0
- package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
- package/lib/tasks/contract/index.ts +28 -0
- package/lib/tasks/contract/plan-path.ts +7 -0
- package/lib/tasks/contract/plan.ts +274 -0
- package/lib/tasks/contract/routes.ts +26 -0
- package/lib/tasks/contract/start.ts +122 -0
- package/lib/tasks/contract/sync.ts +126 -0
- package/lib/tasks/contract/types.ts +41 -0
- package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
- package/lib/tasks/finish.ts +10 -9
- package/lib/tasks/held-replies.ts +7 -13
- package/lib/tasks/held.ts +36 -17
- package/lib/tasks/hold-checks.ts +134 -0
- package/lib/tasks/holds.ts +281 -0
- package/lib/tasks/import.ts +26 -14
- package/lib/tasks/index.ts +56 -22
- package/lib/tasks/launch-gate.ts +1 -1
- package/lib/tasks/move-bundle.ts +17 -2
- package/lib/tasks/move-carry.ts +15 -7
- package/lib/tasks/move-flow.ts +29 -36
- package/lib/tasks/move-guard.ts +10 -2
- package/lib/tasks/move-recover.ts +6 -25
- package/lib/tasks/move.ts +28 -27
- package/lib/tasks/moved-away.ts +1 -1
- package/lib/tasks/pause-hold.ts +13 -5
- package/lib/tasks/pause.ts +21 -54
- 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 +213 -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 +67 -1102
- package/lib/tasks/schedule.ts +44 -147
- package/lib/tasks/session.ts +7 -4
- package/lib/tasks/start.ts +4 -4
- package/lib/tasks/starter.ts +8 -11
- package/lib/usage/calls.ts +1 -1
- package/lib/usage/compare.ts +1 -1
- package/lib/usage/quality.ts +1 -1
- package/lib/usage/turns.ts +1 -1
- package/lib/voice/vocab.ts +1 -1
- package/package.json +1 -1
- package/scripts/backfill-session-logs.ts +1 -1
- package/scripts/check +55 -7
- package/lib/tasks/auto-hold.ts +0 -17
- package/lib/tasks/held-routes.ts +0 -33
- package/lib/tasks/remote.ts +0 -238
|
@@ -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 { list as hostTasks } from "./contract/client";
|
|
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 session_rows 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 session_rows 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 session_rows 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 session_rows 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
|
+
}
|