@saadnvd1/agent-os 0.3.637 → 0.3.644

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 (211) hide show
  1. package/app/api/bus/branches/route.ts +25 -0
  2. package/app/api/bus/resurrect/route.test.ts +3 -6
  3. package/app/api/sessions/[id]/pr/route.ts +3 -3
  4. package/app/api/sessions/[id]/setup/route.ts +3 -3
  5. package/app/api/tasks/[id]/approve-plan/route.ts +3 -4
  6. package/app/api/tasks/[id]/arm/route.ts +6 -5
  7. package/app/api/tasks/[id]/chat-facts/route.ts +2 -2
  8. package/app/api/tasks/[id]/chat-tail/route.ts +2 -2
  9. package/app/api/tasks/[id]/checks/route.ts +1 -1
  10. package/app/api/tasks/[id]/diff/route.ts +1 -1
  11. package/app/api/tasks/[id]/done/route.ts +2 -2
  12. package/app/api/tasks/[id]/drop/route.ts +6 -2
  13. package/app/api/tasks/[id]/export/route.ts +3 -5
  14. package/app/api/tasks/[id]/merge/route.ts +3 -3
  15. package/app/api/tasks/[id]/message/route.ts +2 -2
  16. package/app/api/tasks/[id]/model/route.ts +5 -1
  17. package/app/api/tasks/[id]/move/route.ts +5 -1
  18. package/app/api/tasks/[id]/moved/route.ts +6 -8
  19. package/app/api/tasks/[id]/plan/route.ts +4 -6
  20. package/app/api/tasks/[id]/plan-edits/route.ts +6 -5
  21. package/app/api/tasks/[id]/priority/route.ts +4 -1
  22. package/app/api/tasks/[id]/resume/route.ts +3 -4
  23. package/app/api/tasks/[id]/stop/route.ts +2 -2
  24. package/app/api/tasks/route.ts +1 -1
  25. package/app/api/worktree-sweep/route.test.ts +40 -0
  26. package/app/api/worktree-sweep/route.ts +31 -0
  27. package/app/api/worktree-sweep/run/route.ts +26 -0
  28. package/components/Health/HealthPanel.tsx +4 -1
  29. package/components/Health/SweepRuns.tsx +125 -0
  30. package/components/Merge/GlobalMergeSection.tsx +2 -0
  31. package/components/Merge/ProjectMergeSection.tsx +2 -0
  32. package/components/Merge/WorktreeSweepSection.tsx +125 -0
  33. package/components/Settings/NoApprovalsSection.tsx +1 -1
  34. package/components/Settings/SettingsDialog.tsx +1 -1
  35. package/data/worktree-sweep/index.ts +56 -0
  36. package/lib/approvals/enable.ts +1 -1
  37. package/lib/away/report.ts +5 -5
  38. package/lib/away/run.ts +2 -2
  39. package/lib/bookmarks/store.ts +3 -3
  40. package/lib/bus/held-reply.ts +2 -2
  41. package/lib/bus/index.ts +3 -3
  42. package/lib/chat/compact-context.ts +1 -1
  43. package/lib/chat/hold.ts +3 -3
  44. package/lib/chat/refresh.ts +1 -1
  45. package/lib/chat/registry.ts +3 -3
  46. package/lib/chat/runner.ts +1 -1
  47. package/lib/chat/worker/main.ts +1 -1
  48. package/lib/db/backup.ts +49 -0
  49. package/lib/db/index.ts +5 -1
  50. package/lib/db/lumifyhub.ts +8 -9
  51. package/lib/db/machine-scope.ts +12 -0
  52. package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
  53. package/lib/db/migrations/20261015T1800_split_sessions_tables.ts +172 -0
  54. package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
  55. package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
  56. package/lib/db/migrations/README.md +11 -0
  57. package/lib/db/queries.ts +16 -31
  58. package/lib/db/session-parts/columns.ts +186 -0
  59. package/lib/db/session-parts/fill.ts +33 -0
  60. package/lib/db/session-parts/index.ts +10 -0
  61. package/lib/db/session-parts/pauses.ts +11 -0
  62. package/lib/db/session-parts/placement.ts +11 -0
  63. package/lib/db/session-parts/plans.ts +31 -0
  64. package/lib/db/session-parts/prs.ts +31 -0
  65. package/lib/db/session-parts/rows.ts +69 -0
  66. package/lib/db/session-parts/tasks.ts +27 -0
  67. package/lib/db/session-parts/view.ts +50 -0
  68. package/lib/db/session-parts/write.ts +61 -0
  69. package/lib/done/archive.ts +2 -2
  70. package/lib/done/auto-archive.ts +1 -1
  71. package/lib/done/bulk.ts +1 -1
  72. package/lib/done/index.ts +8 -14
  73. package/lib/done/remote.ts +9 -20
  74. package/lib/health/evidence.ts +1 -1
  75. package/lib/health/facts.ts +1 -1
  76. package/lib/health/pieces.ts +1 -1
  77. package/lib/hosts/peer-actions.ts +2 -2
  78. package/lib/hosts/peer-mirror.ts +2 -2
  79. package/lib/hosts/peer-sync.ts +69 -32
  80. package/lib/learning-cards/input.ts +1 -1
  81. package/lib/learning-cards/store.ts +1 -1
  82. package/lib/models/plan-step.ts +10 -24
  83. package/lib/models/resume.ts +5 -7
  84. package/lib/notify/index.ts +1 -1
  85. package/lib/notify/push/events.ts +1 -1
  86. package/lib/notify/push/index.ts +1 -1
  87. package/lib/orchestrator/act.ts +1 -1
  88. package/lib/orchestrator/armed.test-helpers.ts +228 -0
  89. package/lib/orchestrator/armed.ts +18 -16
  90. package/lib/orchestrator/ask-approvals.ts +1 -1
  91. package/lib/orchestrator/ask-heads.ts +1 -1
  92. package/lib/orchestrator/ask-settle.ts +1 -1
  93. package/lib/orchestrator/ask-verdict.ts +111 -0
  94. package/lib/orchestrator/asks.ts +26 -0
  95. package/lib/orchestrator/brakes.ts +1 -1
  96. package/lib/orchestrator/brief.ts +2 -1
  97. package/lib/orchestrator/digest.ts +2 -2
  98. package/lib/orchestrator/escalate.ts +25 -12
  99. package/lib/orchestrator/external-gates.ts +1 -0
  100. package/lib/orchestrator/external-pr.ts +2 -2
  101. package/lib/orchestrator/facts.ts +4 -28
  102. package/lib/orchestrator/gates.ts +94 -25
  103. package/lib/orchestrator/held-asks.ts +8 -11
  104. package/lib/orchestrator/held-review.ts +8 -4
  105. package/lib/orchestrator/home.ts +2 -2
  106. package/lib/orchestrator/loop-guard.ts +145 -0
  107. package/lib/orchestrator/merge-approvals.ts +2 -2
  108. package/lib/orchestrator/overview.ts +1 -1
  109. package/lib/orchestrator/pr-wake.ts +3 -5
  110. package/lib/orchestrator/questions.ts +1 -1
  111. package/lib/orchestrator/quick-store.ts +1 -1
  112. package/lib/orchestrator/quick.ts +11 -8
  113. package/lib/orchestrator/read.ts +1 -1
  114. package/lib/orchestrator/rebase.ts +8 -11
  115. package/lib/orchestrator/reconcile.ts +2 -2
  116. package/lib/orchestrator/review-prompt.ts +2 -0
  117. package/lib/orchestrator/review.ts +14 -4
  118. package/lib/orchestrator/signoff.test-helpers.ts +322 -0
  119. package/lib/orchestrator/signoff.ts +49 -11
  120. package/lib/orchestrator/targets.ts +1 -1
  121. package/lib/orchestrator/task-state.ts +4 -2
  122. package/lib/orchestrator/testing.ts +11 -14
  123. package/lib/orchestrator/watcher.ts +4 -0
  124. package/lib/plans/features.ts +1 -1
  125. package/lib/plans/release.ts +1 -1
  126. package/lib/ports.ts +1 -1
  127. package/lib/recipes/test-fixtures.ts +2 -3
  128. package/lib/resurrect/find.ts +1 -1
  129. package/lib/resurrect/index.ts +44 -20
  130. package/lib/resurrect/swept.ts +61 -0
  131. package/lib/schedules/store.ts +1 -1
  132. package/lib/session-audit/sources.ts +3 -3
  133. package/lib/session-logs/index.ts +1 -1
  134. package/lib/session-titles.ts +1 -1
  135. package/lib/sessions/setup-progress.ts +1 -1
  136. package/lib/sessions/stop.ts +1 -1
  137. package/lib/sessions/worktree-setup.ts +2 -1
  138. package/lib/simulator/index.ts +1 -1
  139. package/lib/simulator/task-devices.ts +1 -1
  140. package/lib/stacks/board.ts +1 -1
  141. package/lib/stacks/land.ts +1 -1
  142. package/lib/stacks/restack.ts +1 -1
  143. package/lib/stacks/start.ts +2 -2
  144. package/lib/stacks/testing.ts +17 -16
  145. package/lib/stacks/tick.ts +1 -1
  146. package/lib/stacks/view.ts +1 -1
  147. package/lib/status/incremental.ts +1 -1
  148. package/lib/tasks/contract/actions.ts +35 -0
  149. package/lib/tasks/contract/authorize.ts +82 -0
  150. package/lib/tasks/contract/client.ts +296 -0
  151. package/lib/tasks/{held-remote.ts → contract/held-calls.ts} +35 -66
  152. package/lib/tasks/contract/index.ts +28 -0
  153. package/lib/tasks/{remote-plan-path.ts → contract/plan-path.ts} +2 -2
  154. package/lib/tasks/{remote-plan.ts → contract/plan.ts} +76 -110
  155. package/lib/tasks/contract/routes.ts +26 -0
  156. package/lib/tasks/contract/start.ts +122 -0
  157. package/lib/tasks/contract/sync.ts +126 -0
  158. package/lib/tasks/contract/types.ts +41 -0
  159. package/lib/tasks/{remote-views.ts → contract/views.ts} +11 -18
  160. package/lib/tasks/finish.ts +30 -23
  161. package/lib/tasks/held-replies.ts +7 -13
  162. package/lib/tasks/held.ts +14 -18
  163. package/lib/tasks/import.ts +19 -15
  164. package/lib/tasks/index.ts +34 -22
  165. package/lib/tasks/launch-gate.ts +1 -1
  166. package/lib/tasks/move-carry.ts +15 -7
  167. package/lib/tasks/move-flow.ts +29 -36
  168. package/lib/tasks/move-guard.ts +1 -1
  169. package/lib/tasks/move-recover.ts +6 -25
  170. package/lib/tasks/move-testing.ts +16 -7
  171. package/lib/tasks/move.ts +23 -23
  172. package/lib/tasks/moved-away.ts +1 -1
  173. package/lib/tasks/pause-hold.ts +13 -5
  174. package/lib/tasks/pause.ts +19 -23
  175. package/lib/tasks/plan-view.ts +1 -1
  176. package/lib/tasks/pr-activity.ts +11 -2
  177. package/lib/tasks/prune-branches.ts +193 -0
  178. package/lib/tasks/prune-scope.ts +22 -0
  179. package/lib/tasks/queue-placement.ts +1 -1
  180. package/lib/tasks/queue-rows.ts +4 -4
  181. package/lib/tasks/queue-start.ts +5 -3
  182. package/lib/tasks/queue-tick.ts +2 -2
  183. package/lib/tasks/queue.ts +14 -6
  184. package/lib/tasks/remote-branch.ts +170 -0
  185. package/lib/tasks/session.ts +7 -4
  186. package/lib/tasks/start.ts +4 -4
  187. package/lib/tasks/starter.ts +8 -11
  188. package/lib/tasks/testing.ts +13 -7
  189. package/lib/usage/calls.ts +1 -1
  190. package/lib/usage/compare.ts +1 -1
  191. package/lib/usage/quality.ts +1 -1
  192. package/lib/usage/turns.ts +1 -1
  193. package/lib/voice/vocab.ts +1 -1
  194. package/lib/worktree-sweep/commits.ts +65 -0
  195. package/lib/worktree-sweep/findOrphans.ts +139 -0
  196. package/lib/worktree-sweep/findSettled.ts +112 -0
  197. package/lib/worktree-sweep/index.ts +10 -0
  198. package/lib/worktree-sweep/judge.ts +31 -0
  199. package/lib/worktree-sweep/panes.ts +18 -0
  200. package/lib/worktree-sweep/runSweep.ts +243 -0
  201. package/lib/worktree-sweep/settings.ts +83 -0
  202. package/lib/worktree-sweep/start.ts +64 -0
  203. package/lib/worktree-sweep/state.ts +36 -0
  204. package/lib/worktree-sweep/store.ts +96 -0
  205. package/lib/worktree-sweep/types.ts +53 -0
  206. package/package.json +1 -1
  207. package/scripts/backfill-session-logs.ts +1 -1
  208. package/scripts/screenshots/seed.ts +19 -18
  209. package/server.ts +3 -0
  210. package/lib/tasks/held-routes.ts +0 -33
  211. package/lib/tasks/remote.ts +0 -255
