@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.
Files changed (45) hide show
  1. package/app/api/tasks/[id]/approve-plan/route.ts +6 -1
  2. package/app/api/tasks/[id]/export/route.ts +3 -2
  3. package/app/api/tasks/[id]/plan/route.ts +37 -0
  4. package/app/api/tasks/route.ts +4 -1
  5. package/app/globals.css +28 -0
  6. package/components/Chat/ChatPanel.tsx +2 -11
  7. package/components/Chat/EventTurns.tsx +1 -7
  8. package/components/Chat/Markdown.tsx +1 -0
  9. package/components/Chat/Messages.tsx +1 -1
  10. package/lib/approvals/enable.ts +9 -2
  11. package/lib/chat/events.ts +4 -0
  12. package/lib/chat/group.ts +3 -1
  13. package/lib/chat/reading-order.ts +50 -0
  14. package/lib/chat/turns.ts +22 -3
  15. package/lib/chat/worker/host.ts +3 -0
  16. package/lib/db/migrations/20261015T1600_plan_waits_for_caller.ts +19 -0
  17. package/lib/db/types.ts +6 -1
  18. package/lib/env-setup.ts +17 -4
  19. package/lib/models/plan-step.ts +75 -24
  20. package/lib/tasks/held.ts +23 -0
  21. package/lib/tasks/hold-checks.ts +134 -0
  22. package/lib/tasks/holds.ts +281 -0
  23. package/lib/tasks/import.ts +11 -3
  24. package/lib/tasks/index.ts +25 -3
  25. package/lib/tasks/move-bundle.ts +17 -2
  26. package/lib/tasks/move-guard.ts +9 -1
  27. package/lib/tasks/move.ts +5 -4
  28. package/lib/tasks/pause.ts +2 -31
  29. package/lib/tasks/placement.ts +16 -16
  30. package/lib/tasks/plan-view.ts +11 -3
  31. package/lib/tasks/queue-candidates.ts +151 -0
  32. package/lib/tasks/queue-placement.ts +155 -0
  33. package/lib/tasks/queue-rows.ts +236 -0
  34. package/lib/tasks/queue-start.ts +211 -0
  35. package/lib/tasks/queue-tick.ts +148 -0
  36. package/lib/tasks/queue-types.ts +141 -0
  37. package/lib/tasks/queue-view.ts +112 -0
  38. package/lib/tasks/queue.ts +53 -1096
  39. package/lib/tasks/remote-plan-path.ts +7 -0
  40. package/lib/tasks/remote-plan.ts +308 -0
  41. package/lib/tasks/remote.ts +18 -1
  42. package/lib/tasks/schedule.ts +44 -147
  43. package/package.json +1 -1
  44. package/scripts/check +55 -7
  45. 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
+ }
@@ -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
- if (orch?.planFile != null) {
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), orch.planFile);
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.chat) {
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, {
@@ -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, {
@@ -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")
@@ -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 (session.plan_only && session.plan_phase !== "implementing")
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();
@@ -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 { autoHold } from "./auto-hold";
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
- // Why this machine's load rules out starting an agent now, or null. Only a
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(