@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
@@ -1,6 +1,7 @@
1
1
  import { db, type Session } from "../db";
2
2
  import { endPauseOnFinish } from "./pause-hold";
3
- import { isMirror, remoteTaskAction } from "./remote";
3
+ import { dropThere, mergeThere } from "./contract/actions";
4
+ import { isMirror } from "./contract/start";
4
5
  import { deleteWorktree } from "../worktrees";
5
6
  import { settleWorktree, type MergedAs } from "../done/worktree";
6
7
  import {
@@ -15,7 +16,8 @@ import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
15
16
  import { endSessionAgent } from "../sessions/end";
16
17
  import { run } from "./gh";
17
18
  import { pushMerged } from "../notify/push/events";
18
- import { gitFetch, gitPush } from "../git-exec";
19
+ import { gitFetch } from "../git-exec";
20
+ import { deleteTaskBranch } from "./remote-branch";
19
21
  import {
20
22
  mergePolicy,
21
23
  type MergeMethod,
@@ -37,9 +39,7 @@ import { captureMergedTask } from "../session-logs";
37
39
  import { sessionEnded } from "../experiments/hooks";
38
40
  import { snapshotWorktree } from "../resurrect/snapshot";
39
41
  import { keepSessionFiles } from "../session-artifacts/collect";
40
-
41
- // Never deleted, whatever a setting says.
42
- export const PROTECTED_BRANCHES = new Set(["main", "master", "HEAD"]);
42
+ import { setPr, setTask } from "../db/session-parts";
43
43
 
44
44
  export const sessionMergePolicy = (session: Session): MergePolicy =>
45
45
  mergePolicy(session.project_id ? getProject(session.project_id) : null);
@@ -50,7 +50,14 @@ export const sessionMergePolicy = (session: Session): MergePolicy =>
50
50
  async function cleanup(
51
51
  session: Session,
52
52
  repo: string,
53
- opts: { keepRemoteBranch?: boolean; merged?: MergedAs } = {}
53
+ opts: {
54
+ keepRemoteBranch?: boolean;
55
+ merged?: MergedAs;
56
+ // The head the PR merged or closed at.
57
+ prHead?: string | null;
58
+ // A drop of a task that never had a PR.
59
+ noPr?: boolean;
60
+ } = {}
54
61
  ): Promise<void> {
55
62
  const keepRemoteBranch = opts.keepRemoteBranch ?? false;
56
63
  await endSessionAgent(session);
@@ -66,14 +73,14 @@ async function cleanup(
66
73
  }
67
74
  // A stacked child's PR targets this branch: deleting it on origin would
68
75
  // make GitHub close that PR, and a closed PR can't be retargeted.
69
- if (
70
- session.branch_name &&
71
- !keepRemoteBranch &&
72
- !PROTECTED_BRANCHES.has(session.branch_name)
73
- ) {
74
- await gitPush(repo, ["origin", "--delete", session.branch_name]).catch(
75
- () => {}
76
- );
76
+ if (session.branch_name && !keepRemoteBranch) {
77
+ await deleteTaskBranch({
78
+ repo,
79
+ branch: session.branch_name,
80
+ prHead: opts.prHead,
81
+ noPr: opts.noPr,
82
+ keep: [session.base_branch],
83
+ });
77
84
  }
78
85
  await gitFetch(repo, ["--prune", "--quiet"]).catch(() => {});
79
86
  forgetPR(session.id);
@@ -110,6 +117,7 @@ async function finishMerge(
110
117
  await cleanup(session, repo, {
111
118
  keepRemoteBranch: stuck || !policy.delete_remote_branch,
112
119
  merged: { prHead },
120
+ prHead,
113
121
  });
114
122
  // After cleanup's fetch, so the base branch holds the merged files.
115
123
  inBackground(`re-publish docs after task ${session.id}`, () =>
@@ -141,7 +149,7 @@ export async function signOffTask(
141
149
  // Its own machine checks the gates and merges.
142
150
  // That machine's settings chose the method; null if it didn't say.
143
151
  if (isMirror(session))
144
- return remoteTaskAction(session, "merge", opts.head, session.base_branch);
152
+ return mergeThere(session, opts.head, session.base_branch);
145
153
  const repo = projectPathFor(session);
146
154
  if (!repo) throw new Error("Task has no project");
147
155
  const refusal = signOffRefusal(id);
@@ -178,9 +186,10 @@ export async function signOffTask(
178
186
  method: policy.method,
179
187
  head: opts.head,
180
188
  });
181
- db.prepare(
182
- `UPDATE sessions SET task_status = 'merged', pr_status = 'merged' WHERE id = ?`
183
- ).run(id);
189
+ db.transaction(() => {
190
+ setTask(db, id, { task_status: "merged" });
191
+ setPr(db, id, { pr_status: "merged" });
192
+ })();
184
193
  endPauseOnFinish(id);
185
194
  db.prepare(
186
195
  `UPDATE stack_items SET status = 'merged', error = NULL WHERE session_id = ?`
@@ -225,7 +234,7 @@ export async function dropTask(id: string): Promise<void> {
225
234
  if (session.task_status !== "running")
226
235
  throw new Error(`Task is already ${session.task_status}`);
227
236
  if (isMirror(session)) {
228
- await remoteTaskAction(session, "drop");
237
+ await dropThere(session);
229
238
  return;
230
239
  }
231
240
  const repo = projectPathFor(session);
@@ -247,14 +256,12 @@ export async function dropTask(id: string): Promise<void> {
247
256
  if (pr?.state === "OPEN") {
248
257
  await run("gh", ["pr", "close", String(pr.number)], repo).catch(() => {});
249
258
  }
250
- db.prepare(`UPDATE sessions SET task_status = 'dropped' WHERE id = ?`).run(
251
- id
252
- );
259
+ setTask(db, id, { task_status: "dropped" });
253
260
  endPauseOnFinish(id);
254
261
  db.prepare(
255
262
  `UPDATE stack_items SET status = 'dropped' WHERE session_id = ?`
256
263
  ).run(id);
257
264
  syncTaskCardInBackground(session, "dropped", pr);
258
265
  await snapshot;
259
- await cleanup(session, repo);
266
+ await cleanup(session, repo, { prHead: pr?.head, noPr: !pr });
260
267
  }
@@ -8,7 +8,8 @@
8
8
 
9
9
  import { db, type Session } from "../db";
10
10
  import { sendMessage } from "../bus";
11
- import { hostApi, type HostLink } from "../hosts/remote-api";
11
+ import type { HostLink } from "../hosts/remote-api";
12
+ import * as client from "./contract/client";
12
13
 
13
14
  const PULL_EVERY_MS = 5000;
14
15
  const MISSING_FOR_MS = 10 * 60 * 1000;
@@ -27,13 +28,13 @@ interface Pulled {
27
28
  body: string;
28
29
  }
29
30
 
30
- // Held means an orchestrator started it, as isHeldRemote reads it.
31
+ // Held means an orchestrator started it, as isHeldRemote reads it (contract/held-calls.ts).
31
32
  const HELD_BY = `started_by IN (SELECT id FROM sessions WHERE role = 'orchestrator')`;
32
33
 
33
34
  const heldMirror = (hostId: string, id: string) =>
34
35
  db
35
36
  .prepare(
36
- `SELECT * FROM sessions WHERE id = ? AND host_id = ?
37
+ `SELECT * FROM session_rows WHERE id = ? AND host_id = ?
37
38
  AND ${HELD_BY} AND task_status IN ('running', 'moving')`
38
39
  )
39
40
  .get(id, hostId) as Session | undefined;
@@ -41,7 +42,7 @@ const heldMirror = (hostId: string, id: string) =>
41
42
  export function hasHeldMirrors(hostId: string): boolean {
42
43
  return !!db
43
44
  .prepare(
44
- `SELECT 1 FROM sessions WHERE host_id = ? AND ${HELD_BY}
45
+ `SELECT 1 FROM session_rows WHERE host_id = ? AND ${HELD_BY}
45
46
  AND task_prompt IS NOT NULL AND task_status IN ('running', 'moving')`
46
47
  )
47
48
  .get(hostId);
@@ -58,11 +59,7 @@ export async function pullHeldReplies(link: HostLink): Promise<number> {
58
59
  try {
59
60
  let messages: Pulled[];
60
61
  try {
61
- const res = await hostApi<{ messages?: unknown }>(
62
- link,
63
- "/api/tasks/held-replies",
64
- { timeout: 8000 }
65
- );
62
+ const res = await client.replies(link);
66
63
  messages = (Array.isArray(res.messages) ? res.messages : []).filter(
67
64
  (m): m is Pulled =>
68
65
  !!m &&
@@ -98,10 +95,7 @@ export async function pullHeldReplies(link: HostLink): Promise<number> {
98
95
  }
99
96
  }
100
97
  if (handled.length)
101
- await hostApi(link, "/api/tasks/held-replies", {
102
- body: { ids: handled },
103
- timeout: 8000,
104
- }).catch(() => undefined);
98
+ await client.ackReplies(link, handled).catch(() => undefined);
105
99
  return delivered;
106
100
  } finally {
107
101
  busy.delete(hostId);
package/lib/tasks/held.ts CHANGED
@@ -10,6 +10,7 @@ import os from "os";
10
10
  import { db, type Session } from "../db";
11
11
  import { isRemoteHost } from "../hosts";
12
12
  import type { OrchestratedBundle } from "./move-bundle";
13
+ import { setPlacement, setTask } from "../db/session-parts";
13
14
 
14
15
  export function holderRefusal(
15
16
  session: Pick<Session, "held_by_device" | "held_from">,
@@ -26,7 +27,7 @@ export function heldRefusal(
26
27
  device: string | null | undefined
27
28
  ): string | null {
28
29
  const session = db
29
- .prepare(`SELECT held_by_device, held_from FROM sessions WHERE id = ?`)
30
+ .prepare(`SELECT held_by_device, held_from FROM session_rows WHERE id = ?`)
30
31
  .get(id) as Pick<Session, "held_by_device" | "held_from"> | undefined;
31
32
  return session ? holderRefusal(session, device) : null;
32
33
  }
@@ -41,17 +42,15 @@ export function holdFor(
41
42
  since?: string | null;
42
43
  }
43
44
  ): void {
44
- db.prepare(
45
- `UPDATE sessions SET held_by_device = ?, held_from = ?, held_for = ?, held_for_name = ?,
46
- task_since = COALESCE(?, task_since) WHERE id = ?`
47
- ).run(
48
- holder,
49
- held.heldFrom,
50
- held.orchestrator?.id ?? null,
51
- held.orchestrator?.name ?? null,
52
- held.since ?? null,
53
- id
54
- );
45
+ db.transaction(() => {
46
+ setPlacement(db, id, {
47
+ held_by_device: holder,
48
+ held_from: held.heldFrom,
49
+ held_for: held.orchestrator?.id ?? null,
50
+ held_for_name: held.orchestrator?.name ?? null,
51
+ });
52
+ setTask(db, id, { task_since: held.since ?? undefined });
53
+ })();
55
54
  }
56
55
 
57
56
  /**
@@ -81,10 +80,7 @@ const UNRECORDED_STARTER = "unrecorded";
81
80
 
82
81
  /** Records the paired device that started a task here, the only one that may later hold it. */
83
82
  export function recordStarter(id: string, device: string): void {
84
- db.prepare(`UPDATE sessions SET started_by_device = ? WHERE id = ?`).run(
85
- device,
86
- id
87
- );
83
+ setPlacement(db, id, { started_by_device: device });
88
84
  }
89
85
 
90
86
  type Holdable = Pick<
@@ -124,7 +120,7 @@ export function holdExisting(
124
120
  const s = db
125
121
  .prepare(
126
122
  `SELECT held_by_device, started_by, started_by_device, task_status, host_id, task_prompt
127
- FROM sessions WHERE id = ?`
123
+ FROM session_rows WHERE id = ?`
128
124
  )
129
125
  .get(id) as Holdable | undefined;
130
126
  if (!s?.task_prompt || s.task_status !== "running") return false;
@@ -145,7 +141,7 @@ export function planWaitRefusal(
145
141
  ): string | null {
146
142
  const s = db
147
143
  .prepare(
148
- `SELECT plan_waits_for_caller, started_by_device, host_id FROM sessions WHERE id = ?`
144
+ `SELECT plan_waits_for_caller, started_by_device, host_id FROM session_rows WHERE id = ?`
149
145
  )
150
146
  .get(id) as
151
147
  | Pick<Session, "plan_waits_for_caller" | "started_by_device" | "host_id">
@@ -38,6 +38,7 @@ import {
38
38
  validateBundle,
39
39
  type TaskBundle,
40
40
  } from "./move-bundle";
41
+ import { setPlan, setTask } from "../db/session-parts";
41
42
 
42
43
  // Imports in flight in this process, by move id.
43
44
  const g = globalThis as unknown as { __agentosImporting?: Set<string> };
@@ -47,7 +48,7 @@ const importing = (g.__agentosImporting ??= new Set());
47
48
  export const arrived = (moveId: string) =>
48
49
  db
49
50
  .prepare(
50
- `SELECT * FROM sessions WHERE moved_from = ? AND host_id = 'local'
51
+ `SELECT * FROM session_rows WHERE moved_from = ? AND host_id = 'local'
51
52
  AND task_status IN ('running', 'moving', 'moved', 'merged', 'done')`
52
53
  )
53
54
  .get(moveId) as Session | undefined;
@@ -56,7 +57,7 @@ export const arrived = (moveId: string) =>
56
57
  function refuseBusyBranch(projectId: string, branch: string): void {
57
58
  const busy = db
58
59
  .prepare(
59
- `SELECT id FROM sessions WHERE project_id = ? AND branch_name = ?
60
+ `SELECT id FROM session_rows WHERE project_id = ? AND branch_name = ?
60
61
  AND task_status IN ('running', 'moving') AND host_id = 'local'`
61
62
  )
62
63
  .get(projectId, branch);
@@ -166,26 +167,29 @@ async function arrive(
166
167
  .run(worktreePath, bundle.branch, baseBranch, null, id);
167
168
  // A chat's worker reads its brief from the row; a terminal's is passed.
168
169
  db.prepare(
169
- `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', name_source = 'user',
170
- claude_session_id = ?, moved_from = ? WHERE id = ?`
171
- ).run(bundle.prompt, brief, claude?.sessionId ?? null, bundle.moveId, id);
170
+ `UPDATE sessions SET name_source = 'user', claude_session_id = ? WHERE id = ?`
171
+ ).run(claude?.sessionId ?? null, id);
172
+ setTask(db, id, {
173
+ task_prompt: bundle.prompt,
174
+ task_brief: brief,
175
+ task_status: "running",
176
+ moved_from: bundle.moveId,
177
+ });
172
178
  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);
179
+ if (bundle.planWait) setPlan(db, id, { plan_phase: "awaiting" });
177
180
  if (orch) {
178
- db.prepare(
179
- `UPDATE sessions SET plan_phase = ?, task_since = ? WHERE id = ?`
180
- ).run(orch.planPhase, orch.since, id);
181
+ setPlan(db, id, { plan_phase: orch.planPhase });
182
+ setTask(db, id, { task_since: orch.since });
181
183
  // Held for the orchestrator's device. The orchestrator's own state
182
184
  // (started_by, its gates) never comes from the bundle: it stays on its
183
185
  // machine, and a task coming back takes it from the mirror's record.
184
186
  if (holder) {
185
187
  holdFor(id, holder, orch);
186
- db.prepare(
187
- `UPDATE sessions SET model_provider = ?, model_overrides = ? WHERE id = ?`
188
- ).run(orch.modelProvider, orch.modelOverrides, id);
188
+ db.prepare(`UPDATE sessions SET model_provider = ? WHERE id = ?`).run(
189
+ orch.modelProvider,
190
+ id
191
+ );
192
+ setTask(db, id, { model_overrides: orch.modelOverrides });
189
193
  }
190
194
  }
191
195
  carryTaskState(bundle.moveId, id);
@@ -59,12 +59,13 @@ import { finishTaskStart } from "./start";
59
59
  import { isRemoteHost } from "../hosts";
60
60
  import { DEFAULT_START_VIEW, type StartView } from "../sessions/launch";
61
61
  import { viewOf } from "./move-targets";
62
- import { isMirror, startRemoteTask } from "./remote";
63
- import { remoteTaskViews } from "./remote-views";
62
+ import { isMirror, startRemoteTask } from "./contract/start";
63
+ import { mirrorViews } from "./contract/views";
64
64
  import { chatStateNow } from "../chat/runner";
65
65
  import { saidSinceLastMessage } from "../chat/store";
66
66
 
67
67
  import { lastActiveOf } from "./last-active";
68
+ import { setPlan, setTask } from "../db/session-parts";
68
69
  export * from "./state";
69
70
  export { codeReviewGate, codeReviewRefusal } from "./code-review";
70
71
  export { signOffTask, dropTask, signingOff, mergeSettled } from "./finish";
@@ -258,7 +259,7 @@ async function startTask(
258
259
  if (opts.planOnly && view !== "chat")
259
260
  throw new Error("A plan-only task needs a chat, not a terminal");
260
261
  // 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
+ // stops, and what follows is chosen here (contract/plan.ts).
262
263
  const planned =
263
264
  !!(opts.plan || opts.planOnly) &&
264
265
  taskRouting(
@@ -280,7 +281,7 @@ async function startTask(
280
281
  ? { only: !!opts.planOnly, reason: opts.planReason?.trim() || null }
281
282
  : undefined,
282
283
  });
283
- // The mirror's started_by stays orchestrator-only: a held-remote task is
284
+ // The mirror's started_by stays orchestrator-only: a held task on a linked machine is
284
285
  // relayed, stopped and answered through it. A plain calling task is its
285
286
  // parent and started_via only.
286
287
  recordStart(session.id, {
@@ -366,30 +367,41 @@ async function startTask(
366
367
  .run(wt.worktreePath, wt.branchName, baseBranch, null, id);
367
368
  // Everything the launch needs is on the row, so a restart can resume it.
368
369
  db.prepare(
369
- `UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
370
- model_provider = ?, model_overrides = ?, plan_phase = ?, plan_only = ?,
371
- plan_decided = ?, plan_reason = ?, plan_waits_for_caller = ? WHERE id = ?`
370
+ `UPDATE sessions SET setup_status = 'running', name_source = ?, view = ?, model_provider = ? WHERE id = ?`
372
371
  ).run(
373
- prompt,
374
- buildTaskBrief({
375
- branch: wt.branchName,
376
- baseBranch,
377
- stack: opts.base?.stack,
378
- test: declaredTest(wt.worktreePath),
379
- }),
380
372
  naming.source,
381
373
  view,
382
374
  routing.first.provider.id === ANTHROPIC
383
375
  ? null
384
376
  : routing.first.provider.id,
385
- routing.override ? JSON.stringify(routing.override) : null,
386
- routing.plan ? "planning" : null,
387
- routing.planOnly ? 1 : null,
388
- routing.planOnly ? 1 : opts.plan === undefined ? null : opts.plan ? 1 : 0,
389
- opts.planReason?.trim() || null,
390
- routing.planOnly && opts.planWaits ? 1 : null,
391
377
  id
392
378
  );
379
+ setTask(db, id, {
380
+ task_prompt: prompt,
381
+ task_brief: buildTaskBrief({
382
+ branch: wt.branchName,
383
+ baseBranch,
384
+ stack: opts.base?.stack,
385
+ test: declaredTest(wt.worktreePath),
386
+ }),
387
+ task_status: "running",
388
+ model_overrides: routing.override
389
+ ? JSON.stringify(routing.override)
390
+ : null,
391
+ });
392
+ setPlan(db, id, {
393
+ plan_phase: routing.plan ? "planning" : null,
394
+ plan_only: routing.planOnly ? 1 : null,
395
+ plan_decided: routing.planOnly
396
+ ? 1
397
+ : opts.plan === undefined
398
+ ? null
399
+ : opts.plan
400
+ ? 1
401
+ : 0,
402
+ plan_reason: opts.planReason?.trim() || null,
403
+ plan_waits_for_caller: routing.planOnly && opts.planWaits ? 1 : null,
404
+ });
393
405
  recordStart(id, {
394
406
  startedBy: opts.startedBy ?? null,
395
407
  parentSessionId: opts.parentSessionId ?? null,
@@ -532,7 +544,7 @@ export async function taskView(session: Session): Promise<TaskView> {
532
544
  export function movedTasks(): { id: string; movedTo: string | null }[] {
533
545
  return db
534
546
  .prepare(
535
- `SELECT id, moved_to AS movedTo FROM sessions
547
+ `SELECT id, moved_to AS movedTo FROM session_rows
536
548
  WHERE task_status = 'moved' AND host_id = 'local'
537
549
  ORDER BY updated_at DESC LIMIT 500`
538
550
  )
@@ -544,7 +556,7 @@ export async function listTasks(): Promise<TaskView[]> {
544
556
  const sessions = taskSessions(false);
545
557
  const [local, remote] = await Promise.all([
546
558
  Promise.all(sessions.filter((s) => !isMirror(s)).map(taskView)),
547
- remoteTaskViews(sessions.filter(isMirror)),
559
+ mirrorViews(sessions.filter(isMirror)),
548
560
  ]);
549
561
  const costs = taskCosts(local.map((t) => t.id));
550
562
  for (const t of local) {
@@ -9,7 +9,7 @@ import { db } from "../db";
9
9
  export function launchPending(sessionId: string): boolean {
10
10
  const row = db
11
11
  .prepare(
12
- `SELECT 1 FROM sessions WHERE id = ? AND task_status = 'running'
12
+ `SELECT 1 FROM session_rows WHERE id = ? AND task_status = 'running'
13
13
  AND setup_status IN ('running', 'held')`
14
14
  )
15
15
  .get(sessionId);
@@ -10,6 +10,7 @@
10
10
  import type Database from "better-sqlite3";
11
11
  import { db } from "../db";
12
12
  import { orchestratorOf } from "./starter";
13
+ import { fillMissing } from "../db/session-parts";
13
14
 
14
15
  // table, column, and how the id sits in it: the whole value, `task:<id>`, or
15
16
  // the tail of a `<condition>:<id>` key.
@@ -100,15 +101,22 @@ export function carryTaskState(
100
101
  .run(fromId.length, toId, `%:${fromId}`);
101
102
  }
102
103
  const from = handle
103
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
104
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
104
105
  .get(fromId) as Record<string, unknown> | undefined;
105
106
  if (!from) return;
106
- const sets = [...CARRIED_COLUMNS, "task_since"]
107
- .map((c) => `${c} = COALESCE(${c}, @${c})`)
108
- .join(", ");
109
- handle.prepare(`UPDATE sessions SET ${sets} WHERE id = @id`).run({
107
+ const carried = {
110
108
  ...orchestrationOf(from),
111
109
  task_since: (from.task_since as string | null) ?? from.created_at ?? null,
112
- id: toId,
113
- });
110
+ } as Record<string, string | number | null>;
111
+ handle
112
+ .prepare(
113
+ `UPDATE sessions SET started_by = COALESCE(started_by, @started_by),
114
+ model_provider = COALESCE(model_provider, @model_provider) WHERE id = @id`
115
+ )
116
+ .run({
117
+ started_by: carried.started_by,
118
+ model_provider: carried.model_provider,
119
+ id: toId,
120
+ });
121
+ fillMissing(handle, toId, carried);
114
122
  }
@@ -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
  }
@@ -49,7 +49,7 @@ export function moveRefusal(session: Session): string | null {
49
49
  .prepare(
50
50
  `SELECT lh_card_id,
51
51
  EXISTS (SELECT 1 FROM stack_items WHERE session_id = s.id) AS stacked
52
- FROM sessions s WHERE id = ?`
52
+ FROM session_rows s WHERE id = ?`
53
53
  )
54
54
  .get(session.id) as
55
55
  | { lh_card_id: string | null; stacked: number }