@@ -9,40 +9,44 @@
9
9
  * Moving the task reuses the ordinary move (move-flow.ts).
10
10
  */
11
11
 
12
- import fs from "fs";
12
+ import fs from "fs/promises";
13
13
  import path from "path";
14
- import { db, type Session } from "../db";
15
- import { isRemoteHost } from "../hosts";
16
- import { peerSource } from "../hosts/peer-load";
17
- import { hostApi, hostLink, requireHostLink } from "../hosts/remote-api";
18
- import { LOCAL, machineCapabilities } from "../load/machines";
19
- import { noApprovalsOn, recordAutoApproval } from "../approvals/no-approvals";
20
- import { implementArrived, planNoteFor } from "../models/plan-step";
21
- import { sessionWorkspace } from "../models/resolve";
22
- import { queueEvent } from "../orchestrator/events";
23
- import { brakesOn } from "../orchestrator/brakes";
24
- import { isPaused } from "../orchestrator/pause";
25
- import { getProject } from "../projects";
26
- import { planFor, splitOf } from "../plans/features";
27
- import { forgetHostTasks, hostTasks } from "./remote";
28
- import { moveTask } from "./move-flow";
29
- import { meetsNeeds, needsOf } from "./placement";
30
- import { remotePlanPath } from "./remote-plan-path";
31
- import { DEFAULT_PRIORITY, isPriority } from "./schedule-types";
32
- import { localHolds } from "./hold-checks";
33
- import { expandHome } from "./session";
34
- import { orchestratorOf } from "./starter";
14
+ import { db, type Session } from "../../db";
15
+ import { isRemoteHost } from "../../hosts";
16
+ import { peerSource } from "../../hosts/peer-load";
17
+ import { hostLink, requireHostLink } from "../../hosts/remote-api";
18
+ import { LOCAL, machineCapabilities } from "../../load/machines";
19
+ import {
20
+ noApprovalsOn,
21
+ recordAutoApproval,
22
+ } from "../../approvals/no-approvals";
23
+ import { implementArrived, planNoteFor } from "../../models/plan-step";
24
+ import { sessionWorkspace } from "../../models/resolve";
25
+ import { queueEvent } from "../../orchestrator/events";
26
+ import { brakesOn } from "../../orchestrator/brakes";
27
+ import { isPaused } from "../../orchestrator/pause";
28
+ import { getProject } from "../../projects";
29
+ import { planFor, splitOf } from "../../plans/features";
30
+ import { moveTask } from "../move-flow";
31
+ import * as client from "./client";
32
+ import { meetsNeeds, needsOf } from "../placement";
33
+ import { remotePlanPath } from "./plan-path";
34
+ import { DEFAULT_PRIORITY, isPriority } from "../schedule-types";
35
+ import { localHolds } from "../hold-checks";
36
+ import { expandHome } from "../session";
37
+ import { orchestratorOf } from "../starter";
38
+ import { setPlan } from "../../db/session-parts";
35
39
 
