@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.
Files changed (177) hide show
  1. package/app/api/sessions/[id]/pr/route.ts +3 -3
  2. package/app/api/sessions/[id]/setup/route.ts +3 -3
  3. package/app/api/tasks/[id]/approve-plan/route.ts +5 -1
  4. package/app/api/tasks/[id]/arm/route.ts +6 -5
  5. package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
  6. package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
  7. package/app/api/tasks/[id]/checks/route.ts +1 -1
  8. package/app/api/tasks/[id]/diff/route.ts +1 -1
  9. package/app/api/tasks/[id]/done/route.ts +2 -2
  10. package/app/api/tasks/[id]/drop/route.ts +6 -2
  11. package/app/api/tasks/[id]/export/route.ts +3 -4
  12. package/app/api/tasks/[id]/merge/route.ts +3 -3
  13. package/app/api/tasks/[id]/message/route.ts +2 -2
  14. package/app/api/tasks/[id]/model/route.ts +5 -1
  15. package/app/api/tasks/[id]/move/route.ts +5 -1
  16. package/app/api/tasks/[id]/moved/route.ts +6 -8
  17. package/app/api/tasks/[id]/plan/route.ts +35 -0
  18. package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
  19. package/app/api/tasks/[id]/priority/route.ts +4 -1
  20. package/app/api/tasks/[id]/resume/route.ts +3 -4
  21. package/app/api/tasks/[id]/stop/route.ts +2 -2
  22. package/app/api/tasks/route.ts +5 -2
  23. package/app/globals.css +28 -0
  24. package/components/Chat/ChatPanel.tsx +2 -11
  25. package/components/Chat/EventTurns.tsx +1 -7
  26. package/components/Chat/Markdown.tsx +1 -0
  27. package/components/Chat/Messages.tsx +1 -1
  28. package/lib/approvals/enable.ts +9 -2
  29. package/lib/away/report.ts +4 -4
  30. package/lib/away/run.ts +2 -2
  31. package/lib/bookmarks/store.ts +3 -3
  32. package/lib/bus/held-reply.ts +2 -2
  33. package/lib/bus/index.ts +3 -3
  34. package/lib/chat/compact-context.ts +1 -1
  35. package/lib/chat/events.ts +4 -0
  36. package/lib/chat/group.ts +3 -1
  37. package/lib/chat/hold.ts +3 -3
  38. package/lib/chat/reading-order.ts +50 -0
  39. package/lib/chat/refresh.ts +1 -1
  40. package/lib/chat/registry.ts +3 -3
  41. package/lib/chat/runner.ts +1 -1
  42. package/lib/chat/turns.ts +22 -3
  43. package/lib/chat/worker/host.ts +3 -0
  44. package/lib/chat/worker/main.ts +1 -1
  45. package/lib/db/index.ts +5 -1
  46. package/lib/db/lumifyhub.ts +8 -9
  47. package/lib/db/machine-scope.ts +12 -0
  48. package/lib/db/migrations/20261015T1600_plan_waits_for_caller.ts +19 -0
  49. package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
  50. package/lib/db/migrations/README.md +7 -0
  51. package/lib/db/queries.ts +16 -31
  52. package/lib/db/session-parts/columns.ts +186 -0
  53. package/lib/db/session-parts/fill.ts +33 -0
  54. package/lib/db/session-parts/index.ts +9 -0
  55. package/lib/db/session-parts/pauses.ts +11 -0
  56. package/lib/db/session-parts/placement.ts +11 -0
  57. package/lib/db/session-parts/plans.ts +31 -0
  58. package/lib/db/session-parts/prs.ts +31 -0
  59. package/lib/db/session-parts/tasks.ts +27 -0
  60. package/lib/db/session-parts/view.ts +43 -0
  61. package/lib/db/session-parts/write.ts +69 -0
  62. package/lib/db/types.ts +6 -1
  63. package/lib/done/archive.ts +2 -2
  64. package/lib/done/auto-archive.ts +1 -1
  65. package/lib/done/bulk.ts +1 -1
  66. package/lib/done/index.ts +8 -14
  67. package/lib/env-setup.ts +17 -4
  68. package/lib/health/evidence.ts +1 -1
  69. package/lib/health/facts.ts +1 -1
  70. package/lib/health/pieces.ts +1 -1
  71. package/lib/hosts/peer-actions.ts +2 -2
  72. package/lib/hosts/peer-mirror.ts +2 -2
  73. package/lib/hosts/peer-sync.ts +69 -32
  74. package/lib/learning-cards/input.ts +1 -1
  75. package/lib/learning-cards/store.ts +1 -1
  76. package/lib/models/plan-step.ts +83 -46
  77. package/lib/models/resume.ts +5 -7
  78. package/lib/notify/index.ts +1 -1
  79. package/lib/notify/push/events.ts +1 -1
  80. package/lib/notify/push/index.ts +1 -1
  81. package/lib/orchestrator/act.ts +1 -1
  82. package/lib/orchestrator/armed.ts +17 -15
  83. package/lib/orchestrator/ask-approvals.ts +1 -1
  84. package/lib/orchestrator/ask-heads.ts +1 -1
  85. package/lib/orchestrator/ask-settle.ts +1 -1
  86. package/lib/orchestrator/brakes.ts +1 -1
  87. package/lib/orchestrator/digest.ts +2 -2
  88. package/lib/orchestrator/external-pr.ts +2 -2
  89. package/lib/orchestrator/facts.ts +4 -28
  90. package/lib/orchestrator/held-asks.ts +3 -3
  91. package/lib/orchestrator/home.ts +2 -2
  92. package/lib/orchestrator/overview.ts +1 -1
  93. package/lib/orchestrator/pr-wake.ts +3 -5
  94. package/lib/orchestrator/questions.ts +1 -1
  95. package/lib/orchestrator/quick-store.ts +1 -1
  96. package/lib/orchestrator/quick.ts +10 -7
  97. package/lib/orchestrator/read.ts +1 -1
  98. package/lib/orchestrator/rebase.ts +8 -11
  99. package/lib/orchestrator/reconcile.ts +2 -2
  100. package/lib/orchestrator/signoff.ts +3 -1
  101. package/lib/orchestrator/targets.ts +1 -1
  102. package/lib/orchestrator/task-state.ts +4 -2
  103. package/lib/plans/features.ts +1 -1
  104. package/lib/plans/release.ts +1 -1
  105. package/lib/ports.ts +1 -1
  106. package/lib/resurrect/find.ts +1 -1
  107. package/lib/resurrect/index.ts +24 -18
  108. package/lib/schedules/store.ts +1 -1
  109. package/lib/session-audit/sources.ts +3 -3
  110. package/lib/session-logs/index.ts +1 -1
  111. package/lib/session-titles.ts +1 -1
  112. package/lib/sessions/setup-progress.ts +1 -1
  113. package/lib/sessions/stop.ts +1 -1
  114. package/lib/sessions/worktree-setup.ts +2 -1
  115. package/lib/simulator/index.ts +1 -1
  116. package/lib/simulator/task-devices.ts +1 -1
  117. package/lib/stacks/board.ts +1 -1
  118. package/lib/stacks/land.ts +1 -1
  119. package/lib/stacks/restack.ts +1 -1
  120. package/lib/stacks/start.ts +2 -2
  121. package/lib/stacks/tick.ts +1 -1
  122. package/lib/stacks/view.ts +1 -1
  123. package/lib/status/incremental.ts +1 -1
  124. package/lib/tasks/contract/actions.ts +35 -0
  125. package/lib/tasks/contract/authorize.ts +82 -0
  126. package/lib/tasks/contract/client.ts +296 -0
  127. package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
  128. package/lib/tasks/contract/index.ts +28 -0
  129. package/lib/tasks/contract/plan-path.ts +7 -0
  130. package/lib/tasks/contract/plan.ts +274 -0
  131. package/lib/tasks/contract/routes.ts +26 -0
  132. package/lib/tasks/contract/start.ts +122 -0
  133. package/lib/tasks/contract/sync.ts +126 -0
  134. package/lib/tasks/contract/types.ts +41 -0
  135. package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
  136. package/lib/tasks/finish.ts +10 -9
  137. package/lib/tasks/held-replies.ts +7 -13
  138. package/lib/tasks/held.ts +36 -17
  139. package/lib/tasks/hold-checks.ts +134 -0
  140. package/lib/tasks/holds.ts +281 -0
  141. package/lib/tasks/import.ts +26 -14
  142. package/lib/tasks/index.ts +56 -22
  143. package/lib/tasks/launch-gate.ts +1 -1
  144. package/lib/tasks/move-bundle.ts +17 -2
  145. package/lib/tasks/move-carry.ts +15 -7
  146. package/lib/tasks/move-flow.ts +29 -36
  147. package/lib/tasks/move-guard.ts +10 -2
  148. package/lib/tasks/move-recover.ts +6 -25
  149. package/lib/tasks/move.ts +28 -27
  150. package/lib/tasks/moved-away.ts +1 -1
  151. package/lib/tasks/pause-hold.ts +13 -5
  152. package/lib/tasks/pause.ts +21 -54
  153. package/lib/tasks/placement.ts +16 -16
  154. package/lib/tasks/plan-view.ts +11 -3
  155. package/lib/tasks/queue-candidates.ts +151 -0
  156. package/lib/tasks/queue-placement.ts +155 -0
  157. package/lib/tasks/queue-rows.ts +236 -0
  158. package/lib/tasks/queue-start.ts +213 -0
  159. package/lib/tasks/queue-tick.ts +148 -0
  160. package/lib/tasks/queue-types.ts +141 -0
  161. package/lib/tasks/queue-view.ts +112 -0
  162. package/lib/tasks/queue.ts +67 -1102
  163. package/lib/tasks/schedule.ts +44 -147
  164. package/lib/tasks/session.ts +7 -4
  165. package/lib/tasks/start.ts +4 -4
  166. package/lib/tasks/starter.ts +8 -11
  167. package/lib/usage/calls.ts +1 -1
  168. package/lib/usage/compare.ts +1 -1
  169. package/lib/usage/quality.ts +1 -1
  170. package/lib/usage/turns.ts +1 -1
  171. package/lib/voice/vocab.ts +1 -1
  172. package/package.json +1 -1
  173. package/scripts/backfill-session-logs.ts +1 -1
  174. package/scripts/check +55 -7
  175. package/lib/tasks/auto-hold.ts +0 -17
  176. package/lib/tasks/held-routes.ts +0 -33
  177. package/lib/tasks/remote.ts +0 -238
