@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/held.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { orchestratorOf } from "./starter";
|
|
9
9
|
import os from "os";
|
|
10
10
|
import { db, type Session } from "../db";
|
|
11
|
+
import { isRemoteHost } from "../hosts";
|
|
11
12
|
import type { OrchestratedBundle } from "./move-bundle";
|
|
12
13
|
|
|
13
14
|
export function holderRefusal(
|
|
@@ -133,3 +134,25 @@ export function holdExisting(
|
|
|
133
134
|
holdFor(id, device, held);
|
|
134
135
|
return true;
|
|
135
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A plan another machine placed here is read and approved by that machine
|
|
140
|
+
* only: it decides where the task is built. Null for any other task.
|
|
141
|
+
*/
|
|
142
|
+
export function planWaitRefusal(
|
|
143
|
+
id: string,
|
|
144
|
+
device: string | null | undefined
|
|
145
|
+
): string | null {
|
|
146
|
+
const s = db
|
|
147
|
+
.prepare(
|
|
148
|
+
`SELECT plan_waits_for_caller, started_by_device, host_id FROM sessions WHERE id = ?`
|
|
149
|
+
)
|
|
150
|
+
.get(id) as
|
|
151
|
+
| Pick<Session, "plan_waits_for_caller" | "started_by_device" | "host_id">
|
|
152
|
+
| undefined;
|
|
153
|
+
if (!s?.plan_waits_for_caller) return null;
|
|
154
|
+
// The placing machine's own mirror of the plan: its people decide it.
|
|
155
|
+
if (isRemoteHost(s.host_id)) return null;
|
|
156
|
+
if (device && device === s.started_by_device) return null;
|
|
157
|
+
return "The machine that placed this plan decides it; approve it from there";
|
|
158
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The holds that read the database or this machine, each returning `Hold`s
|
|
3
|
+
* (holds.ts) so every caller that asks "may this start now" gets the same
|
|
4
|
+
* answer and the same words: the orchestrator's Pause and the usage window,
|
|
5
|
+
* `after`, and this machine's load for a start here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Session } from "../db";
|
|
9
|
+
import { getProject } from "../projects";
|
|
10
|
+
import { isRemoteHost } from "../hosts";
|
|
11
|
+
import { isPaused } from "../orchestrator/pause";
|
|
12
|
+
import { brakesEnabled } from "../orchestrator/brakes";
|
|
13
|
+
import { readUsage, windowRefusal } from "../orchestrator/usage";
|
|
14
|
+
import { localView, machineCapabilities } from "../load/machines";
|
|
15
|
+
import { DEFAULT_PRIORITY, type Priority } from "./schedule-types";
|
|
16
|
+
import { machineHolds, holdReason, type Hold } from "./holds";
|
|
17
|
+
import { heavyNeeds, meetsNeeds, needsOf, type Need } from "./placement";
|
|
18
|
+
import { expandHome } from "./session";
|
|
19
|
+
import { afterLabel, hasFinished } from "./queue-rows";
|
|
20
|
+
import type { QueueRow } from "./queue-types";
|
|
21
|
+
import type { PauseBy } from "./schedule-types";
|
|
22
|
+
|
|
23
|
+
// Why an automatic start has to wait: the orchestrator's Pause, then the
|
|
24
|
+
// usage window. A start now by a person isn't held, and a plan-only start
|
|
25
|
+
// is held by Pause alone.
|
|
26
|
+
export function autoHolds(
|
|
27
|
+
workspaceId: string | null,
|
|
28
|
+
planOnly = false
|
|
29
|
+
): Hold[] {
|
|
30
|
+
const out: Hold[] = [];
|
|
31
|
+
if (workspaceId && isPaused(workspaceId))
|
|
32
|
+
out.push({
|
|
33
|
+
code: "orch_paused",
|
|
34
|
+
text: "Waiting: the orchestrator is paused",
|
|
35
|
+
});
|
|
36
|
+
if (planOnly) return out;
|
|
37
|
+
const window = brakesEnabled() ? windowRefusal(readUsage()) : null;
|
|
38
|
+
if (window) out.push({ code: "window", text: `Waiting: ${window}` });
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const autoHold = (
|
|
43
|
+
workspaceId: string | null,
|
|
44
|
+
planOnly = false
|
|
45
|
+
): Hold | null => autoHolds(workspaceId, planOnly)[0] ?? null;
|
|
46
|
+
|
|
47
|
+
// `after X`: the task it waits on hasn't finished.
|
|
48
|
+
export function afterHold(r: QueueRow): Hold | null {
|
|
49
|
+
if (!r.after_task || hasFinished(r.after_task)) return null;
|
|
50
|
+
return {
|
|
51
|
+
code: "after",
|
|
52
|
+
text: `After ${afterLabel({ ...r, after_any: null }) ?? "its task"}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// What this machine would say to a start of a task with these needs here
|
|
57
|
+
// and now: empty means it may start.
|
|
58
|
+
export function localHolds(
|
|
59
|
+
ask: { needs: Need[]; priority?: Priority },
|
|
60
|
+
now = Date.now()
|
|
61
|
+
): Hold[] {
|
|
62
|
+
return machineHolds(
|
|
63
|
+
{
|
|
64
|
+
heavy: heavyNeeds(ask.needs),
|
|
65
|
+
needsUnmet: !meetsNeeds(ask.needs, machineCapabilities()),
|
|
66
|
+
remote: false,
|
|
67
|
+
priority: ask.priority ?? DEFAULT_PRIORITY,
|
|
68
|
+
},
|
|
69
|
+
localView(now),
|
|
70
|
+
now
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const requestNeeds = (
|
|
75
|
+
projectId: string,
|
|
76
|
+
workingDirectory: string,
|
|
77
|
+
prompt: string,
|
|
78
|
+
setting: Need[] | null
|
|
79
|
+
): Need[] =>
|
|
80
|
+
setting ??
|
|
81
|
+
needsOf({
|
|
82
|
+
projectId,
|
|
83
|
+
projectPath: expandHome(workingDirectory),
|
|
84
|
+
prompt,
|
|
85
|
+
}).needs;
|
|
86
|
+
|
|
87
|
+
// Whether the task needs something this machine lacks (a Mac).
|
|
88
|
+
export function needsUnmetHere(
|
|
89
|
+
projectId: string,
|
|
90
|
+
prompt: string,
|
|
91
|
+
setting: Need[] | null
|
|
92
|
+
): boolean {
|
|
93
|
+
const project = getProject(projectId);
|
|
94
|
+
if (!project || isRemoteHost(project.host_id)) return false;
|
|
95
|
+
return !meetsNeeds(
|
|
96
|
+
requestNeeds(projectId, project.working_directory, prompt, setting),
|
|
97
|
+
machineCapabilities()
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Whether this machine's load rules out starting a task for this project now.
|
|
102
|
+
export function loadHeldStart(
|
|
103
|
+
projectId: string,
|
|
104
|
+
prompt: string,
|
|
105
|
+
setting: Need[] | null
|
|
106
|
+
): boolean {
|
|
107
|
+
const project = getProject(projectId);
|
|
108
|
+
if (!project || isRemoteHost(project.host_id)) return false;
|
|
109
|
+
const head = localHolds({
|
|
110
|
+
needs: requestNeeds(projectId, project.working_directory, prompt, setting),
|
|
111
|
+
})[0];
|
|
112
|
+
return head?.code === "load_red" || head?.code === "heavy";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Why this machine's load rules out starting an agent now, or null. Only a
|
|
116
|
+
// red (or still settling) machine holds a resume; amber doesn't.
|
|
117
|
+
export function loadHoldFor(s: Session): string | null {
|
|
118
|
+
const project = s.project_id ? getProject(s.project_id) : null;
|
|
119
|
+
if (!project || isRemoteHost(s.host_id ?? project.host_id)) return null;
|
|
120
|
+
const hold = localHolds({ needs: [] })[0];
|
|
121
|
+
return hold?.code === "load_red" ? holdReason(hold) : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Why a resume this actor asked for must wait: this machine's load, and for
|
|
125
|
+
// the orchestrator also what holds its own starts (it is paused, or the
|
|
126
|
+
// usage window). A person's own resume skips the second.
|
|
127
|
+
export function resumeHoldFor(s: Session, actor: PauseBy): string | null {
|
|
128
|
+
const load = loadHoldFor(s);
|
|
129
|
+
if (load) return load;
|
|
130
|
+
if (actor !== "orchestrator") return null;
|
|
131
|
+
const ws = s.project_id ? getProject(s.project_id)?.workspace_id : null;
|
|
132
|
+
const hold = autoHold(ws ?? null);
|
|
133
|
+
return hold ? holdReason(hold) : null;
|
|
134
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every reason a task waits, as one type and one ordered list. A hold is
|
|
3
|
+
* pure data: `holdsFor` turns a candidate and what we know of its machine
|
|
4
|
+
* into the holds that apply, always in HOLD_ORDER, and the first is the
|
|
5
|
+
* reason shown. The queue view, a queued row's note, a paused task's resume
|
|
6
|
+
* note and the start decision all read that list, so "why is this waiting"
|
|
7
|
+
* has one source. No database and no clock of its own: the checks that need
|
|
8
|
+
* either are in hold-checks.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_PRIORITY,
|
|
13
|
+
type Priority,
|
|
14
|
+
type WaitCode,
|
|
15
|
+
} from "./schedule-types";
|
|
16
|
+
import type { Candidate } from "./schedule";
|
|
17
|
+
|
|
18
|
+
// Out of red this long before automatic starts resume.
|
|
19
|
+
export const LOAD_SETTLE_MS = 120_000;
|
|
20
|
+
// On amber, only this priority and above start.
|
|
21
|
+
export const AMBER_MIN_PRIORITY: Priority = "blocking";
|
|
22
|
+
// Between automatic starts on one machine.
|
|
23
|
+
export const START_GAP_MS = 60_000;
|
|
24
|
+
// Between starts of tasks that boot a simulator.
|
|
25
|
+
export const HEAVY_START_GAP_MS = 5 * 60_000;
|
|
26
|
+
|
|
27
|
+
export const PRIORITY_POINTS: Record<Priority, number> = {
|
|
28
|
+
urgent: 400,
|
|
29
|
+
blocking: 300,
|
|
30
|
+
normal: 200,
|
|
31
|
+
background: 100,
|
|
32
|
+
};
|
|
33
|
+
const priorityRank = (p: Priority) => PRIORITY_POINTS[p] ?? 0;
|
|
34
|
+
|
|
35
|
+
export type MachineLevel = "green" | "amber" | "red";
|
|
36
|
+
|
|
37
|
+
// What the machine a candidate would start on looks like right now.
|
|
38
|
+
export interface MachineView {
|
|
39
|
+
// Shown in hold texts, e.g. "this machine".
|
|
40
|
+
label: string;
|
|
41
|
+
// False: no load holds at all (AGENTOS_LOAD=off).
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
// No sample read yet since AgentOS started.
|
|
44
|
+
reading: boolean;
|
|
45
|
+
level: MachineLevel;
|
|
46
|
+
// Not red, and calm for LOAD_SETTLE_MS since it last was.
|
|
47
|
+
settled: boolean;
|
|
48
|
+
// Seconds of calm still needed, when not settled and not red.
|
|
49
|
+
settleLeftMs: number;
|
|
50
|
+
booted: number;
|
|
51
|
+
maxBooted: number;
|
|
52
|
+
// Heavy tasks started within HEAVY_START_GAP_MS that hold no lease yet.
|
|
53
|
+
heavyStarting: number;
|
|
54
|
+
lastAutoStartAt: number | null;
|
|
55
|
+
lastHeavyStartAt: number | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type MachineCode =
|
|
59
|
+
| "needs"
|
|
60
|
+
| "load_red"
|
|
61
|
+
| "load_amber"
|
|
62
|
+
| "heavy"
|
|
63
|
+
| "spacing";
|
|
64
|
+
|
|
65
|
+
// A reason a task isn't starting. `code` is also what a queue row stores and
|
|
66
|
+
// the client reads. A hold that rests on a machine says which one, and when
|
|
67
|
+
// it knows, the time (ms) it lifts.
|
|
68
|
+
export type Hold =
|
|
69
|
+
| { code: "after"; text: string }
|
|
70
|
+
| { code: "after_any"; text: string }
|
|
71
|
+
| { code: "orch_paused"; text: string }
|
|
72
|
+
| { code: "window"; text: string }
|
|
73
|
+
| {
|
|
74
|
+
code: MachineCode;
|
|
75
|
+
text: string;
|
|
76
|
+
machine: string;
|
|
77
|
+
until?: number;
|
|
78
|
+
// Why it waits here instead of starting on a linked machine.
|
|
79
|
+
stay?: string;
|
|
80
|
+
}
|
|
81
|
+
| { code: "slot"; text: string };
|
|
82
|
+
|
|
83
|
+
// The order holds are reported in; the first that applies is the reason.
|
|
84
|
+
export const HOLD_ORDER: readonly WaitCode[] = [
|
|
85
|
+
"after",
|
|
86
|
+
"after_any",
|
|
87
|
+
"orch_paused",
|
|
88
|
+
"window",
|
|
89
|
+
"needs",
|
|
90
|
+
"load_red",
|
|
91
|
+
"load_amber",
|
|
92
|
+
"heavy",
|
|
93
|
+
"spacing",
|
|
94
|
+
"slot",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
// Whether the hold lifts without anyone acting. A Pause is lifted by a
|
|
98
|
+
// person, and a task that needs a Mac never gets one by waiting.
|
|
99
|
+
const CLEARS_BY_ITSELF: Record<WaitCode, boolean> = {
|
|
100
|
+
after: true,
|
|
101
|
+
after_any: true,
|
|
102
|
+
orch_paused: false,
|
|
103
|
+
window: true,
|
|
104
|
+
needs: false,
|
|
105
|
+
load_red: true,
|
|
106
|
+
load_amber: true,
|
|
107
|
+
heavy: true,
|
|
108
|
+
spacing: true,
|
|
109
|
+
slot: true,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const clearsByItself = (h: Hold): boolean => CLEARS_BY_ITSELF[h.code];
|
|
113
|
+
|
|
114
|
+
// The holds a linked machine could lift by taking the start.
|
|
115
|
+
const PLACEABLE = new Set<WaitCode>([
|
|
116
|
+
"load_red",
|
|
117
|
+
"load_amber",
|
|
118
|
+
"heavy",
|
|
119
|
+
"needs",
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
export const placeable = (h: Pick<Hold, "code">): boolean =>
|
|
123
|
+
PLACEABLE.has(h.code);
|
|
124
|
+
|
|
125
|
+
// The text a person sees: the hold, and for a task that can't go elsewhere,
|
|
126
|
+
// why it stays here.
|
|
127
|
+
export function holdText(h: Hold): string {
|
|
128
|
+
return "stay" in h && h.stay ? `${h.text} (${h.stay})` : h.text;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The same without the "Waiting: " lead, for a note that has its own.
|
|
132
|
+
export const holdReason = (h: Hold): string =>
|
|
133
|
+
holdText(h).replace(/^Waiting: /, "");
|
|
134
|
+
|
|
135
|
+
// What a row stores of its first hold: null code and text when none.
|
|
136
|
+
export function queueNote(holds: readonly Hold[] | undefined): {
|
|
137
|
+
code: WaitCode | null;
|
|
138
|
+
text: string | null;
|
|
139
|
+
} | null {
|
|
140
|
+
if (!holds) return null;
|
|
141
|
+
const head = holds[0];
|
|
142
|
+
return head
|
|
143
|
+
? { code: head.code, text: holdText(head) }
|
|
144
|
+
: { code: null, text: null };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// What a timed resume waiting for its turn says on its task.
|
|
148
|
+
export function resumeNote(
|
|
149
|
+
holds: readonly Hold[] | undefined,
|
|
150
|
+
slots: { running: number; limit: number | null }
|
|
151
|
+
): string | null {
|
|
152
|
+
if (!holds) return null;
|
|
153
|
+
const head = holds[0];
|
|
154
|
+
if (head && head.code !== "slot") return `held: ${holdReason(head)}`;
|
|
155
|
+
return slots.limit === null
|
|
156
|
+
? "resuming: waiting for its turn"
|
|
157
|
+
: `resuming: waiting for a slot, ${slots.running} of ${slots.limit} running`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const seconds = (ms: number) => Math.max(1, Math.ceil(ms / 1000));
|
|
161
|
+
|
|
162
|
+
export function slotText(rank: number, of: number, priority: Priority): string {
|
|
163
|
+
const word = priority === DEFAULT_PRIORITY ? "" : ` · ${priority}`;
|
|
164
|
+
return `#${rank} of ${of}${word}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
type MachineAsk = Pick<
|
|
168
|
+
Candidate,
|
|
169
|
+
"heavy" | "needsUnmet" | "remote" | "priority"
|
|
170
|
+
> &
|
|
171
|
+
Partial<Pick<Candidate, "planOnly">>;
|
|
172
|
+
|
|
173
|
+
// The holds the machine puts on a start, in order. Empty: it may start here
|
|
174
|
+
// now. A machine with no reading yet says only that; one with load checks
|
|
175
|
+
// off says only what it lacks.
|
|
176
|
+
export function machineHolds(
|
|
177
|
+
c: MachineAsk,
|
|
178
|
+
m: MachineView,
|
|
179
|
+
now: number
|
|
180
|
+
): Hold[] {
|
|
181
|
+
if (c.remote || c.planOnly) return [];
|
|
182
|
+
const out: Hold[] = [];
|
|
183
|
+
const add = (code: MachineCode, text: string, until?: number): void => {
|
|
184
|
+
out.push({
|
|
185
|
+
code,
|
|
186
|
+
text: `Waiting: ${text}`,
|
|
187
|
+
machine: m.label,
|
|
188
|
+
...(until === undefined ? {} : { until }),
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
if (c.needsUnmet) add("needs", "needs a Mac");
|
|
192
|
+
if (!m.enabled) return out;
|
|
193
|
+
if (m.reading) {
|
|
194
|
+
add("load_red", "reading machine load");
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
if (m.level === "red") add("load_red", `${m.label} load red`);
|
|
198
|
+
else if (!m.settled)
|
|
199
|
+
add(
|
|
200
|
+
"load_red",
|
|
201
|
+
`${m.label} load was red; calm for ${seconds(m.settleLeftMs)}s more`,
|
|
202
|
+
now + m.settleLeftMs
|
|
203
|
+
);
|
|
204
|
+
else if (
|
|
205
|
+
m.level === "amber" &&
|
|
206
|
+
priorityRank(c.priority) < priorityRank(AMBER_MIN_PRIORITY)
|
|
207
|
+
)
|
|
208
|
+
add(
|
|
209
|
+
"load_amber",
|
|
210
|
+
`${m.label} load amber; ${c.priority} work starts on green`
|
|
211
|
+
);
|
|
212
|
+
if (c.heavy && m.level !== "red") {
|
|
213
|
+
if (m.level !== "green")
|
|
214
|
+
add("heavy", `${m.label} load amber; simulator work starts on green`);
|
|
215
|
+
else if (m.heavyStarting > 0)
|
|
216
|
+
add("heavy", `a simulator is booting on ${m.label}`);
|
|
217
|
+
else if (m.booted >= m.maxBooted)
|
|
218
|
+
add("heavy", `${m.booted} of ${m.maxBooted} simulators in use`);
|
|
219
|
+
else if (
|
|
220
|
+
m.lastHeavyStartAt !== null &&
|
|
221
|
+
now - m.lastHeavyStartAt < HEAVY_START_GAP_MS
|
|
222
|
+
)
|
|
223
|
+
add(
|
|
224
|
+
"heavy",
|
|
225
|
+
`a simulator task just started on ${m.label} (next in ${seconds(HEAVY_START_GAP_MS - (now - m.lastHeavyStartAt))}s)`,
|
|
226
|
+
m.lastHeavyStartAt + HEAVY_START_GAP_MS
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (m.lastAutoStartAt !== null && now - m.lastAutoStartAt < START_GAP_MS)
|
|
230
|
+
add(
|
|
231
|
+
"spacing",
|
|
232
|
+
`spacing starts (next in ${seconds(START_GAP_MS - (now - m.lastAutoStartAt))}s)`,
|
|
233
|
+
m.lastAutoStartAt + START_GAP_MS
|
|
234
|
+
);
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The first machine hold, or null: the one every placement check asks.
|
|
239
|
+
export const machineHold = (
|
|
240
|
+
c: MachineAsk,
|
|
241
|
+
m: MachineView,
|
|
242
|
+
now: number
|
|
243
|
+
): Hold | null => machineHolds(c, m, now)[0] ?? null;
|
|
244
|
+
|
|
245
|
+
export interface HoldContext {
|
|
246
|
+
machine: MachineView;
|
|
247
|
+
now: number;
|
|
248
|
+
// Finishes other waiters have already taken this look.
|
|
249
|
+
used: ReadonlySet<string>;
|
|
250
|
+
// Running tasks in its line, and the line's limit.
|
|
251
|
+
inFlight: number;
|
|
252
|
+
limit: number;
|
|
253
|
+
// Its place in the line's score order.
|
|
254
|
+
rank: number;
|
|
255
|
+
of: number;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Every hold on a candidate, in HOLD_ORDER. The first is why it waits; none:
|
|
259
|
+
// it starts.
|
|
260
|
+
export function holdsFor(c: Candidate, ctx: HoldContext): Hold[] {
|
|
261
|
+
const out: Hold[] = [];
|
|
262
|
+
if (c.before) out.push(c.before);
|
|
263
|
+
if (c.any && !c.any.released) {
|
|
264
|
+
const free = c.any.finished.some((id) => !ctx.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
|
+
out.push({
|
|
269
|
+
code: "after_any",
|
|
270
|
+
text: `Waiting: after any running task (${c.any.label})`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (c.auto) out.push(...([] as Hold[]).concat(c.auto));
|
|
274
|
+
out.push(...machineHolds(c, ctx.machine, ctx.now));
|
|
275
|
+
if (!c.planOnly && ctx.inFlight >= ctx.limit)
|
|
276
|
+
out.push({
|
|
277
|
+
code: "slot",
|
|
278
|
+
text: slotText(ctx.rank, ctx.of, c.priority),
|
|
279
|
+
});
|
|
280
|
+
return out;
|
|
281
|
+
}
|
package/lib/tasks/import.ts
CHANGED
|
@@ -118,9 +118,10 @@ async function arrive(
|
|
|
118
118
|
};
|
|
119
119
|
stepProgress(bundle.moveId, "conversation");
|
|
120
120
|
// The plan is never in git: it comes in the bundle.
|
|
121
|
-
|
|
121
|
+
const planFile = bundle.planWait?.file ?? orch?.planFile ?? null;
|
|
122
|
+
if (planFile != null) {
|
|
122
123
|
preparePlanDir(worktreePath);
|
|
123
|
-
fs.writeFileSync(path.join(worktreePath, PLAN_FILE),
|
|
124
|
+
fs.writeFileSync(path.join(worktreePath, PLAN_FILE), planFile);
|
|
124
125
|
}
|
|
125
126
|
if (claude && paths) {
|
|
126
127
|
await writeTranscript(
|
|
@@ -169,6 +170,10 @@ async function arrive(
|
|
|
169
170
|
claude_session_id = ?, moved_from = ? WHERE id = ?`
|
|
170
171
|
).run(bundle.prompt, brief, claude?.sessionId ?? null, bundle.moveId, id);
|
|
171
172
|
if (bundle.chat) unpackChat(id, bundle.chat, paths);
|
|
173
|
+
if (bundle.planWait)
|
|
174
|
+
db.prepare(
|
|
175
|
+
`UPDATE sessions SET plan_phase = 'awaiting' WHERE id = ?`
|
|
176
|
+
).run(id);
|
|
172
177
|
if (orch) {
|
|
173
178
|
db.prepare(
|
|
174
179
|
`UPDATE sessions SET plan_phase = ?, task_since = ? WHERE id = ?`
|
|
@@ -205,7 +210,10 @@ async function arrive(
|
|
|
205
210
|
}
|
|
206
211
|
const note = arrivalNote(bundle, worktreePath, os.hostname());
|
|
207
212
|
const prompt = claude ? note : `${bundle.prompt}\n\n${note}`;
|
|
208
|
-
if (bundle.
|
|
213
|
+
if (bundle.planWait) {
|
|
214
|
+
// Waiting on its plan's approval: nothing runs until the message that
|
|
215
|
+
// implements it, which resumes the carried conversation.
|
|
216
|
+
} else if (bundle.chat) {
|
|
209
217
|
// Its worker starts on the carried conversation, takes the note
|
|
210
218
|
// first, then what was queued.
|
|
211
219
|
await sendChat(id, {
|
package/lib/tasks/index.ts
CHANGED
|
@@ -173,6 +173,9 @@ export async function createTask(opts: {
|
|
|
173
173
|
held?: { device: string; orchestrated: OrchestratedBundle };
|
|
174
174
|
// The paired device that sent this keyed start: the only one that may hold it later.
|
|
175
175
|
startedByDevice?: string | null;
|
|
176
|
+
// Another machine placed this plan here and decides what follows it: the
|
|
177
|
+
// task plans, then waits for that machine (implies planOnly).
|
|
178
|
+
planWaits?: boolean;
|
|
176
179
|
}): Promise<Session> {
|
|
177
180
|
if (opts.id) {
|
|
178
181
|
const existing = queries.getSession(db).get(opts.id) as Session | undefined;
|
|
@@ -244,12 +247,27 @@ async function startTask(
|
|
|
244
247
|
throw new Error(`${agentLabel(agent)} tasks run on this machine for now`);
|
|
245
248
|
}
|
|
246
249
|
if (isRemoteHost(hostId)) {
|
|
247
|
-
if (opts.planOnly)
|
|
248
|
-
throw new Error("A plan-only task runs on this machine for now");
|
|
249
250
|
if (opts.base || opts.cardId)
|
|
250
251
|
throw new Error(
|
|
251
252
|
"Stacked and card tasks run on this machine only for now"
|
|
252
253
|
);
|
|
254
|
+
if (opts.planOnly && opts.plan === false)
|
|
255
|
+
throw new Error(
|
|
256
|
+
"planOnly implies plan; it can't be combined with plan false"
|
|
257
|
+
);
|
|
258
|
+
if (opts.planOnly && view !== "chat")
|
|
259
|
+
throw new Error("A plan-only task needs a chat, not a terminal");
|
|
260
|
+
// The plan step is this machine's to run and decide: it plans there,
|
|
261
|
+
// stops, and what follows is chosen here (remote-plan.ts).
|
|
262
|
+
const planned =
|
|
263
|
+
!!(opts.plan || opts.planOnly) &&
|
|
264
|
+
taskRouting(
|
|
265
|
+
project.workspace_id,
|
|
266
|
+
view,
|
|
267
|
+
opts.model,
|
|
268
|
+
opts.plan,
|
|
269
|
+
opts.planOnly
|
|
270
|
+
).plan;
|
|
253
271
|
const session = await startRemoteTask(hostId, project, {
|
|
254
272
|
id,
|
|
255
273
|
prompt,
|
|
@@ -258,6 +276,9 @@ async function startTask(
|
|
|
258
276
|
baseBranch: opts.baseBranch,
|
|
259
277
|
view,
|
|
260
278
|
startedBy: opts.startedBy,
|
|
279
|
+
plan: planned
|
|
280
|
+
? { only: !!opts.planOnly, reason: opts.planReason?.trim() || null }
|
|
281
|
+
: undefined,
|
|
261
282
|
});
|
|
262
283
|
// The mirror's started_by stays orchestrator-only: a held-remote task is
|
|
263
284
|
// relayed, stopped and answered through it. A plain calling task is its
|
|
@@ -347,7 +368,7 @@ async function startTask(
|
|
|
347
368
|
db.prepare(
|
|
348
369
|
`UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
|
|
349
370
|
model_provider = ?, model_overrides = ?, plan_phase = ?, plan_only = ?,
|
|
350
|
-
plan_decided = ?, plan_reason = ? WHERE id = ?`
|
|
371
|
+
plan_decided = ?, plan_reason = ?, plan_waits_for_caller = ? WHERE id = ?`
|
|
351
372
|
).run(
|
|
352
373
|
prompt,
|
|
353
374
|
buildTaskBrief({
|
|
@@ -366,6 +387,7 @@ async function startTask(
|
|
|
366
387
|
routing.planOnly ? 1 : null,
|
|
367
388
|
routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
|
|
368
389
|
opts.planReason?.trim() || null,
|
|
390
|
+
routing.planOnly && opts.planWaits ? 1 : null,
|
|
369
391
|
id
|
|
370
392
|
);
|
|
371
393
|
recordStart(id, {
|
package/lib/tasks/move-bundle.ts
CHANGED
|
@@ -57,6 +57,10 @@ export interface TaskBundle {
|
|
|
57
57
|
// What a task started by an orchestrator carries besides its work; absent
|
|
58
58
|
// for any other task, or from a machine that predates these moves.
|
|
59
59
|
orchestrated?: OrchestratedBundle;
|
|
60
|
+
// A plan written and waiting for the machine that placed it (it is moving
|
|
61
|
+
// there to be built): the plan file, which is never in git. The arriving
|
|
62
|
+
// machine runs no turn; the approval that follows starts one.
|
|
63
|
+
planWait?: { file: string | null };
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
export interface OrchestratedBundle {
|
|
@@ -70,7 +74,7 @@ export interface OrchestratedBundle {
|
|
|
70
74
|
// machine without that provider refuses the task rather than change it.
|
|
71
75
|
modelProvider: string | null;
|
|
72
76
|
modelOverrides: string | null;
|
|
73
|
-
planPhase: "planning" | "implementing" | null;
|
|
77
|
+
planPhase: "planning" | "awaiting" | "implementing" | null;
|
|
74
78
|
// .agentos/plan.md, which is never in git.
|
|
75
79
|
planFile: string | null;
|
|
76
80
|
// The orchestrator that holds the task (its id and name on the machine
|
|
@@ -153,7 +157,7 @@ function validateChat(c: ChatBundle): ChatBundle {
|
|
|
153
157
|
return { ...c, plan: !!c.plan };
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
const PHASES = ["planning", "implementing"];
|
|
160
|
+
const PHASES = ["planning", "awaiting", "implementing"];
|
|
157
161
|
|
|
158
162
|
export function validateOrchestrated(
|
|
159
163
|
o: OrchestratedBundle
|
|
@@ -196,6 +200,16 @@ export function validateOrchestrated(
|
|
|
196
200
|
};
|
|
197
201
|
}
|
|
198
202
|
|
|
203
|
+
function validatePlanWait(w: unknown): TaskBundle["planWait"] {
|
|
204
|
+
if (w === undefined || w === null) return undefined;
|
|
205
|
+
const file = (w as { file?: unknown }).file;
|
|
206
|
+
if (typeof w !== "object" || !(file === null || isStr(file)))
|
|
207
|
+
throw new Error("Bad plan");
|
|
208
|
+
if ((file?.length ?? 0) > MAX_PLAN_BYTES)
|
|
209
|
+
throw new Error("The plan is too large to move");
|
|
210
|
+
return { file };
|
|
211
|
+
}
|
|
212
|
+
|
|
199
213
|
export function validateBundle(b: TaskBundle): TaskBundle {
|
|
200
214
|
if (!b || typeof b !== "object") throw new Error("No task to import");
|
|
201
215
|
if (!isSessionId(b.moveId)) throw new Error("Bad move id");
|
|
@@ -228,6 +242,7 @@ export function validateBundle(b: TaskBundle): TaskBundle {
|
|
|
228
242
|
orchestrated: b.orchestrated
|
|
229
243
|
? validateOrchestrated(b.orchestrated)
|
|
230
244
|
: undefined,
|
|
245
|
+
planWait: validatePlanWait(b.planWait),
|
|
231
246
|
chat: b.chat ? validateChat(b.chat) : undefined,
|
|
232
247
|
name: b.name.slice(0, 200),
|
|
233
248
|
from: String(b.from || "another machine")
|
package/lib/tasks/move-guard.ts
CHANGED
|
@@ -29,7 +29,15 @@ export function moveRefusal(session: Session): string | null {
|
|
|
29
29
|
return "Its repo is encrypted with git-crypt and the key never leaves this machine, so it stays here";
|
|
30
30
|
if (escalations(session.id).length)
|
|
31
31
|
return "It's waiting on you (an escalated gate), so it stays here";
|
|
32
|
-
if (
|
|
32
|
+
if (
|
|
33
|
+
session.plan_only &&
|
|
34
|
+
session.plan_phase !== "implementing" &&
|
|
35
|
+
// A plan another machine placed here moves to it once written.
|
|
36
|
+
!(
|
|
37
|
+
session.plan_waits_for_caller &&
|
|
38
|
+
(session.plan_phase === "awaiting" || session.plan_phase === "handoff")
|
|
39
|
+
)
|
|
40
|
+
)
|
|
33
41
|
return "It's a plan-only task that hasn't been approved, so it stays here";
|
|
34
42
|
const ask = db
|
|
35
43
|
.prepare(
|
package/lib/tasks/move.ts
CHANGED
|
@@ -140,10 +140,7 @@ function orchestratedBundle(session: Session): OrchestratedBundle {
|
|
|
140
140
|
modelProvider: session.model_provider ?? null,
|
|
141
141
|
modelOverrides: session.model_overrides ?? null,
|
|
142
142
|
// moveRefusal keeps an unapproved plan-only task here.
|
|
143
|
-
planPhase: (session.plan_phase ?? null) as
|
|
144
|
-
| "planning"
|
|
145
|
-
| "implementing"
|
|
146
|
-
| null,
|
|
143
|
+
planPhase: (session.plan_phase ?? null) as OrchestratedBundle["planPhase"],
|
|
147
144
|
planFile: readPlanFile(session.working_directory),
|
|
148
145
|
orchestrator: orchestratorOf(session),
|
|
149
146
|
};
|
|
@@ -215,6 +212,10 @@ export async function exportTask(id: string, to: string): Promise<TaskBundle> {
|
|
|
215
212
|
: null,
|
|
216
213
|
chat: chatBundle,
|
|
217
214
|
orchestrated: isOrchestrated(now) ? orchestratedBundle(now) : undefined,
|
|
215
|
+
planWait:
|
|
216
|
+
now.plan_waits_for_caller && now.plan_phase === "awaiting"
|
|
217
|
+
? { file: readPlanFile(cwd) }
|
|
218
|
+
: undefined,
|
|
218
219
|
};
|
|
219
220
|
} finally {
|
|
220
221
|
packed();
|
package/lib/tasks/pause.ts
CHANGED
|
@@ -34,9 +34,7 @@ import { openBySubject, workSubject } from "../orchestrator/asks";
|
|
|
34
34
|
import { queueEventOnce } from "../orchestrator/events";
|
|
35
35
|
import { isPaused } from "../orchestrator/pause";
|
|
36
36
|
import { isMerging } from "./finish";
|
|
37
|
-
import {
|
|
38
|
-
import { localView } from "../load/machines";
|
|
39
|
-
import { machineHold } from "./schedule";
|
|
37
|
+
import { loadHoldFor, resumeHoldFor } from "./hold-checks";
|
|
40
38
|
import {
|
|
41
39
|
continuePrompt,
|
|
42
40
|
durationText,
|
|
@@ -395,34 +393,7 @@ export function requestResume(sessionId: string, actor: PauseBy): void {
|
|
|
395
393
|
);
|
|
396
394
|
}
|
|
397
395
|
|
|
398
|
-
|
|
399
|
-
// red (or still settling) machine holds a resume; amber doesn't.
|
|
400
|
-
export function loadHoldFor(s: Session): string | null {
|
|
401
|
-
const project = s.project_id ? getProject(s.project_id) : null;
|
|
402
|
-
if (!project || isRemoteHost(s.host_id ?? project.host_id)) return null;
|
|
403
|
-
const hold = machineHold(
|
|
404
|
-
{
|
|
405
|
-
heavy: false,
|
|
406
|
-
needsUnmet: false,
|
|
407
|
-
remote: false,
|
|
408
|
-
priority: DEFAULT_PRIORITY,
|
|
409
|
-
},
|
|
410
|
-
localView(),
|
|
411
|
-
Date.now()
|
|
412
|
-
);
|
|
413
|
-
return hold?.code === "load_red" ? hold.text.replace(/^Waiting: /, "") : null;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Why a resume this actor asked for must wait: this machine's load, and for
|
|
417
|
-
// the orchestrator also what holds its own starts (it is paused, or the
|
|
418
|
-
// usage window). Saad's own resume skips the second.
|
|
419
|
-
export function resumeHoldFor(s: Session, actor: PauseBy): string | null {
|
|
420
|
-
const load = loadHoldFor(s);
|
|
421
|
-
if (load) return load;
|
|
422
|
-
if (actor !== "orchestrator") return null;
|
|
423
|
-
const ws = s.project_id ? getProject(s.project_id)?.workspace_id : null;
|
|
424
|
-
return autoHold(ws ?? null)?.text.replace(/^Waiting: /, "") ?? null;
|
|
425
|
-
}
|
|
396
|
+
export { loadHoldFor, resumeHoldFor };
|
|
426
397
|
|
|
427
398
|
export function setPauseNote(sessionId: string, note: string | null): void {
|
|
428
399
|
db.prepare(
|