36
40
  export { remotePlanPath };
37
41
 
38
42
  const getSession = (id: string) =>
39
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
43
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
40
44
  | Session
41
45
  | undefined;
42
46
 
43
- function readCopy(id: string): string | null {
47
+ async function readCopy(id: string): Promise<string | null> {
44
48
  try {
45
- return fs.readFileSync(remotePlanPath(id), "utf8");
49
+ return await fs.readFile(remotePlanPath(id), "utf8");
46
50
  } catch {
47
51
  return null;
48
52
  }
@@ -81,29 +85,27 @@ export function implementTarget(
81
85
  return meetsNeeds(needs, machineCapabilities()) ? "home" : "there";
82
86
  }
83
87
 
84
- // One look at a mirror that is planning: if its plan is written there, copy
85
- // it, say so, and carry on for a plan-first task.
86
- async function planWritten(s: Session): Promise<void> {
88
+ /**
89
+ * A mirror that is planning, its machine's listing of it in hand: if its plan
90
+ * is written there, copy it, say so, and carry on for a plan-first task.
91
+ */
92
+ export async function planWritten(
93
+ s: Session,
94
+ view: { planReady?: boolean } | undefined
95
+ ): Promise<void> {
87
96
  const link = hostLink(s.host_id);
88
- if (!link) return;
89
- const view = await hostTasks(link)
90
- .then(({ tasks }) => tasks.find((t) => t?.id === s.id))
91
- .catch(() => undefined);
92
- if (!view?.planReady) return;
93
- const { text } = await hostApi<{ text: string | null }>(
94
- link,
95
- `/api/tasks/${encodeURIComponent(s.id)}/plan`,
96
- { timeout: 30000 }
97
+ if (!link || !view?.planReady) return;
98
+ const text = await client.plan(link, s.id);
99
+ const claimed = setPlan(
100
+ db,
101
+ s.id,
102
+ { plan_phase: "awaiting" },
103
+ { plan_phase: "planning" }
97
104
  );
98
- const claimed = db
99
- .prepare(
100
- `UPDATE sessions SET plan_phase = 'awaiting' WHERE id = ? AND plan_phase = 'planning'`
101
- )
102
- .run(s.id).changes;
103
105
  if (!claimed) return;
104
106
  if (text !== null) {
105
- fs.mkdirSync(path.dirname(remotePlanPath(s.id)), { recursive: true });
106
- fs.writeFileSync(remotePlanPath(s.id), text);
107
+ await fs.mkdir(path.dirname(remotePlanPath(s.id)), { recursive: true });
108
+ await fs.writeFile(remotePlanPath(s.id), text);
107
109
  }
108
110
  const fresh = getSession(s.id)!;
109
111
  const split = checkedSplit(fresh, text);
@@ -121,7 +123,7 @@ async function planWritten(s: Session): Promise<void> {
121
123
  ? "moving here to implement once this machine is free"
122
124
  : "waiting for approval, not implementing (approve_plan to go ahead, drop to reject)";
123
125
  const note = planNoteFor(fresh, remotePlanPath(s.id), next);
124
- db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(note, s.id);
126
+ setPlan(db, s.id, { plan_note: note });
125
127
  const workspaceId = sessionWorkspace(fresh);
126
128
  if (orchestratorOf(fresh.started_by) && workspaceId) {
127
129
  queueEvent(workspaceId, `plan-done:${s.id}`, s.id, note);
@@ -136,7 +138,7 @@ async function planWritten(s: Session): Promise<void> {
136
138
  if (!goOn) {
137
139
  try {
138
140
  const planned = text ? planFor(fresh, text) : null;
139
- (await import("../notify/push/events")).pushPlanAwaiting(
141
+ (await import("../../notify/push/events")).pushPlanAwaiting(
140
142
  s.id,
141
143
  s.name,
142
144
  planned?.pieces.length ?? 1
@@ -146,7 +148,7 @@ async function planWritten(s: Session): Promise<void> {
146
148
  }
147
149
  return;
148
150
  }
149
- await continueRemotePlan(fresh);
151
+ await continuePlanThere(fresh);
150
152
  if (auto)
151
153
  recordAutoApproval({
152
154
  source: "plan",
@@ -161,28 +163,29 @@ async function planWritten(s: Session): Promise<void> {
161
163
  * Carries a written plan on: approves it on the machine that planned it, or
162
164
  * hands it to the move home. Returns what it will implement on.
163
165
  */
164
- export async function continueRemotePlan(
166
+ export async function continuePlanThere(
165
167
  s: Session,
166
168
  message?: string
167
169
  ): Promise<string> {
168
170
  const link = requireHostLink(s.host_id!);
169
- const split = checkedSplit(s, readCopy(s.id));
171
+ const split = checkedSplit(s, await readCopy(s.id));
170
172
  if (implementTarget(s, split) === "there") {
171
- const out = await hostApi<{ model?: string }>(
172
- link,
173
- `/api/tasks/${encodeURIComponent(s.id)}/approve-plan`,
174
- { body: message ? { message } : {}, timeout: 60000 }
173
+ const out = await client.approvePlan(link, s.id, message);
174
+ setPlan(
175
+ db,
176
+ s.id,
177
+ { plan_phase: "implementing" },
178
+ { plan_phase: "awaiting" }
175
179
  );
176
- db.prepare(
177
- `UPDATE sessions SET plan_phase = 'implementing' WHERE id = ? AND plan_phase = 'awaiting'`
178
- ).run(s.id);
179
- forgetHostTasks(link.hostId);
180
+ client.forget(link.hostId);
180
181
  return out.model ?? link.hostName;
181
182
  }
182
- db.prepare(
183
- `UPDATE sessions SET plan_phase = 'handoff', plan_handoff_message = ?
184
- WHERE id = ? AND plan_phase = 'awaiting'`
185
- ).run(message ?? null, s.id);
183
+ setPlan(
184
+ db,
185
+ s.id,
186
+ { plan_phase: "handoff", plan_handoff_message: message ?? null },
187
+ { plan_phase: "awaiting" }
188
+ );
186
189
  const workspaceId = sessionWorkspace(s);
187
190
  if (orchestratorOf(s.started_by) && workspaceId)
188
191
  queueEvent(
@@ -197,18 +200,19 @@ export async function continueRemotePlan(
197
200
  return "this machine, once it is free";
198
201
  }
199
202
 
200
- const handingOff = new Set<string>();
203
+ export const handingOff = new Set<string>();
201
204
 
202
- async function finishArrival(id: string, message?: string): Promise<boolean> {
205
+ export async function finishArrival(
206
+ id: string,
207
+ message?: string
208
+ ): Promise<boolean> {
203
209
  await implementArrived(id, message || undefined);
204
- db.prepare(
205
- `UPDATE sessions SET plan_handoff_message = NULL WHERE id = ?`
206
- ).run(id);
210
+ setPlan(db, id, { plan_handoff_message: null });
207
211
  return true;
208
212
  }
209
213
 
210
214
  // Fired, not awaited: a move takes minutes and the queue tick must not wait.
211
- function fire(id: string, run: () => Promise<unknown>) {
215
+ export function fire(id: string, run: () => Promise<unknown>) {
212
216
  if (handingOff.has(id)) return;
213
217
  void run().catch((e) => console.error(`Moving ${id} here failed:`, e));
214
218
  }
@@ -253,10 +257,11 @@ export async function handoffHome(
253
257
  const edits = s.plan_edits ?? null;
254
258
  const arrived = await moveTask(id, LOCAL);
255
259
  // The arrived row keeps the pending hand-off until implementing has
256
- // started, so a restart in between is picked up by syncRemotePlans.
257
- db.prepare(
258
- `UPDATE sessions SET plan_edits = COALESCE(?, plan_edits), plan_handoff_message = ? WHERE id = ?`
259
- ).run(edits, message ?? "", arrived.id);
260
+ // started, so a restart in between is picked up by syncMirrors.
261
+ setPlan(db, arrived.id, {
262
+ plan_edits: edits ?? undefined,
263
+ plan_handoff_message: message ?? "",
264
+ });
260
265
  handingOff.add(arrived.id);
261
266
  try {
262
267
  return await finishArrival(arrived.id, message);
@@ -267,42 +272,3 @@ export async function handoffHome(
267
272
  handingOff.delete(id);
268
273
  }
269
274
  }
270
-
271
- /** Every mirror whose plan is being written, or approved and coming here. */
272
- export async function syncRemotePlans(): Promise<void> {
273
- const rows = db
274
- .prepare(
275
- `SELECT * FROM sessions
276
- WHERE plan_waits_for_caller = 1 AND plan_phase IN ('planning', 'handoff')
277
- AND task_status = 'running' AND archived_at IS NULL`
278
- )
279
- .all() as Session[];
280
- for (const s of rows) {
281
- if (!isRemoteHost(s.host_id)) continue;
282
- try {
283
- if (s.plan_phase === "planning") await planWritten(s);
284
- else fire(s.id, () => handoffHome(s.id));
285
- } catch (e) {
286
- console.error(`Plan on ${s.host_id} for ${s.id} failed:`, e);
287
- }
288
- }
289
- // A task that arrived but never began implementing (a restart between).
290
- const arrived = db
291
- .prepare(
292
- `SELECT id, plan_handoff_message FROM sessions
293
- WHERE plan_phase = 'awaiting' AND plan_handoff_message IS NOT NULL
294
- AND task_status = 'running' AND archived_at IS NULL AND peer_mirror IS NOT 1`
295
- )
296
- .all() as { id: string; plan_handoff_message: string }[];
297
- for (const a of arrived) {
298
- if (isRemoteHost(getSession(a.id)?.host_id)) continue;
299
- fire(a.id, async () => {
300
- handingOff.add(a.id);
301
- try {
302
- await finishArrival(a.id, a.plan_handoff_message);
303
- } finally {
304
- handingOff.delete(a.id);
305
- }
306
- });
307
- }
308
- }
@@ -0,0 +1,26 @@
1
+ /** The task routes a linked machine's AgentOS answers, and how long to wait for each. */
2
+
3
+ export const taskPath = (id: string, leaf: string) =>
4
+ `/api/tasks/${encodeURIComponent(id)}/${leaf}`;
5
+
6
+ export const LIST = "/api/tasks";
7
+ export const IMPORT = "/api/tasks/import";
8
+ export const HELD_REPLIES = "/api/tasks/held-replies";
9
+ export const arrivedPath = (from: string) =>
10
+ `/api/tasks/arrived?from=${encodeURIComponent(from)}`;
11
+
12
+ export const TIMEOUT = {
13
+ list: 8000,
14
+ start: 180000,
15
+ plan: 30000,
16
+ approvePlan: 60000,
17
+ message: 20000,
18
+ hold: 20000,
19
+ chat: 10000,
20
+ stop: 30000,
21
+ done: 120000,
22
+ merge: 300000,
23
+ replies: 8000,
24
+ exportOut: 300000,
25
+ importBundle: 600000,
26
+ } as const;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Tasks that run on another machine's AgentOS. That machine schedules them;
3
+ * this one keeps a mirror row (same id, the host's id) so the sidebar lists
4
+ * them and the terminal attaches through that machine's AgentOS.
5
+ */
6
+
7
+ import { db, type Project, type Session } from "../../db";
8
+ import { isRemoteHost } from "../../hosts";
9
+ import { requireHostLink } from "../../hosts/remote-api";
10
+ import { orchestratedStart } from "../held";
11
+ import { projectRef } from "../project-ref";
12
+ import { orchestratorOf } from "../starter";
13
+ import type { TaskStatus } from "../state";
14
+ import * as client from "./client";
15
+ import { setPlan, setTask, startTaskClock } from "../../db/session-parts";
16
+
17
+ export function isMirror(session: Session): boolean {
18
+ return isRemoteHost(session.host_id);
19
+ }
20
+
21
+ export function mirrorTask(
22
+ hostId: string,
23
+ projectId: string | null,
24
+ remote: Session,
25
+ startedBy?: string | null
26
+ ): void {
27
+ db.transaction(() => {
28
+ const inserted = db
29
+ .prepare(
30
+ `INSERT INTO sessions (id, name, tmux_name, working_directory, model, group_path,
31
+ agent_type, auto_approve, project_id, host_id, worktree_path, branch_name,
32
+ base_branch, name_source, view)
33
+ VALUES (?, ?, ?, ?, ?, 'sessions', 'claude', 1, ?, ?, ?, ?, ?, 'user', ?)
34
+ ON CONFLICT(id) DO NOTHING`
35
+ )
36
+ .run(
37
+ remote.id,
38
+ remote.name,
39
+ remote.tmux_name,
40
+ remote.working_directory,
41
+ remote.model,
42
+ projectId,
43
+ hostId,
44
+ remote.worktree_path,
45
+ remote.branch_name,
46
+ remote.base_branch,
47
+ // As that machine runs it: one that predates chat tasks says terminal.
48
+ remote.view === "chat" ? "chat" : "terminal"
49
+ );
50
+ if (inserted.changes)
51
+ setTask(db, remote.id, {
52
+ task_prompt: remote.task_prompt,
53
+ task_status: "running",
54
+ });
55
+ // What carryTaskState gives a moved task's mirror: the orchestrator that
56
+ // holds it, and when it began (so its PR is judged from then).
57
+ if (startedBy) {
58
+ db.prepare(
59
+ `UPDATE sessions SET started_by = COALESCE(started_by, ?) WHERE id = ?`
60
+ ).run(startedBy, remote.id);
61
+ startTaskClock(db, remote.id);
62
+ }
63
+ })();
64
+ }
65
+
66
+ export async function startRemoteTask(
67
+ hostId: string,
68
+ project: Project,
69
+ opts: {
70
+ id: string;
71
+ prompt: string;
72
+ name?: string;
73
+ model?: string;
74
+ baseBranch?: string;
75
+ view?: "chat" | "terminal";
76
+ // The orchestrator that started it: that machine holds the task for it.
77
+ startedBy?: string | null;
78
+ // It plans first: there it plans, stops, and waits for this machine.
79
+ plan?: { only: boolean; reason: string | null };
80
+ }
81
+ ): Promise<Session> {
82
+ const link = requireHostLink(hostId);
83
+ const { startedBy: by, plan, ...rest } = opts;
84
+ const startedBy = orchestratorOf(by);
85
+ if (startedBy)
86
+ await client.requireCapability(
87
+ link,
88
+ "held-start",
89
+ "an orchestrator's task",
90
+ "hold"
91
+ );
92
+ if (plan)
93
+ await client.requireCapability(link, "remote-plan", "a plan step", "plan");
94
+ const { session } = await client.start(link, {
95
+ project: await projectRef(project),
96
+ ...rest,
97
+ ...(plan && {
98
+ plan: true,
99
+ plan_only: true,
100
+ plan_waits: true,
101
+ plan_reason: plan.reason ?? undefined,
102
+ }),
103
+ orchestrated: startedBy ? orchestratedStart(startedBy) : undefined,
104
+ });
105
+ mirrorTask(hostId, project.id, session, startedBy);
106
+ if (plan)
107
+ setPlan(db, session.id, {
108
+ plan_phase: "planning",
109
+ plan_only: plan.only ? 1 : null,
110
+ plan_decided: 1,
111
+ plan_reason: plan.reason,
112
+ plan_waits_for_caller: 1,
113
+ });
114
+ client.forget(hostId);
115
+ return db
116
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
117
+ .get(session.id) as Session;
118
+ }
119
+
120
+ export function setMirrorStatus(id: string, status: TaskStatus): void {
121
+ setTask(db, id, { task_status: status }, { task_status: "running" });
122
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The one loop that brings this machine's mirrors in line with what their
3
+ * machines say. Each tick reads every machine's task list once and applies it
4
+ * to each mirror: finished and moved, a plan written, a plan note to tell the
5
+ * orchestrator about, an approved plan coming home. Nothing else polls a
6
+ * machine's tasks to write a mirror.
7
+ */
8
+
9
+ import { db, type Session } from "../../db";
10
+ import { isRemoteHost } from "../../hosts";
11
+ import { hostLink, cleanRemoteText } from "../../hosts/remote-api";
12
+ import { sessionWorkspace } from "../../models/resolve";
13
+ import { queueEvent } from "../../orchestrator/events";
14
+ import { orchestratorOf } from "../starter";
15
+ import type { TaskStatus } from "../state";
16
+ import type { TaskView } from "../index";
17
+ import * as client from "./client";
18
+ import {
19
+ finishArrival,
20
+ fire,
21
+ handingOff,
22
+ handoffHome,
23
+ planWritten,
24
+ } from "./plan";
25
+ import { setMirrorStatus } from "./start";
26
+ import { setPlan } from "../../db/session-parts";
27
+
28
+ const FINISHED: Record<string, TaskStatus> = {
29
+ merged: "merged",
30
+ dropped: "dropped",
31
+ done: "done",
32
+ };
33
+
34
+ const getSession = (id: string) =>
35
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
36
+ | Session
37
+ | undefined;
38
+
39
+ // The plan-written line a held task stored on its machine (plan-step.ts),
40
+ // once per distinct plan: the mirror keeps the last one it told about.
41
+ function announcePlanNote(s: Session, view: TaskView): void {
42
+ const workspaceId = sessionWorkspace(s);
43
+ if (!workspaceId || !orchestratorOf(s.started_by)) return;
44
+ const note =
45
+ typeof view.planNote === "string"
46
+ ? cleanRemoteText(view.planNote).slice(0, 2000)
47
+ : "";
48
+ if (!note || note === s.plan_note) return;
49
+ setPlan(db, s.id, { plan_note: note });
50
+ queueEvent(workspaceId, `plan-done:${s.id}`, s.id, note);
51
+ }
52
+
53
+ async function applyView(
54
+ s: Session,
55
+ view: TaskView | undefined,
56
+ gone: Set<string>
57
+ ): Promise<void> {
58
+ if (!view) {
59
+ if (gone.has(s.id)) setMirrorStatus(s.id, "moved");
60
+ return;
61
+ }
62
+ if (FINISHED[view.state]) {
63
+ setMirrorStatus(s.id, FINISHED[view.state]);
64
+ return;
65
+ }
66
+ if (!s.plan_waits_for_caller) return announcePlanNote(s, view);
67
+ if (s.plan_phase === "planning") await planWritten(s, view);
68
+ else if (s.plan_phase === "handoff") fire(s.id, () => handoffHome(s.id));
69
+ }
70
+
71
+ /** Every task this machine mirrors from a linked machine, that machine's word applied. */
72
+ export async function syncMirrors(): Promise<void> {
73
+ const rows = (
74
+ db
75
+ .prepare(
76
+ `SELECT * FROM session_rows
77
+ WHERE task_prompt IS NOT NULL AND task_status = 'running'
78
+ AND archived_at IS NULL AND peer_mirror IS NOT 1
79
+ AND host_id IS NOT NULL`
80
+ )
81
+ .all() as Session[]
82
+ ).filter((s) => isRemoteHost(s.host_id));
83
+ const byHost = new Map<string, Session[]>();
84
+ for (const s of rows)
85
+ byHost.set(s.host_id, [...(byHost.get(s.host_id) ?? []), s]);
86
+ for (const [hostId, mirrors] of byHost) {
87
+ const link = hostLink(hostId);
88
+ if (!link) continue;
89
+ let listing: Awaited<ReturnType<typeof client.list>>;
90
+ try {
91
+ listing = await client.list(link);
92
+ } catch {
93
+ continue;
94
+ }
95
+ const live = new Map(
96
+ (Array.isArray(listing.tasks) ? listing.tasks : []).map((t) => [t?.id, t])
97
+ );
98
+ const gone = new Set((listing.moved ?? []).map((m) => m.id));
99
+ for (const s of mirrors) {
100
+ try {
101
+ await applyView(s, live.get(s.id), gone);
102
+ } catch (e) {
103
+ console.error(`Mirror of ${s.id} on ${hostId} failed:`, e);
104
+ }
105
+ }
106
+ }
107
+ // A task that arrived but never began implementing (a restart between).
108
+ const arrived = db
109
+ .prepare(
110
+ `SELECT id, plan_handoff_message FROM session_rows
111
+ WHERE plan_phase = 'awaiting' AND plan_handoff_message IS NOT NULL
112
+ AND task_status = 'running' AND archived_at IS NULL AND peer_mirror IS NOT 1`
113
+ )
114
+ .all() as { id: string; plan_handoff_message: string }[];
115
+ for (const a of arrived) {
116
+ if (isRemoteHost(getSession(a.id)?.host_id)) continue;
117
+ fire(a.id, async () => {
118
+ handingOff.add(a.id);
119
+ try {
120
+ await finishArrival(a.id, a.plan_handoff_message);
121
+ } finally {
122
+ handingOff.delete(a.id);
123
+ }
124
+ });
125
+ }
126
+ }
@@ -0,0 +1,41 @@
1
+ import type { TaskView } from "../index";
2
+
3
+ /**
4
+ * What this machine's task API does, for another machine deciding whether
5
+ * it can trust it with a pinned merge or a move; keyed-start: a session
6
+ * start takes a key, so it's safe to retry (lib/sessions/launch.ts).
7
+ */
8
+ export const TASK_CAPABILITIES = [
9
+ "pinned-merge",
10
+ "move",
11
+ "chat-turn",
12
+ "keyed-start",
13
+ "chat-move",
14
+ // Holds an orchestrator's task for the machine that sent it, and answers
15
+ // its messages, reads, stops and done (contract/authorize.ts).
16
+ "orchestrated-move",
17
+ // A held task's chat facts and its messages for its orchestrator.
18
+ "held-chat-facts",
19
+ "held-replies",
20
+ // A keyed start that carries an orchestrator's description holds the task
21
+ // for the device that sent it, as a move does (lib/tasks/held.ts).
22
+ "held-start",
23
+ // A plan placed here by another machine stops at the plan for that machine
24
+ // to decide what follows (contract/plan.ts).
25
+ "remote-plan",
26
+ // Every task route a peer calls checks the holder or starter the same way.
27
+ // compat: the shims marked "task-contract-1" in contract/ go once every
28
+ // linked machine reports this.
29
+ "task-contract-1",
30
+ ] as const;
31
+
32
+ export interface HostTasks {
33
+ tasks: TaskView[];
34
+ moved?: { id: string; movedTo: string | null }[];
35
+ capabilities?: string[];
36
+ }
37
+
38
+ export interface ChatFacts {
39
+ blocked: string | null;
40
+ turn: string | null;
41
+ }
@@ -1,21 +1,15 @@
1
1
  /**
2
2
  * How tasks on other machines look here: each mirror's view as its machine
3
- * reports it. A mirror follows the machine's word: finished when it says
4
- * finished, moved when it says moved, never because it went missing once.
3
+ * reports it. Reading only: the sync loop (sync.ts) is what records that a
4
+ * mirror finished or moved, never because it went missing once.
5
5
  */
6
6
 
7
- import type { Session } from "../db";
8
- import { requireHostLink, type HostLink } from "../hosts/remote-api";
9
- import { deriveTaskState, type TaskStatus } from "./state";
10
- import { hostTasks, setMirrorStatus } from "./remote";
11
- import type { TaskView } from "./index";
12
- import { viewOf } from "./move-targets";
13
-
14
- const FINISHED: Record<string, TaskStatus> = {
15
- merged: "merged",
16
- dropped: "dropped",
17
- done: "done",
18
- };
7
+ import type { Session } from "../../db";
8
+ import { requireHostLink, type HostLink } from "../../hosts/remote-api";
9
+ import { deriveTaskState } from "../state";
10
+ import type { TaskView } from "../index";
11
+ import { viewOf } from "../move-targets";
12
+ import * as client from "./client";
19
13
 
20
14
  function offlineView(
21
15
  session: Session,
@@ -57,13 +51,13 @@ async function viewsOn(hostId: string, rows: Session[]): Promise<TaskView[]> {
57
51
  return rows.map((s) => offlineView(s, "", (err as Error).message));
58
52
  }
59
53
  try {
60
- const { tasks, moved = [] } = await hostTasks(link);
54
+ const { tasks, moved = [] } = await client.list(link);
61
55
  const live = new Map(tasks.map((t) => [t.id, t]));
62
56
  const gone = new Set(moved.map((m) => m.id));
63
57
  return rows.flatMap((s): TaskView[] => {
64
58
  const t = live.get(s.id);
65
59
  if (!t) {
66
- if (gone.has(s.id)) return (setMirrorStatus(s.id, "moved"), []);
60
+ if (gone.has(s.id)) return [];
67
61
  return [
68
62
  offlineView(
69
63
  s,
@@ -72,7 +66,6 @@ async function viewsOn(hostId: string, rows: Session[]): Promise<TaskView[]> {
72
66
  ),
73
67
  ];
74
68
  }
75
- if (FINISHED[t.state]) setMirrorStatus(s.id, FINISHED[t.state]);
76
69
  return [
77
70
  {
78
71
  ...t,
@@ -92,7 +85,7 @@ async function viewsOn(hostId: string, rows: Session[]): Promise<TaskView[]> {
92
85
  }
93
86
  }
94
87
 
95
- export async function remoteTaskViews(mirrors: Session[]): Promise<TaskView[]> {
88
+ export async function mirrorViews(mirrors: Session[]): Promise<TaskView[]> {
96
89
  const byHost = new Map<string, Session[]>();
97
90
  for (const s of mirrors)
98
91
  byHost.set(s.host_id, [...(byHost.get(s.host_id) ?? []), s]);