@@ -16,19 +16,15 @@ import { holderRefusal } from "./held";
16
16
  import { isPaused } from "../orchestrator/pause";
17
17
  import { sessionWorkspace } from "../models/resolve";
18
18
  import { arrived as arrivedHere, importTask } from "./import";
19
- import {
20
- forgetHostTasks,
21
- requireCapability,
22
- postIdempotent,
23
- unknownOutcome,
24
- } from "./remote";
25
- import { arrivedThere, settleMovedOut, tell } from "./move-recover";
19
+ import * as client from "./contract/client";
20
+ import { arrivedThere, settleMovedOut } from "./move-recover";
26
21
  import {
27
22
  finishProgress,
28
23
  moveSteps,
29
24
  startProgress,
30
25
  stepProgress,
31
26
  } from "./move-progress";
27
+ import { setTask } from "../db/session-parts";
32
28
 
33
29
  const LIVE = new Set(["running", "moving"]);
34
30
 
@@ -89,7 +85,7 @@ export async function moveTask(id: string, toHostId: string): Promise<Session> {
89
85
  }
90
86
 
91
87
  const requireChatMoves = (link: HostLink) =>
92
- requireCapability(link, "chat-move", "chat tasks");
88
+ client.requireCapability(link, "chat-move", "chat tasks");
93
89
 
