@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
@@ -123,7 +123,7 @@ function armed(): ArmedFact[] {
123
123
  db
124
124
  .prepare(
125
125
  `SELECT s.id, s.name, s.merge_armed_at, s.pr_number, p.workspace_id
126
- FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
126
+ FROM session_rows s LEFT JOIN projects p ON p.id = s.project_id
127
127
  WHERE s.merge_armed_at IS NOT NULL AND s.archived_at IS NULL
128
128
  AND COALESCE(s.task_status, 'running') = 'running'`
129
129
  )
@@ -39,7 +39,7 @@ export function mergedSince(
39
39
  return (
40
40
  db
41
41
  .prepare(
42
- `SELECT id, name, pr_number, task_prompt, updated_at FROM sessions
42
+ `SELECT id, name, pr_number, task_prompt, updated_at FROM session_rows
43
43
  WHERE project_id = ? AND task_status = 'merged'
44
44
  AND updated_at >= datetime(?)`
45
45
  )
@@ -15,7 +15,7 @@ import { isValidTmuxName } from "./attach";
15
15
  import { homeRelative } from "./discover";
16
16
  import { toPeerSession } from "./peer-sessions";
17
17
  import { insertMirror } from "./peer-sync";
18
- import { hostTasks, postIdempotent } from "../tasks/remote";
18
+ import { list as hostTasks, postIdempotent } from "../tasks/contract/client";
19
19
  import type { ProjectRef } from "../tasks/project-ref";
20
20
  import {
21
21
  HostApiError,
@@ -83,7 +83,7 @@ const path = (id: string, rest = "") =>
83
83
  `/api/sessions/${encodeURIComponent(id)}${rest}`;
84
84
 
85
85
  const row = (id: string) =>
86
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as Session;
86
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as Session;
87
87
 
88
88
  export async function renameOnPeer(
89
89
  link: HostLink,
@@ -16,7 +16,7 @@ export async function mirrorPeerSession(
16
16
  sessionId: string
17
17
  ): Promise<Session> {
18
18
  const existing = db
19
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
19
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
20
20
  .get(sessionId) as Session | undefined;
21
21
  if (existing) {
22
22
  if (existing.host_id !== hostId)
@@ -43,6 +43,6 @@ export async function mirrorPeerSession(
43
43
  )
44
44
  );
45
45
  return db
46
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
46
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
47
47
  .get(peer.id) as Session;
48
48
  }
@@ -6,37 +6,45 @@
6
6
  * go there (peer-actions.ts).
7
7
  */
8
8
 
9
- import { db, queries, type Project } from "../db";
9
+ import { db, queries, type Project, type Session } from "../db";
10
10
  import { isValidAgentType } from "../providers";
11
11
  import { projectMatcher } from "./discover";
12
12
  import type { PeerSession } from "./peer-sessions";
13
+ import { setPlacement, setPr } from "../db/session-parts";
13
14
 
14
15
  export function insertMirror(
15
16
  peer: PeerSession,
16
17
  projectId: string | null
17
18
  ): void {
18
- db.prepare(
19
- `INSERT INTO sessions (id, name, tmux_name, working_directory, model, group_path,
20
- agent_type, project_id, host_id, view, name_source, pr_url, pr_number,
21
- pr_status, updated_at, peer_mirror)
22
- VALUES (?, ?, ?, ?, ?, 'sessions', ?, ?, ?, ?, 'user', ?, ?, ?,
23
- COALESCE(?, datetime('now')), 1)
24
- ON CONFLICT(id) DO NOTHING`
25
- ).run(
26
- peer.id,
27
- peer.name,
28
- peer.tmuxName || `${peer.agentType}-${peer.id}`,
29
- peer.path || "~",
30
- peer.model,
31
- isValidAgentType(peer.agentType) ? peer.agentType : "shell",
32
- projectId,
33
- peer.hostId,
34
- peer.view,
35
- peer.prUrl,
36
- peer.prNumber,
37
- peer.prStatus,
38
- peer.updatedAt
39
- );
19
+ db.transaction(() => {
20
+ const inserted = db
21
+ .prepare(
22
+ `INSERT INTO sessions (id, name, tmux_name, working_directory, model, group_path,
23
+ agent_type, project_id, host_id, view, name_source, updated_at)
24
+ VALUES (?, ?, ?, ?, ?, 'sessions', ?, ?, ?, ?, 'user',
25
+ COALESCE(?, datetime('now')))
26
+ ON CONFLICT(id) DO NOTHING`
27
+ )
28
+ .run(
29
+ peer.id,
30
+ peer.name,
31
+ peer.tmuxName || `${peer.agentType}-${peer.id}`,
32
+ peer.path || "~",
33
+ peer.model,
34
+ isValidAgentType(peer.agentType) ? peer.agentType : "shell",
35
+ projectId,
36
+ peer.hostId,
37
+ peer.view,
38
+ peer.updatedAt
39
+ );
40
+ if (!inserted.changes) return;
41
+ setPr(db, peer.id, {
42
+ pr_url: peer.prUrl,
43
+ pr_number: peer.prNumber,
44
+ pr_status: peer.prStatus as Session["pr_status"],
45
+ });
46
+ setPlacement(db, peer.id, { peer_mirror: 1 });
47
+ })();
40
48
  }
41
49
 
42
50
  // One listing mirrors at most this many: a listing is another machine's
@@ -71,17 +79,46 @@ export function syncPeerMirrors(
71
79
  );
72
80
  // That machine lists the id, so it's its session: a row mirrored on open
73
81
  // before this column existed is marked too.
74
- const update = db.prepare(
82
+ const current = db.prepare(
83
+ `SELECT name, view, tmux_name, project_id, pr_url, pr_number, pr_status,
84
+ updated_at, peer_mirror
85
+ FROM session_rows WHERE id = ? AND host_id = ? AND task_prompt IS NULL`
86
+ );
87
+ const touch = db.prepare(
75
88
  `UPDATE sessions SET name = ?, view = ?, tmux_name = ?, project_id = ?,
76
- pr_url = ?, pr_number = ?, pr_status = ?,
77
- updated_at = COALESCE(?, updated_at), peer_mirror = 1
78
- WHERE id = ? AND host_id = ? AND task_prompt IS NULL
79
- AND NOT (name IS ? AND view IS ? AND tmux_name IS ? AND project_id IS ?
80
- AND pr_url IS ? AND pr_number IS ? AND pr_status IS ?
81
- AND updated_at IS COALESCE(?, updated_at) AND peer_mirror = 1)`
89
+ updated_at = COALESCE(?, updated_at) WHERE id = ?`
82
90
  );
91
+ const update = (
92
+ peer: PeerSession,
93
+ fields: (string | number | null)[]
94
+ ): void => {
95
+ const [name, view, tmuxName, projectId, prUrl, prNumber, prStatus, at] =
96
+ fields;
97
+ const cur = current.get(peer.id, hostId) as
98
+ | Record<string, string | number | null>
99
+ | undefined;
100
+ if (!cur) return;
101
+ const same =
102
+ cur.name === name &&
103
+ cur.view === view &&
104
+ cur.tmux_name === tmuxName &&
105
+ cur.project_id === projectId &&
106
+ cur.pr_url === prUrl &&
107
+ cur.pr_number === prNumber &&
108
+ cur.pr_status === prStatus &&
109
+ cur.updated_at === (at ?? cur.updated_at) &&
110
+ cur.peer_mirror === 1;
111
+ if (same) return;
112
+ touch.run(name, view, tmuxName, projectId, at, peer.id);
113
+ setPr(db, peer.id, {
114
+ pr_url: prUrl as string | null,
115
+ pr_number: prNumber as number | null,
116
+ pr_status: prStatus as Session["pr_status"],
117
+ });
118
+ setPlacement(db, peer.id, { peer_mirror: 1 });
119
+ };
83
120
  const mirrors = db.prepare(
84
- `SELECT id FROM sessions WHERE host_id = ? AND peer_mirror = 1
121
+ `SELECT id FROM session_rows WHERE host_id = ? AND peer_mirror = 1
85
122
  AND task_prompt IS NULL AND archived_at IS NULL
86
123
  AND created_at < datetime(?, 'unixepoch')`
87
124
  );
@@ -112,7 +149,7 @@ export function syncPeerMirrors(
112
149
  peer.prStatus,
113
150
  peer.updatedAt,
114
151
  ];
115
- update.run(...fields, peer.id, hostId, ...fields);
152
+ update(peer, fields);
116
153
  }
117
154
  if (!complete) return;
118
155
  const now = new Set(listed.map((p) => p.id));
@@ -80,7 +80,7 @@ export async function readSession(id: string): Promise<SessionInput | null> {
80
80
  ? (
81
81
  db
82
82
  .prepare(
83
- `SELECT s.task_prompt AS p FROM sessions s JOIN projects p ON p.id = s.project_id
83
+ `SELECT s.task_prompt AS p FROM session_rows s JOIN projects p ON p.id = s.project_id
84
84
  WHERE p.workspace_id = ? AND s.id != ? AND s.quick_of IS NULL
85
85
  AND s.task_prompt IS NOT NULL
86
86
  AND s.created_at >= datetime('now', ?)
@@ -30,7 +30,7 @@ export function listPending(): PendingRow[] {
30
30
  `SELECT lc.*, s.name AS session_name,
31
31
  COALESCE(lc.workspace_id, s.workspace_id, p.workspace_id) AS ws
32
32
  FROM learning_cards lc
33
- JOIN sessions s ON s.id = lc.session_id
33
+ JOIN session_rows s ON s.id = lc.session_id
34
34
  LEFT JOIN projects p ON p.id = s.project_id
35
35
  WHERE lc.status = 'pending'
36
36
  ORDER BY lc.created_at DESC, lc.id DESC`
@@ -33,6 +33,7 @@ import {
33
33
  planPathOf,
34
34
  splitOf,
35
35
  } from "../plans/features";
36
+ import { advancePlan, setPlan } from "../db/session-parts";
36
37
 
37
38
  export const PLAN_FILE = ".agentos/plan.md";
38
39
 
@@ -164,10 +165,7 @@ function announcePlan(s: Session, ref: string | null, retried = false): void {
164
165
  const again = retried ? " (asked once more for it first)" : "";
165
166
  const head = planNoteFor(s, file, next, again, ref ? " anyway" : "");
166
167
  if (s.held_by_device) {
167
- db.prepare(`UPDATE sessions SET plan_note = ? WHERE id = ?`).run(
168
- head,
169
- s.id
170
- );
168
+ setPlan(db, s.id, { plan_note: head });
171
169
  return;
172
170
  }
173
171
  queueEvent(workspaceId!, `plan-done:${s.id}`, s.id, head);
@@ -227,9 +225,9 @@ export const implementMessageId = (sessionId: string) =>
227
225
  type Send = (sessionId: string, id: string) => Promise<void>;
228
226
 
229
227
  function getSession(sessionId: string): Session | undefined {
230
- return db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
231
- | Session
232
- | undefined;
228
+ return db
229
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
230
+ .get(sessionId) as Session | undefined;
233
231
  }
234
232
 
235
233
  // Planning (or, once approved, awaiting) becomes implementing, once: the
@@ -273,13 +271,7 @@ async function startImplementing(
273
271
  let nothingOwn = false;
274
272
  let featureId: string | null = null;
275
273
  const claimed = db.transaction(() => {
276
- const changed = db
277
- .prepare(
278
- `UPDATE sessions SET plan_phase = 'implementing'
279
- WHERE id = ? AND plan_phase = ? AND task_status = 'running'`
280
- )
281
- .run(sessionId, from).changes;
282
- if (!changed) return false;
274
+ if (!advancePlan(db, sessionId, from, "implementing")) return false;
283
275
  setSessionModel(sessionId, routed);
284
276
  let piece: string | undefined;
285
277
  let note = amendment;
@@ -485,13 +477,7 @@ export async function handoffAfterPlan(
485
477
  });
486
478
  return ref !== null;
487
479
  }
488
- const awaiting = db
489
- .prepare(
490
- `UPDATE sessions SET plan_phase = 'awaiting'
491
- WHERE id = ? AND plan_phase = 'planning' AND task_status = 'running'`
492
- )
493
- .run(sessionId).changes;
494
- if (!awaiting) return false;
480
+ if (!advancePlan(db, sessionId, "planning", "awaiting")) return false;
495
481
  try {
496
482
  announcePlan(s, null, retried);
497
483
  } catch (e) {
@@ -538,7 +524,7 @@ export async function recheckPlanning(
538
524
  ): Promise<void> {
539
525
  const rows = db
540
526
  .prepare(
541
- `SELECT id FROM sessions
527
+ `SELECT id FROM session_rows
542
528
  WHERE plan_phase = 'planning' AND task_status = 'running'
543
529
  AND archived_at IS NULL AND worktree_path IS NOT NULL`
544
530
  )
@@ -603,7 +589,7 @@ export async function approvePlan(
603
589
  // On another machine, the plan was written there: this machine decides
604
590
  // where it is built, and the approval follows it.
605
591
  const ref = isRemoteHost(s.host_id)
606
- ? await (await import("../tasks/remote-plan")).continueRemotePlan(s, text)
592
+ ? await (await import("../tasks/contract/plan")).continuePlanThere(s, text)
607
593
  : await startImplementing(s, "awaiting", send, text);
608
594
  if (!ref) throw new Error(`${s.name} isn't waiting for plan approval`);
609
595
  if (by === "no-approvals")
@@ -617,7 +603,7 @@ export async function approvePlan(
617
603
  return { ref, amendments: scopeChangesOf(s.id).length };
618
604
  }
619
605
 
620
- // A plan that moved here to be built (remote-plan.ts): implementation starts
606
+ // A plan that moved here to be built (contract/plan.ts): implementation starts
621
607
  // on this machine, once, with the approval's change if it had one.
622
608
  export async function implementArrived(
623
609
  sessionId: string,
@@ -19,6 +19,7 @@ import {
19
19
  type ModelOverride,
20
20
  } from "./resolve";
21
21
  import { sessionRole } from "./session-role";
22
+ import { setTask } from "../db/session-parts";
22
23
 
23
24
  export const RESUME_MESSAGE =
24
25
  "You were paused. Carry on where you left off: check the worktree and the conversation above for where things stand, then finish the task.";
@@ -45,9 +46,9 @@ export async function resumeOnModel(
45
46
  model: unknown,
46
47
  message = RESUME_MESSAGE
47
48
  ): Promise<ResumeResult> {
48
- const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
49
- | Session
50
- | undefined;
49
+ const s = db
50
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
51
+ .get(sessionId) as Session | undefined;
51
52
  if (!s?.task_status) throw new Error("No such task");
52
53
  if (s.task_status !== "running")
53
54
  throw new Error(
@@ -65,10 +66,7 @@ export async function resumeOnModel(
65
66
  throw new Error(
66
67
  "It's mid-turn: stop it first, or wait for the turn to end"
67
68
  );
68
- db.prepare(`UPDATE sessions SET model_overrides = ? WHERE id = ?`).run(
69
- JSON.stringify(override),
70
- sessionId
71
- );
69
+ setTask(db, sessionId, { model_overrides: JSON.stringify(override) });
72
70
  if (launchPending(sessionId)) {
73
71
  setSessionModel(sessionId, routed);
74
72
  return { ref: routed.ref, resumed: false };
@@ -160,7 +160,7 @@ export async function notifyFrom(
160
160
  text: string
161
161
  ): Promise<Outcome> {
162
162
  const session = from
163
- ? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(from) as
163
+ ? (db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(from) as
164
164
  | Session
165
165
  | undefined)
166
166
  : undefined;
@@ -92,7 +92,7 @@ export const pushPlanAwaiting = (
92
92
  export const pushPlanReady = (sessionId: string, itemId: string) => {
93
93
  // Only a task has a screen to open; a plain chat's plan goes to the list.
94
94
  const task = db
95
- .prepare(`SELECT task_status FROM sessions WHERE id = ?`)
95
+ .prepare(`SELECT task_status FROM session_rows WHERE id = ?`)
96
96
  .get(sessionId) as { task_status: string | null } | undefined;
97
97
  fire(
98
98
  {
@@ -57,7 +57,7 @@ export interface PushOptions {
57
57
 
58
58
  function isMirrored(sessionId: string): boolean {
59
59
  const s = db
60
- .prepare(`SELECT host_id, peer_mirror FROM sessions WHERE id = ?`)
60
+ .prepare(`SELECT host_id, peer_mirror FROM session_rows WHERE id = ?`)
61
61
  .get(sessionId) as
62
62
  | { host_id: string | null; peer_mirror: number | null }
63
63
  | undefined;
@@ -439,7 +439,7 @@ export async function resurrect(
439
439
  if (!reason.trim()) throw new Error("Say why it's restored");
440
440
  const id = resolveEnded(ref, { workspaceId });
441
441
  const row = db
442
- .prepare(`SELECT task_status, archived_at FROM sessions WHERE id = ?`)
442
+ .prepare(`SELECT task_status, archived_at FROM session_rows WHERE id = ?`)
443
443
  .get(id) as { task_status: string | null; archived_at: string | null };
444
444
  const task =
445
445
  row.task_status === "dropped" ||
@@ -0,0 +1,228 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { execFileSync } from "child_process";
5
+ import { beforeAll, beforeEach, vi } from "vitest";
6
+ import type { TaskPR } from "@/lib/tasks/state";
7
+ import type { SessionFacts } from "./facts";
8
+
9
+ // Shared fixtures for the armed*.test.ts files: a real repository with a
10
+ // local bare remote, gh faked around it. Import this before anything it mocks.
11
+ // Tests change the fake world through S.
12
+ export const S: { pr: TaskPR | null } = { pr: null };
13
+ export const merges: string[][] = [];
14
+ export const isProbe = (args: string[]) =>
15
+ args[0] === "api" ||
16
+ (args[0] === "pr" &&
17
+ args[1] === "list" &&
18
+ args.includes("statusCheckRollup"));
19
+
20
+ vi.mock("@/lib/tasks/gh", async (importOriginal) => {
21
+ const real = await importOriginal<typeof import("@/lib/tasks/gh")>();
22
+ return {
23
+ ...real,
24
+ run: async (cmd: string, args: string[], cwd: string, t?: number) => {
25
+ if (cmd === "gh" && isProbe(args)) return "0";
26
+ if (cmd === "gh") {
27
+ merges.push(args);
28
+ if (S.pr) S.pr = { ...S.pr, state: "MERGED" };
29
+ return "";
30
+ }
31
+ if (cmd === "tmux") throw new Error("no tmux in tests");
32
+ return real.run(cmd, args, cwd, t);
33
+ },
34
+ findPR: async () => S.pr,
35
+ };
36
+ });
37
+ vi.mock("@/lib/status-detector", () => ({
38
+ checkWaitingPatterns: () => false,
39
+ statusDetector: {
40
+ refreshCache: async () => {},
41
+ sessionExists: () => true,
42
+ getStatus: async () => "idle",
43
+ titleFor: () => "",
44
+ getTimestamp: () => 0,
45
+ hostFor: () => "local",
46
+ capturePane: async () => "❯ ",
47
+ },
48
+ }));
49
+
50
+ export const { db, updateSessionRow } = await import("@/lib/db");
51
+ export const { createProject } = await import("@/lib/projects");
52
+ export const { createWorkspace, setProjectWorkspace } =
53
+ await import("@/lib/workspaces");
54
+ export const { ensureOrchestrator } = await import("./home");
55
+ export const { runTool } = await import("./serve");
56
+ export const { getCheck, putCheck } = await import("./checks");
57
+ export const { failureOf, recordFailure } = await import("./gates");
58
+ export const { LOOP_GUARD_COMMITS } = await import("./loop-guard");
59
+ export const { seedSession } = await import("./testing");
60
+ export const { setMergeApprovals } = await import("./merge-approvals");
61
+ export const { mergeArmed, armTask } = await import("./armed");
62
+ export const { setPaused } = await import("./pause");
63
+ export const { escalate } = await import("./escalate");
64
+ export const { openAsks, raiseAsk } = await import("./asks");
65
+ export const { judge, mergeJudged } = await import("./signoff");
66
+ export const { rebaseConflicting } = await import("./rebase");
67
+ export const { recordConditions } = await import("./events");
68
+
69
+ beforeAll(() => {
70
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "aos-orch-armed-"));
71
+ vi.spyOn(os, "homedir").mockReturnValue(home);
72
+ });
73
+ beforeEach(() => {
74
+ merges.length = 0;
75
+ });
76
+
77
+ export const git = (cwd: string, ...args: string[]) =>
78
+ execFileSync("git", args, {
79
+ cwd,
80
+ encoding: "utf8",
81
+ stdio: "pipe",
82
+ env: { ...process.env, GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z" },
83
+ }).trim();
84
+
85
+ export function commit(repo: string, file: string, text: string): string {
86
+ fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
87
+ fs.writeFileSync(path.join(repo, file), text);
88
+ git(repo, "add", "-A");
89
+ git(repo, "commit", "-qm", `change ${file}`);
90
+ git(repo, "push", "-q", "origin", "HEAD");
91
+ return git(repo, "rev-parse", "HEAD");
92
+ }
93
+
94
+ export const lines = (n: number, change: Record<number, string> = {}) =>
95
+ Array.from({ length: n }, (_, i) => change[i + 1] ?? `line ${i + 1}`).join(
96
+ "\n"
97
+ ) + "\n";
98
+
99
+ // A workspace whose project is a real clone, which is also the task's
100
+ // worktree, on its branch with its PR open at the branch's head. `other`
101
+ // is a second clone for moving main.
102
+ export function setup() {
103
+ setMergeApprovals(false);
104
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "aos-armed-repo-"));
105
+ const remote = path.join(root, "remote.git");
106
+ const repo = path.join(root, "repo");
107
+ const other = path.join(root, "other");
108
+ git(root, "init", "-q", "--bare", "-b", "main", remote);
109
+ git(root, "clone", "-q", remote, repo);
110
+ for (const r of [repo]) {
111
+ git(r, "config", "user.email", "t@example.com");
112
+ git(r, "config", "user.name", "T");
113
+ }
114
+ commit(repo, "src/b.ts", lines(12));
115
+ git(root, "clone", "-q", remote, other);
116
+ git(other, "config", "user.email", "o@example.com");
117
+ git(other, "config", "user.name", "O");
118
+ git(repo, "checkout", "-qb", "feature/t");
119
+ const sha = commit(repo, "src/b.ts", lines(12, { 5: "changed five" }));
120
+
121
+ const workspace = createWorkspace(`ws-${path.basename(root)}`);
122
+ const project = createProject({
123
+ name: `app-${path.basename(root)}`,
124
+ workingDirectory: repo,
125
+ });
126
+ setProjectWorkspace(project.id, workspace.id);
127
+ ensureOrchestrator(workspace.id);
128
+ const task = seedSession({
129
+ projectId: project.id,
130
+ name: "add-a",
131
+ task: true,
132
+ branch: "feature/t",
133
+ });
134
+ updateSessionRow(db, task, {
135
+ base_branch: "main",
136
+ task_prompt: "Add a",
137
+ pr_number: 7,
138
+ worktree_path: repo,
139
+ });
140
+ S.pr = {
141
+ number: 7,
142
+ url: "https://github.com/o/r/pull/7",
143
+ state: "OPEN",
144
+ checks: "pass",
145
+ head: sha,
146
+ failing: null,
147
+ checkCount: 1,
148
+ attribution: null,
149
+ };
150
+ const w = workspace.id;
151
+ const put = (
152
+ kind: "review" | "ci",
153
+ status: "pass" | "block",
154
+ at = S.pr!.head!
155
+ ) =>
156
+ putCheck({
157
+ workspaceId: w,
158
+ sessionId: task,
159
+ sha: at,
160
+ kind,
161
+ status,
162
+ detail:
163
+ kind === "ci"
164
+ ? JSON.stringify({ count: S.pr!.checkCount, at: 0 })
165
+ : "Looks right.",
166
+ });
167
+ // CI has looked settled on the head for a while.
168
+ put("ci", "pass");
169
+ return {
170
+ w,
171
+ repo,
172
+ other,
173
+ remote,
174
+ task,
175
+ sha,
176
+ put,
177
+ // Someone else's PR merged: main moves.
178
+ moveMain: (file: string, text: string) => {
179
+ git(other, "pull", "-q", "origin", "main");
180
+ return commit(other, file, text);
181
+ },
182
+ facts: (status: SessionFacts["status"] = "idle"): SessionFacts[] => [
183
+ {
184
+ id: task,
185
+ name: "add-a",
186
+ project: project.name,
187
+ view: "terminal",
188
+ status,
189
+ activity: null,
190
+ needsInput: false,
191
+ lastActive: 0,
192
+ branch: "feature/t",
193
+ task: { state: "review", pr: S.pr, blocked: null, ciSettleIn: 0 },
194
+ stack: null,
195
+ },
196
+ ],
197
+ };
198
+ }
199
+
200
+ export const events = (w: string) =>
201
+ (
202
+ db
203
+ .prepare(
204
+ `SELECT key, line FROM orchestrator_events WHERE workspace_id = ? AND key LIKE 'once:%' ORDER BY id`
205
+ )
206
+ .all(w) as { key: string; line: string }[]
207
+ ).map((e) => e.line);
208
+
209
+ export const armedAt = (task: string) =>
210
+ (
211
+ db
212
+ .prepare(`SELECT merge_armed_at FROM session_rows WHERE id = ?`)
213
+ .get(task) as { merge_armed_at: string | null }
214
+ ).merge_armed_at;
215
+
216
+ export function armedDeps() {
217
+ const reviews: string[] = [];
218
+ return {
219
+ reviews,
220
+ deps: {
221
+ judge,
222
+ merge: mergeJudged,
223
+ review: async (_w: string, id: string) => {
224
+ reviews.push(id);
225
+ },
226
+ },
227
+ };
228
+ }