94
90
  const unconfirmed = (where: string, err: unknown) =>
95
91
  new Error(
@@ -101,7 +97,11 @@ async function moveOut(session: Session, hostId: string): Promise<Session> {
101
97
  const chat = session.view === "chat";
102
98
  if (chat) await requireChatMoves(link);
103
99
  if (isOrchestrated(session))
104
- await requireCapability(link, "orchestrated-move", "orchestrator tasks");
100
+ await client.requireCapability(
101
+ link,
102
+ "orchestrated-move",
103
+ "orchestrator tasks"
104
+ );
105
105
  startProgress(
106
106
  session.id,
107
107
  link.hostName,
@@ -118,15 +118,10 @@ async function moveOut(session: Session, hostId: string): Promise<Session> {
118
118
  stepProgress(session.id, "arrive");
119
119
  let there: Session;
120
120
  try {
121
- ({ session: there } = await postIdempotent<{ session: Session }>(
122
- link,
123
- "/api/tasks/import",
124
- bundle,
125
- 600000
126
- ));
121
+ ({ session: there } = await client.importBundle(link, bundle));
127
122
  } catch (err) {
128
123
  // It may be running there already: resuming here could run it twice.
129
- if (unknownOutcome(err)) throw unconfirmed(link.hostName, err);
124
+ if (client.unknownOutcome(err)) throw unconfirmed(link.hostName, err);
130
125
  await resumeTask(session.id);
131
126
  throw err;
132
127
  }
@@ -134,45 +129,43 @@ async function moveOut(session: Session, hostId: string): Promise<Session> {
134
129
  }
135
130
 
136
131
  function mirrorRunning(id: string): void {
137
- db.prepare(
138
- `UPDATE sessions SET task_status = 'running', moved_to = NULL
139
- WHERE id = ? AND task_status = 'moving'`
140
- ).run(id);
132
+ setTask(
133
+ db,
134
+ id,
135
+ { task_status: "running", moved_to: null },
136
+ { task_status: "moving" }
137
+ );
141
138
  }
142
139
 
143
140
  async function moveIn(mirror: Session): Promise<Session> {
144
141
  const link = requireHostLink(mirror.host_id);
145
142
  if (mirror.view === "chat") await requireChatMoves(link);
146
143
  startProgress(mirror.id, "this machine", moveSteps("in", link.hostName));
147
- const path = `/api/tasks/${encodeURIComponent(mirror.id)}`;
148
144
  // A retry after it arrived here but that machine didn't hear: tidy up.
149
145
  let here = arrivedHere(mirror.id);
150
146
  if (!here) {
151
147
  // Moving, so nothing reads the mirror as a task to judge, or sign off,
152
148
  // while it leaves (a refusal below puts it back).
153
- db.prepare(
154
- `UPDATE sessions SET task_status = 'moving', moved_to = 'this machine'
155
- WHERE id = ? AND task_status = 'running'`
156
- ).run(mirror.id);
149
+ setTask(
150
+ db,
151
+ mirror.id,
152
+ { task_status: "moving", moved_to: "this machine" },
153
+ { task_status: "running" }
154
+ );
157
155
  // That machine resumes the agent itself if its half fails there.
158
156
  stepProgress(mirror.id, "export");
159
157
  let bundle: TaskBundle;
160
158
  try {
161
- ({ bundle } = await postIdempotent<{ bundle: TaskBundle }>(
162
- link,
163
- `${path}/export`,
164
- { to: os.hostname() },
165
- 300000
166
- ));
159
+ bundle = await client.exportOut(link, mirror.id, os.hostname());
167
160
  } catch (err) {
168
161
  mirrorRunning(mirror.id);
169
- if (unknownOutcome(err)) throw unconfirmed(link.hostName, err);
162
+ if (client.unknownOutcome(err)) throw unconfirmed(link.hostName, err);
170
163
  throw err;
171
164
  }
172
165
  try {
173
166
  here = await importTask(bundle);
174
167
  } catch (err) {
175
- await tell(link, `${path}/resume`, { force: true }).catch((e) => {
168
+ await client.resume(link, mirror.id, { force: true }).catch((e) => {
176
169
  throw new Error(
177
170
  `${(err as Error).message}; and ${link.hostName} didn't resume it: ${e.message}`
178
171
  );
@@ -186,7 +179,7 @@ async function moveIn(mirror: Session): Promise<Session> {
186
179
  // "moving" there, which nothing can sign off), the mirror stays, so Move
187
180
  // here again comes back to this.
188
181
  try {
189
- await tell(link, `${path}/moved`, { to: os.hostname() });
182
+ await client.confirmMoved(link, mirror.id, os.hostname());
190
183
  } catch (err) {
191
184
  throw new Error(
192
185
  `It's running here now, but ${link.hostName} didn't confirm (${(err as Error).message}). Press Move here again to tidy up.`
@@ -197,8 +190,8 @@ async function moveIn(mirror: Session): Promise<Session> {
197
190
  mirror.id,
198
191
  mirror.host_id
199
192
  );
200
- forgetHostTasks(link.hostId);
193
+ client.forget(link.hostId);
201
194
  return db
202
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
195
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
203
196
  .get(here.id) as Session;
204
197
  }
@@ -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(
@@ -41,7 +49,7 @@ export function moveRefusal(session: Session): string | null {
41
49
  .prepare(
42
50
  `SELECT lh_card_id,
43
51
  EXISTS (SELECT 1 FROM stack_items WHERE session_id = s.id) AS stacked
44
- FROM sessions s WHERE id = ?`
52
+ FROM session_rows s WHERE id = ?`
45
53
  )
46
54
  .get(session.id) as
47
55
  | { lh_card_id: string | null; stacked: number }
@@ -8,38 +8,19 @@
8
8
 
9
9
  import { db, type Session } from "../db";
10
10
  import { hostIdNamed } from "../hosts";
11
- import { hostApi, hostLink, type HostLink } from "../hosts/remote-api";
11
+ import { hostLink, type HostLink } from "../hosts/remote-api";
12
12
  import { getTaskSession } from "./session";
13
13
  import { markMoved, resumeTask } from "./move";
14
- import { forgetHostTasks, mirrorTask, unknownOutcome } from "./remote";
14
+ import * as client from "./contract/client";
15
+ import { mirrorTask } from "./contract/start";
15
16
  import { carryTaskState } from "./move-carry";
16
17
 
17
- /** POST, repeated when the outcome is unknown (the calls are idempotent). */
18
- export async function tell(
19
- link: HostLink,
20
- path: string,
21
- body: object = {}
22
- ): Promise<void> {
23
- for (let i = 0; ; i++) {
24
- try {
25
- await hostApi(link, path, { body });
26
- return;
27
- } catch (err) {
28
- if (!unknownOutcome(err) || i === 2) throw err;
29
- }
30
- }
31
- }
32
-
33
18
  /** The task a move from this session made there, or null. Throws if unsure. */
34
19
  export async function arrivedThere(
35
20
  link: HostLink,
36
21
  sessionId: string
37
22
  ): Promise<Session | null> {
38
- const { session } = await hostApi<{ session: Session | null }>(
39
- link,
40
- `/api/tasks/arrived?from=${encodeURIComponent(sessionId)}`
41
- );
42
- return session;
23
+ return client.arrived(link, sessionId);
43
24
  }
44
25
 
45
26
  export function settleMovedOut(
@@ -53,9 +34,9 @@ export function settleMovedOut(
53
34
  mirrorTask(hostId, session.project_id, there);
54
35
  carryTaskState(session.id, there.id);
55
36
  })();
56
- forgetHostTasks(hostId);
37
+ client.forget(hostId);
57
38
  return db
58
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
39
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
59
40
  .get(there.id) as Session;
60
41
  }
61
42
 
package/lib/tasks/move.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  latestSessionId,
37
37
  readTranscript,
38
38
  } from "./transcript";
39
+ import { setTask } from "../db/session-parts";
39
40
 
40
41
  export * from "./move-bundle";
41
42
 
@@ -88,13 +89,13 @@ async function pushOwnBranch(cwd: string, branch: string): Promise<void> {
88
89
  * arrived, and then it would run on both.
89
90
  */
90
91
  function claimMoving(id: string, to: string): { fresh: boolean } {
91
- const fresh = db
92
- .prepare(
93
- `UPDATE sessions SET task_status = 'moving', moved_to = ?
94
- WHERE id = ? AND task_status = 'running'`
95
- )
96
- .run(to, id);
97
- if (fresh.changes === 1) return { fresh: true };
92
+ const fresh = setTask(
93
+ db,
94
+ id,
95
+ { task_status: "moving", moved_to: to },
96
+ { task_status: "running" }
97
+ );
98
+ if (fresh) return { fresh: true };
98
99
  const { task_status, moved_to } = getTaskSession(id);
99
100
  if (task_status === "moving" && moved_to === to) return { fresh: false };
100
101
  throw new Error(
@@ -140,10 +141,7 @@ function orchestratedBundle(session: Session): OrchestratedBundle {
140
141
  modelProvider: session.model_provider ?? null,
141
142
  modelOverrides: session.model_overrides ?? null,
142
143
  // moveRefusal keeps an unapproved plan-only task here.
143
- planPhase: (session.plan_phase ?? null) as
144
- | "planning"
145
- | "implementing"
146
- | null,
144
+ planPhase: (session.plan_phase ?? null) as OrchestratedBundle["planPhase"],
147
145
  planFile: readPlanFile(session.working_directory),
148
146
  orchestrator: orchestratorOf(session),
149
147
  };
@@ -215,6 +213,10 @@ export async function exportTask(id: string, to: string): Promise<TaskBundle> {
215
213
  : null,
216
214
  chat: chatBundle,
217
215
  orchestrated: isOrchestrated(now) ? orchestratedBundle(now) : undefined,
216
+ planWait:
217
+ now.plan_waits_for_caller && now.plan_phase === "awaiting"
218
+ ? { file: readPlanFile(cwd) }
219
+ : undefined,
218
220
  };
219
221
  } finally {
220
222
  packed();
@@ -249,12 +251,12 @@ export async function exportOrResume(
249
251
  }
250
252
 
251
253
  export function markMoved(id: string, to: string): void {
252
- const moved = db
253
- .prepare(
254
- `UPDATE sessions SET task_status = 'moved', moved_to = ?
255
- WHERE id = ? AND task_status IN ('running', 'moving')`
256
- )
257
- .run(to, id).changes;
254
+ const moved = setTask(
255
+ db,
256
+ id,
257
+ { task_status: "moved", moved_to: to },
258
+ { task_status: ["running", "moving"] }
259
+ );
258
260
  // It goes on there with a copy of its own.
259
261
  if (moved) void dropSessionDatabase(id);
260
262
  }
@@ -268,25 +270,24 @@ export function markMoved(id: string, to: string): void {
268
270
  */
269
271
  export async function resumeTask(id: string, note?: string): Promise<void> {
270
272
  if (exporting.has(id)) throw new Error("It's moving right now");
271
- const claimed = db
272
- .prepare(
273
- `UPDATE sessions SET task_status = 'running' WHERE id = ? AND task_status = 'moving'`
274
- )
275
- .run(id);
273
+ const claimed = setTask(
274
+ db,
275
+ id,
276
+ { task_status: "running" },
277
+ { task_status: "moving" }
278
+ );
276
279
  const session = getTaskSession(id);
277
- if (claimed.changes !== 1) {
280
+ if (!claimed) {
278
281
  if (session.task_status === "running") return;
279
282
  throw new Error(`Task is already ${session.task_status}`);
280
283
  }
281
284
  try {
282
285
  await relaunch(session, note);
283
286
  } catch (err) {
284
- db.prepare(
285
- `UPDATE sessions SET task_status = 'moving' WHERE id = ? AND task_status = 'running'`
286
- ).run(id);
287
+ setTask(db, id, { task_status: "moving" }, { task_status: "running" });
287
288
  throw err;
288
289
  }
289
- db.prepare(`UPDATE sessions SET moved_to = NULL WHERE id = ?`).run(id);
290
+ setTask(db, id, { moved_to: null });
290
291
  }
291
292
 
292
293
  async function relaunch(session: Session, note?: string): Promise<void> {
@@ -12,7 +12,7 @@ import { settleWorktree, type WorktreeFate } from "../done/worktree";
12
12
  import { archiveSession } from "../done/archive";
13
13
 
14
14
  export async function cleanUpMovedAway(id: string): Promise<WorktreeFate> {
15
- const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
15
+ const s = db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
16
16
  | Session
17
17
  | undefined;
18
18
  if (!s || s.task_status !== "moved" || !s.held_by_device)
@@ -2,6 +2,7 @@
2
2
  // can hold a message for it without loading the pause machinery.
3
3
 
4
4
  import { db, type TaskPause } from "../db";
5
+ import { setPause } from "../db/session-parts";
5
6
 
6
7
  export interface PauseHold {
7
8
  pauseId: number;
@@ -11,7 +12,7 @@ export interface PauseHold {
11
12
  export function pauseHoldOf(sessionId: string): PauseHold | null {
12
13
  const row = db
13
14
  .prepare(
14
- `SELECT pause_id, paused_until FROM sessions
15
+ `SELECT pause_id, paused_until FROM session_rows
15
16
  WHERE id = ? AND pause_state IN ('pausing', 'paused') AND pause_id IS NOT NULL`
16
17
  )
17
18
  .get(sessionId) as
@@ -45,10 +46,17 @@ export function endPause(
45
46
  `UPDATE task_pauses SET state = 'ended', ended_at = datetime('now'), end_reason = ?
46
47
  WHERE id = ? AND state != 'ended'`
47
48
  ).run(reason, row.id);
48
- db.prepare(
49
- `UPDATE sessions SET pause_state = NULL, paused_until = NULL, pause_id = NULL, pause_note = NULL
50
- WHERE id = ? AND pause_id = ?`
51
- ).run(row.session_id, row.id);
49
+ setPause(
50
+ db,
51
+ row.session_id,
52
+ {
53
+ pause_state: null,
54
+ paused_until: null,
55
+ pause_id: null,
56
+ pause_note: null,
57
+ },
58
+ { pause_id: row.id }
59
+ );
52
60
  })();
53
61
  }
54
62
 
@@ -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,
@@ -52,6 +50,7 @@ import {
52
50
  type PauseView,
53
51
  type Priority,
54
52
  } from "./schedule-types";
53
+ import { setPause } from "../db/session-parts";
55
54
 
56
55
  export class PauseRefused extends Error {}
57
56
 
@@ -74,7 +73,7 @@ const nudge = () =>
74
73
  void import("./queue").then((q) => q.tickQueueSoon()).catch(() => undefined);
75
74
 
76
75
  const sessionOf = (id: string) =>
77
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
76
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
78
77
  | Session
79
78
  | undefined;
80
79
 
@@ -167,10 +166,9 @@ export function requestPause(sessionId: string, req: PauseRequest): TaskPause {
167
166
  until ? toSqliteTime(until) : null,
168
167
  existing.id
169
168
  );
170
- db.prepare(`UPDATE sessions SET paused_until = ? WHERE id = ?`).run(
171
- until ? toSqliteTime(until) : null,
172
- sessionId
173
- );
169
+ setPause(db, sessionId, {
170
+ paused_until: until ? toSqliteTime(until) : null,
171
+ });
174
172
  // "Pause now" takes over: the first driver is superseded, not raced.
175
173
  drive(sessionId, existing.id, mode === "now");
176
174
  return pauseRow(existing.id)!;
@@ -192,9 +190,11 @@ export function requestPause(sessionId: string, req: PauseRequest): TaskPause {
192
190
  until ? toSqliteTime(until) : null
193
191
  ).lastInsertRowid
194
192
  );
195
- db.prepare(
196
- `UPDATE sessions SET pause_state = 'pausing', paused_until = ?, pause_id = ? WHERE id = ?`
197
- ).run(until ? toSqliteTime(until) : null, id, sessionId);
193
+ setPause(db, sessionId, {
194
+ pause_state: "pausing",
195
+ paused_until: until ? toSqliteTime(until) : null,
196
+ pause_id: id,
197
+ });
198
198
  drive(sessionId, id);
199
199
  return pauseRow(id)!;
200
200
  }
@@ -309,10 +309,7 @@ function markPaused(sessionId: string, pauseId: number): void {
309
309
  WHERE id = ? AND state = 'pausing'`
310
310
  )
311
311
  .run(pauseId).changes;
312
- if (n)
313
- db.prepare(`UPDATE sessions SET pause_state = 'paused' WHERE id = ?`).run(
314
- sessionId
315
- );
312
+ if (n) setPause(db, sessionId, { pause_state: "paused" });
316
313
  return n;
317
314
  })();
318
315
  if (!changed) return;
@@ -361,10 +358,7 @@ export function setResumeTime(sessionId: string, until: Date | null): void {
361
358
  db.prepare(
362
359
  `UPDATE task_pauses SET until_at = ?, resume_requested_at = NULL WHERE id = ? AND state IN ('pausing','paused')`
363
360
  ).run(value, row.id);
364
- db.prepare(`UPDATE sessions SET paused_until = ? WHERE id = ?`).run(
365
- value,
366
- sessionId
367
- );
361
+ setPause(db, sessionId, { paused_until: value });
368
362
  })();
369
363
  }
370
364
 
@@ -395,39 +389,14 @@ export function requestResume(sessionId: string, actor: PauseBy): void {
395
389
  );
396
390
  }
397
391
 
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
- }
392
+ export { loadHoldFor, resumeHoldFor };
426
393
 
427
394
  export function setPauseNote(sessionId: string, note: string | null): void {
428
- db.prepare(
429
- `UPDATE sessions SET pause_note = ? WHERE id = ? AND pause_state = 'paused' AND COALESCE(pause_note, '') != COALESCE(?, '')`
430
- ).run(note, sessionId, note);
395
+ const row = db
396
+ .prepare(`SELECT pause_note FROM session_rows WHERE id = ?`)
397
+ .get(sessionId) as { pause_note: string | null } | undefined;
398
+ if ((row?.pause_note ?? "") === (note ?? "")) return;
399
+ setPause(db, sessionId, { pause_note: note }, { pause_state: "paused" });
431
400
  }
432
401
 
433
402
  export type ResumeOutcome =
@@ -482,7 +451,7 @@ export function dueResumes(): DueResume[] {
482
451
  .prepare(
483
452
  `SELECT p.id AS pause_id, p.session_id, p.by_kind, p.until_at, p.resume_requested_at, p.resume_by_kind,
484
453
  s.project_id, s.priority
485
- FROM task_pauses p JOIN sessions s ON s.id = p.session_id
454
+ FROM task_pauses p JOIN session_rows s ON s.id = p.session_id
486
455
  WHERE p.state = 'paused' AND p.resume_requested_at IS NOT NULL
487
456
  AND s.task_status IN ('running') AND s.archived_at IS NULL`
488
457
  )
@@ -612,9 +581,7 @@ export async function claimResume(
612
581
  }),
613
582
  Date.now()
614
583
  );
615
- db.prepare(
616
- `UPDATE sessions SET pause_state = 'resuming', pause_note = NULL WHERE id = ?`
617
- ).run(sessionId);
584
+ setPause(db, sessionId, { pause_state: "resuming", pause_note: null });
618
585
  return true;
619
586
  })();
620
587
  if (!claimed) return "gone";
@@ -12,12 +12,8 @@ import path from "path";
12
12
  import { db } from "../db";
13
13
  import { loadProjectConfig } from "../project-config";
14
14
  import { MACHINE_NEEDS } from "../project-config/schema";
15
- import {
16
- machineHold,
17
- type Candidate,
18
- type Hold,
19
- type MachineView,
20
- } from "./schedule";
15
+ import { machineHold, placeable, type MachineView } from "./holds";
16
+ import type { Candidate } from "./schedule";
21
17
 
22
18
  export type Need = (typeof MACHINE_NEEDS)[number];
23
19
  export type NeedsSource = "task" | "brief" | "config" | "inferred" | "none";
@@ -138,8 +134,6 @@ export function unplaceable(r: {
138
134
  host_id: string | null;
139
135
  }): string | null {
140
136
  if (r.host_id) return "it was pinned to a machine";
141
- if (r.plan_only) return "plan-only tasks run on this machine";
142
- if (r.plan) return "plan-first tasks run on this machine";
143
137
  if (r.agent_type && r.agent_type !== "claude")
144
138
  return "only Claude tasks run on another machine";
145
139
  if (r.model_overrides) return "per-role models run on this machine";
@@ -153,11 +147,24 @@ export interface PeerOption {
153
147
  capabilities: string[];
154
148
  // It can hold an orchestrator's task (false: an older AgentOS). Unset: yes.
155
149
  holds?: boolean;
150
+ // It can run a plan step that stops for this machine (false: an older
151
+ // AgentOS). Unset: yes.
152
+ plans?: boolean;
156
153
  // The project exists there (projectMachines said null).
157
154
  hasProject: boolean;
158
155
  loadPerCore: number;
159
156
  }
160
157
 
158
+ // A start that plans first: the plan phase only reads code, so it needs no
159
+ // simulator or Mac, and the plan-only skip of load holds doesn't apply to
160
+ // where it goes, only to whether it may wait. The candidate as a plan phase
161
+ // sees it.
162
+ export const asPlanPhase = <
163
+ T extends Pick<Candidate, "heavy" | "needsUnmet" | "planOnly">,
164
+ >(
165
+ c: T
166
+ ): T => ({ ...c, heavy: false, needsUnmet: false, planOnly: false });
167
+
161
168
  export interface Placement {
162
169
  hostId: string;
163
170
  name: string;
@@ -165,13 +172,6 @@ export interface Placement {
165
172
  why: string;
166
173
  }
167
174
 
168
- const LOAD_HOLDS = new Set<Hold["code"]>([
169
- "load_red",
170
- "load_amber",
171
- "needs",
172
- "heavy",
173
- ]);
174
-
175
175
  // True when the home machine's load or needs rule this start out, which is
176
176
  // the only time a linked machine is worth asking.
177
177
  export const wantsPlacing = (
@@ -181,7 +181,7 @@ export const wantsPlacing = (
181
181
  ): boolean => {
182
182
  if (c.remote || home.reading) return false;
183
183
  const hold = machineHold(c, home, now);
184
- return !!hold && LOAD_HOLDS.has(hold.code);
184
+ return !!hold && placeable(hold);
185
185
  };
186
186
 
187
187
  // Null: start it at home (or hold it there for a reason placing doesn't
@@ -17,6 +17,8 @@ import {
17
17
  type TaskPlan,
18
18
  } from "./loop-types";
19
19
  import { expandHome } from "./session";
20
+ import { isRemoteHost } from "../hosts";
21
+ import { remotePlanPath } from "./contract/plan-path";
20
22
 
21
23
  const TITLE_MAX = 120;
22
24
 
@@ -131,7 +133,10 @@ export function taskPlan(session: Session): TaskPlan | null {
131
133
  };
132
134
 
133
135
  if (phase && session.worktree_path) {
134
- const file = path.join(expandHome(session.worktree_path), PLAN_FILE);
136
+ // A plan written on a linked machine is read from the copy kept here.
137
+ const file = isRemoteHost(session.host_id)
138
+ ? remotePlanPath(session.id)
139
+ : path.join(expandHome(session.worktree_path), PLAN_FILE);
135
140
  const text = readHead(file, PLAN_TEXT_MAX);
136
141
  if (text !== null || phase === "planning") {
137
142
  const body = text ?? "";
@@ -141,13 +146,16 @@ export function taskPlan(session: Session): TaskPlan | null {
141
146
  status:
142
147
  phase === "planning"
143
148
  ? "writing"
144
- : phase === "awaiting"
149
+ : phase === "awaiting" || phase === "handoff"
145
150
  ? "ready"
146
151
  : "approved",
147
152
  source: "step",
148
153
  title: planTitle(body),
149
154
  path: text !== null ? PLAN_FILE : null,
150
- pieces: phase === "awaiting" ? piecesView(session, file) : null,
155
+ pieces:
156
+ phase === "awaiting" || phase === "handoff"
157
+ ? piecesView(session, file)
158
+ : null,
151
159
  ...capped(body),
152
160
  };
153
161
  }