@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
@@ -8,38 +8,19 @@
8
8
 
9
9
  import { db, type Session } from "../db";
10
10
  import { hostIdNamed } from "../hosts";
11
- import { hostApi, hostLink, type HostLink } from "../hosts/remote-api";
11
+ import { hostLink, type HostLink } from "../hosts/remote-api";
12
12
  import { getTaskSession } from "./session";
13
13
  import { markMoved, resumeTask } from "./move";
14
- import { forgetHostTasks, mirrorTask, unknownOutcome } from "./remote";
14
+ import * as client from "./contract/client";
15
+ import { mirrorTask } from "./contract/start";
15
16
  import { carryTaskState } from "./move-carry";
16
17
 
17
- /** POST, repeated when the outcome is unknown (the calls are idempotent). */
18
- export async function tell(
19
- link: HostLink,
20
- path: string,
21
- body: object = {}
22
- ): Promise<void> {
23
- for (let i = 0; ; i++) {
24
- try {
25
- await hostApi(link, path, { body });
26
- return;
27
- } catch (err) {
28
- if (!unknownOutcome(err) || i === 2) throw err;
29
- }
30
- }
31
- }
32
-
33
18
  /** The task a move from this session made there, or null. Throws if unsure. */
34
19
  export async function arrivedThere(
35
20
  link: HostLink,
36
21
  sessionId: string
37
22
  ): Promise<Session | null> {
38
- const { session } = await hostApi<{ session: Session | null }>(
39
- link,
40
- `/api/tasks/arrived?from=${encodeURIComponent(sessionId)}`
41
- );
42
- return session;
23
+ return client.arrived(link, sessionId);
43
24
  }
44
25
 
45
26
  export function settleMovedOut(
@@ -53,9 +34,9 @@ export function settleMovedOut(
53
34
  mirrorTask(hostId, session.project_id, there);
54
35
  carryTaskState(session.id, there.id);
55
36
  })();
56
- forgetHostTasks(hostId);
37
+ client.forget(hostId);
57
38
  return db
58
- .prepare(`SELECT * FROM sessions WHERE id = ?`)
39
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
59
40
  .get(there.id) as Session;
60
41
  }
61
42
 
@@ -6,7 +6,7 @@ import fs from "fs";
6
6
  import os from "os";
7
7
  import path from "path";
8
8
  import { randomUUID } from "crypto";
9
- import { db, type Session } from "../db";
9
+ import { db, insertSessionRow, type Session } from "../db";
10
10
  import { createProject } from "../projects";
11
11
  import { writeTranscript } from "./transcript";
12
12
  import { gitSync } from "../git-exec";
@@ -17,7 +17,7 @@ export const git = (cwd: string, ...args: string[]) =>
17
17
  gitSync(cwd, args).trim();
18
18
 
19
19
  export const row = (id: string) =>
20
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as Session;
20
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as Session;
21
21
 
22
22
  export interface MoveFixture {
23
23
  tmp: string;
@@ -71,11 +71,20 @@ export async function seedTask(
71
71
  git(f.repo, "worktree", "add", "-q", "-b", branch, cwd, "main");
72
72
  fs.writeFileSync(path.join(cwd, "work.txt"), "half done\n");
73
73
  const id = randomUUID();
74
- db.prepare(
75
- `INSERT INTO sessions (id, name, tmux_name, working_directory, project_id, task_status,
76
- task_prompt, branch_name, worktree_path, base_branch, claude_session_id, model)
77
- VALUES (?, 'Fix it', ?, ?, ?, 'running', 'fix the thing', ?, ?, 'main', ?, 'sonnet')`
78
- ).run(id, `claude-${id}`, cwd, f.projectId, branch, cwd, CLAUDE_ID);
74
+ insertSessionRow(db, {
75
+ id,
76
+ name: "Fix it",
77
+ tmux_name: `claude-${id}`,
78
+ working_directory: cwd,
79
+ project_id: f.projectId,
80
+ task_status: "running",
81
+ task_prompt: "fix the thing",
82
+ branch_name: branch,
83
+ worktree_path: cwd,
84
+ base_branch: "main",
85
+ claude_session_id: CLAUDE_ID,
86
+ model: "sonnet",
87
+ });
79
88
  await writeTranscript(
80
89
  cwd,
81
90
  CLAUDE_ID,
package/lib/tasks/move.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  latestSessionId,
37
37
  readTranscript,
38
38
  } from "./transcript";
39
+ import { setTask } from "../db/session-parts";
39
40
 
40
41
  export * from "./move-bundle";
41
42
 
@@ -88,13 +89,13 @@ async function pushOwnBranch(cwd: string, branch: string): Promise<void> {
88
89
  * arrived, and then it would run on both.
89
90
  */
90
91
  function claimMoving(id: string, to: string): { fresh: boolean } {
91
- const fresh = db
92
- .prepare(
93
- `UPDATE sessions SET task_status = 'moving', moved_to = ?
94
- WHERE id = ? AND task_status = 'running'`
95
- )
96
- .run(to, id);
97
- if (fresh.changes === 1) return { fresh: true };
92
+ const fresh = setTask(
93
+ db,
94
+ id,
95
+ { task_status: "moving", moved_to: to },
96
+ { task_status: "running" }
97
+ );
98
+ if (fresh) return { fresh: true };
98
99
  const { task_status, moved_to } = getTaskSession(id);
99
100
  if (task_status === "moving" && moved_to === to) return { fresh: false };
100
101
  throw new Error(
@@ -250,12 +251,12 @@ export async function exportOrResume(
250
251
  }
251
252
 
252
253
  export function markMoved(id: string, to: string): void {
253
- const moved = db
254
- .prepare(
255
- `UPDATE sessions SET task_status = 'moved', moved_to = ?
256
- WHERE id = ? AND task_status IN ('running', 'moving')`
257
- )
258
- .run(to, id).changes;
254
+ const moved = setTask(
255
+ db,
256
+ id,
257
+ { task_status: "moved", moved_to: to },
258
+ { task_status: ["running", "moving"] }
259
+ );
259
260
  // It goes on there with a copy of its own.
260
261
  if (moved) void dropSessionDatabase(id);
261
262
  }
@@ -269,25 +270,24 @@ export function markMoved(id: string, to: string): void {
269
270
  */
270
271
  export async function resumeTask(id: string, note?: string): Promise<void> {
271
272
  if (exporting.has(id)) throw new Error("It's moving right now");
272
- const claimed = db
273
- .prepare(
274
- `UPDATE sessions SET task_status = 'running' WHERE id = ? AND task_status = 'moving'`
275
- )
276
- .run(id);
273
+ const claimed = setTask(
274
+ db,
275
+ id,
276
+ { task_status: "running" },
277
+ { task_status: "moving" }
278
+ );
277
279
  const session = getTaskSession(id);
278
- if (claimed.changes !== 1) {
280
+ if (!claimed) {
279
281
  if (session.task_status === "running") return;
280
282
  throw new Error(`Task is already ${session.task_status}`);
281
283
  }
282
284
  try {
283
285
  await relaunch(session, note);
284
286
  } catch (err) {
285
- db.prepare(
286
- `UPDATE sessions SET task_status = 'moving' WHERE id = ? AND task_status = 'running'`
287
- ).run(id);
287
+ setTask(db, id, { task_status: "moving" }, { task_status: "running" });
288
288
  throw err;
289
289
  }
290
- db.prepare(`UPDATE sessions SET moved_to = NULL WHERE id = ?`).run(id);
290
+ setTask(db, id, { moved_to: null });
291
291
  }
292
292
 
293
293
  async function relaunch(session: Session, note?: string): Promise<void> {
@@ -12,7 +12,7 @@ import { settleWorktree, type WorktreeFate } from "../done/worktree";
12
12
  import { archiveSession } from "../done/archive";
13
13
 
14
14
  export async function cleanUpMovedAway(id: string): Promise<WorktreeFate> {
15
- const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
15
+ const s = db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
16
16
  | Session
17
17
  | undefined;
18
18
  if (!s || s.task_status !== "moved" || !s.held_by_device)
@@ -2,6 +2,7 @@
2
2
  // can hold a message for it without loading the pause machinery.
3
3
 
4
4
  import { db, type TaskPause } from "../db";
5
+ import { setPause } from "../db/session-parts";
5
6
 
6
7
  export interface PauseHold {
7
8
  pauseId: number;
@@ -11,7 +12,7 @@ export interface PauseHold {
11
12
  export function pauseHoldOf(sessionId: string): PauseHold | null {
12
13
  const row = db
13
14
  .prepare(
14
- `SELECT pause_id, paused_until FROM sessions
15
+ `SELECT pause_id, paused_until FROM session_rows
15
16
  WHERE id = ? AND pause_state IN ('pausing', 'paused') AND pause_id IS NOT NULL`
16
17
  )
17
18
  .get(sessionId) as
@@ -45,10 +46,17 @@ export function endPause(
45
46
  `UPDATE task_pauses SET state = 'ended', ended_at = datetime('now'), end_reason = ?
46
47
  WHERE id = ? AND state != 'ended'`
47
48
  ).run(reason, row.id);
48
- db.prepare(
49
- `UPDATE sessions SET pause_state = NULL, paused_until = NULL, pause_id = NULL, pause_note = NULL
50
- WHERE id = ? AND pause_id = ?`
51
- ).run(row.session_id, row.id);
49
+ setPause(
50
+ db,
51
+ row.session_id,
52
+ {
53
+ pause_state: null,
54
+ paused_until: null,
55
+ pause_id: null,
56
+ pause_note: null,
57
+ },
58
+ { pause_id: row.id }
59
+ );
52
60
  })();
53
61
  }
54
62
 
@@ -50,6 +50,7 @@ import {
50
50
  type PauseView,
51
51
  type Priority,
52
52
  } from "./schedule-types";
53
+ import { setPause } from "../db/session-parts";
53
54
 
54
55
  export class PauseRefused extends Error {}
55
56
 
@@ -72,7 +73,7 @@ const nudge = () =>
72
73
  void import("./queue").then((q) => q.tickQueueSoon()).catch(() => undefined);
73
74
 
74
75
  const sessionOf = (id: string) =>
75
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
76
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
76
77
  | Session
77
78
  | undefined;
78
79
 
@@ -165,10 +166,9 @@ export function requestPause(sessionId: string, req: PauseRequest): TaskPause {
165
166
  until ? toSqliteTime(until) : null,
166
167
  existing.id
167
168
  );
168
- db.prepare(`UPDATE sessions SET paused_until = ? WHERE id = ?`).run(
169
- until ? toSqliteTime(until) : null,
170
- sessionId
171
- );
169
+ setPause(db, sessionId, {
170
+ paused_until: until ? toSqliteTime(until) : null,
171
+ });
172
172
  // "Pause now" takes over: the first driver is superseded, not raced.
173
173
  drive(sessionId, existing.id, mode === "now");
174
174
  return pauseRow(existing.id)!;
@@ -190,9 +190,11 @@ export function requestPause(sessionId: string, req: PauseRequest): TaskPause {
190
190
  until ? toSqliteTime(until) : null
191
191
  ).lastInsertRowid
192
192
  );
193
- db.prepare(
194
- `UPDATE sessions SET pause_state = 'pausing', paused_until = ?, pause_id = ? WHERE id = ?`
195
- ).run(until ? toSqliteTime(until) : null, id, sessionId);
193
+ setPause(db, sessionId, {
194
+ pause_state: "pausing",
195
+ paused_until: until ? toSqliteTime(until) : null,
196
+ pause_id: id,
197
+ });
196
198
  drive(sessionId, id);
197
199
  return pauseRow(id)!;
198
200
  }
@@ -307,10 +309,7 @@ function markPaused(sessionId: string, pauseId: number): void {
307
309
  WHERE id = ? AND state = 'pausing'`
308
310
  )
309
311
  .run(pauseId).changes;
310
- if (n)
311
- db.prepare(`UPDATE sessions SET pause_state = 'paused' WHERE id = ?`).run(
312
- sessionId
313
- );
312
+ if (n) setPause(db, sessionId, { pause_state: "paused" });
314
313
  return n;
315
314
  })();
316
315
  if (!changed) return;
@@ -359,10 +358,7 @@ export function setResumeTime(sessionId: string, until: Date | null): void {
359
358
  db.prepare(
360
359
  `UPDATE task_pauses SET until_at = ?, resume_requested_at = NULL WHERE id = ? AND state IN ('pausing','paused')`
361
360
  ).run(value, row.id);
362
- db.prepare(`UPDATE sessions SET paused_until = ? WHERE id = ?`).run(
363
- value,
364
- sessionId
365
- );
361
+ setPause(db, sessionId, { paused_until: value });
366
362
  })();
367
363
  }
368
364
 
@@ -396,9 +392,11 @@ export function requestResume(sessionId: string, actor: PauseBy): void {
396
392
  export { loadHoldFor, resumeHoldFor };
397
393
 
398
394
  export function setPauseNote(sessionId: string, note: string | null): void {
399
- db.prepare(
400
- `UPDATE sessions SET pause_note = ? WHERE id = ? AND pause_state = 'paused' AND COALESCE(pause_note, '') != COALESCE(?, '')`
401
- ).run(note, sessionId, note);
395
+ const row = db
396
+ .prepare(`SELECT pause_note FROM session_rows WHERE id = ?`)
397
+ .get(sessionId) as { pause_note: string | null } | undefined;
398
+ if ((row?.pause_note ?? "") === (note ?? "")) return;
399
+ setPause(db, sessionId, { pause_note: note }, { pause_state: "paused" });
402
400
  }
403
401
 
404
402
  export type ResumeOutcome =
@@ -453,7 +451,7 @@ export function dueResumes(): DueResume[] {
453
451
  .prepare(
454
452
  `SELECT p.id AS pause_id, p.session_id, p.by_kind, p.until_at, p.resume_requested_at, p.resume_by_kind,
455
453
  s.project_id, s.priority
456
- FROM task_pauses p JOIN sessions s ON s.id = p.session_id
454
+ FROM task_pauses p JOIN session_rows s ON s.id = p.session_id
457
455
  WHERE p.state = 'paused' AND p.resume_requested_at IS NOT NULL
458
456
  AND s.task_status IN ('running') AND s.archived_at IS NULL`
459
457
  )
@@ -583,9 +581,7 @@ export async function claimResume(
583
581
  }),
584
582
  Date.now()
585
583
  );
586
- db.prepare(
587
- `UPDATE sessions SET pause_state = 'resuming', pause_note = NULL WHERE id = ?`
588
- ).run(sessionId);
584
+ setPause(db, sessionId, { pause_state: "resuming", pause_note: null });
589
585
  return true;
590
586
  })();
591
587
  if (!claimed) return "gone";
@@ -18,7 +18,7 @@ import {
18
18
  } from "./loop-types";
19
19
  import { expandHome } from "./session";
20
20
  import { isRemoteHost } from "../hosts";
21
- import { remotePlanPath } from "./remote-plan-path";
21
+ import { remotePlanPath } from "./contract/plan-path";
22
22
 
23
23
  const TITLE_MAX = 120;
24
24
 
@@ -275,9 +275,19 @@ export async function failedLog(
275
275
  repo: Repo,
276
276
  url: string | null | undefined
277
277
  ): Promise<FailedLog | null> {
278
+ const raw = await failedJobLog(dir, repo, url);
279
+ return raw === null ? null : trimLog(raw);
280
+ }
281
+
282
+ // The same log whole, as gh prints it.
283
+ export async function failedJobLog(
284
+ dir: string,
285
+ repo: Repo,
286
+ url: string | null | undefined
287
+ ): Promise<string | null> {
278
288
  const job = jobOf(url);
279
289
  if (!job) return null;
280
- const raw = await run(
290
+ return run(
281
291
  "gh",
282
292
  [
283
293
  "run",
@@ -291,5 +301,4 @@ export async function failedLog(
291
301
  dir,
292
302
  60000
293
303
  );
294
- return trimLog(raw);
295
304
  }
@@ -0,0 +1,193 @@
1
+ // `aos branches prune`: the stale remote branches AgentOS tasks left behind.
2
+ // A branch is stale when it is a task's recorded branch, its latest PR is
3
+ // merged or closed at the branch's current commit, and no open PR builds on
4
+ // it. Dry run by default; deleting goes through the same checks as a merge.
5
+
6
+ import { db } from "../db";
7
+ import { run } from "./gh";
8
+ import { PROTECTED_BRANCHES, deleteTaskBranch } from "./remote-branch";
9
+
10
+ export interface PruneRepo {
11
+ dir: string;
12
+ slug: string;
13
+ projectIds: string[];
14
+ }
15
+
16
+ export interface StaleBranch {
17
+ branch: string;
18
+ pr: number;
19
+ state: "MERGED" | "CLOSED";
20
+ head: string;
21
+ }
22
+
23
+ export interface RepoPrune {
24
+ slug: string;
25
+ stale: StaleBranch[];
26
+ kept: { branch: string; why: string }[];
27
+ deleted: string[];
28
+ }
29
+
30
+ interface ListedPR {
31
+ number: number;
32
+ state: string;
33
+ headRefName: string;
34
+ headRefOid: string;
35
+ baseRefName: string;
36
+ isCrossRepository: boolean;
37
+ }
38
+
39
+ const taskBranches = (projectIds: string[]): Set<string> => {
40
+ if (!projectIds.length) return new Set();
41
+ const rows = db
42
+ .prepare(
43
+ `SELECT DISTINCT branch_name FROM session_rows
44
+ WHERE branch_name IS NOT NULL
45
+ AND project_id IN (${projectIds.map(() => "?").join(", ")})`
46
+ )
47
+ .all(...projectIds) as { branch_name: string }[];
48
+ return new Set(rows.map((r) => r.branch_name));
49
+ };
50
+
51
+ async function remoteBranches(repo: PruneRepo): Promise<Map<string, string>> {
52
+ const out = await run(
53
+ "gh",
54
+ [
55
+ "api",
56
+ `repos/${repo.slug}/branches`,
57
+ "--paginate",
58
+ "--jq",
59
+ ".[] | [.name, .commit.sha] | @tsv",
60
+ ],
61
+ repo.dir,
62
+ 120000
63
+ );
64
+ const tips = new Map<string, string>();
65
+ for (const line of out.split("\n")) {
66
+ const [name, sha] = line.split("\t");
67
+ if (name && sha) tips.set(name, sha);
68
+ }
69
+ return tips;
70
+ }
71
+
72
+ export async function pruneRepo(
73
+ repo: PruneRepo,
74
+ opts: { yes?: boolean } = {}
75
+ ): Promise<RepoPrune> {
76
+ const [tips, prsOut, defaultOut] = await Promise.all([
77
+ remoteBranches(repo),
78
+ run(
79
+ "gh",
80
+ [
81
+ "pr",
82
+ "list",
83
+ "--repo",
84
+ repo.slug,
85
+ "--state",
86
+ "all",
87
+ "--limit",
88
+ "1000",
89
+ "--json",
90
+ "number,state,headRefName,headRefOid,baseRefName,isCrossRepository",
91
+ ],
92
+ repo.dir,
93
+ 120000
94
+ ),
95
+ run(
96
+ "gh",
97
+ [
98
+ "repo",
99
+ "view",
100
+ repo.slug,
101
+ "--json",
102
+ "defaultBranchRef",
103
+ "--jq",
104
+ ".defaultBranchRef.name",
105
+ ],
106
+ repo.dir,
107
+ 20000
108
+ ),
109
+ ]);
110
+ const prs = JSON.parse(prsOut) as ListedPR[];
111
+ const defaultBranch = defaultOut.trim();
112
+ const mine = taskBranches(repo.projectIds);
113
+ const result: RepoPrune = {
114
+ slug: repo.slug,
115
+ stale: [],
116
+ kept: [],
117
+ deleted: [],
118
+ };
119
+ const keep = (branch: string, why: string) =>
120
+ result.kept.push({ branch, why });
121
+
122
+ for (const [branch, tip] of [...tips].sort()) {
123
+ if (branch === defaultBranch || PROTECTED_BRANCHES.has(branch)) continue;
124
+ if (!mine.has(branch)) {
125
+ keep(branch, "not an AgentOS task's branch");
126
+ continue;
127
+ }
128
+ const own = prs
129
+ .filter((p) => p.headRefName === branch && !p.isCrossRepository)
130
+ .sort((a, b) => b.number - a.number);
131
+ if (!own.length) {
132
+ keep(branch, "no PR");
133
+ continue;
134
+ }
135
+ const open = prs.find(
136
+ (p) =>
137
+ p.state === "OPEN" &&
138
+ (p.baseRefName === branch ||
139
+ (p.headRefName === branch && !p.isCrossRepository))
140
+ );
141
+ if (open) {
142
+ keep(branch, `open PR #${open.number}`);
143
+ continue;
144
+ }
145
+ const latest = own[0];
146
+ if (latest.headRefOid !== tip) {
147
+ keep(branch, `moved to ${tip.slice(0, 7)} after PR #${latest.number}`);
148
+ continue;
149
+ }
150
+ result.stale.push({
151
+ branch,
152
+ pr: latest.number,
153
+ state: latest.state as StaleBranch["state"],
154
+ head: latest.headRefOid,
155
+ });
156
+ }
157
+
158
+ if (opts.yes)
159
+ for (const s of result.stale) {
160
+ const out = await deleteTaskBranch({
161
+ repo: repo.dir,
162
+ slug: repo.slug,
163
+ branch: s.branch,
164
+ prHead: s.head,
165
+ keep: [defaultBranch],
166
+ });
167
+ if (out.deleted) result.deleted.push(s.branch);
168
+ else keep(s.branch, out.why);
169
+ }
170
+ return result;
171
+ }
172
+
173
+ export function pruneText(results: RepoPrune[], yes: boolean): string {
174
+ const lines: string[] = [];
175
+ let total = 0;
176
+ for (const r of results) {
177
+ lines.push(`${r.slug}:`);
178
+ const list = yes ? r.deleted : r.stale.map((s) => s.branch);
179
+ total += list.length;
180
+ for (const s of r.stale)
181
+ if (!yes || r.deleted.includes(s.branch))
182
+ lines.push(
183
+ ` ${yes ? "deleted" : "stale"} ${s.branch} (PR #${s.pr} ${s.state.toLowerCase()})`
184
+ );
185
+ for (const k of r.kept) lines.push(` kept ${k.branch} (${k.why})`);
186
+ }
187
+ lines.push(
188
+ yes
189
+ ? `Deleted ${total} branch${total === 1 ? "" : "es"}.`
190
+ : `${total} stale branch${total === 1 ? "" : "es"}. Dry run: aos branches prune --yes deletes them.`
191
+ );
192
+ return lines.join("\n");
193
+ }
@@ -0,0 +1,22 @@
1
+ import { getProject } from "../projects";
2
+ import { repoSlug } from "../git-remote";
3
+ import { scopeProjectIds, type DoneScope } from "../done/bulk";
4
+ import { expandHome } from "./session";
5
+ import type { PruneRepo } from "./prune-branches";
6
+
7
+ // The scope's local GitHub clones, one entry per repository (projects that
8
+ // share one are merged, so its branches are listed once).
9
+ export async function prunableRepos(scope: DoneScope): Promise<PruneRepo[]> {
10
+ const bySlug = new Map<string, PruneRepo>();
11
+ for (const id of scopeProjectIds(scope)) {
12
+ const p = getProject(id);
13
+ if (!p || (p.host_id && p.host_id !== "local")) continue;
14
+ const dir = expandHome(p.working_directory);
15
+ const slug = await repoSlug(dir);
16
+ if (!slug) continue;
17
+ const hit = bySlug.get(slug);
18
+ if (hit) hit.projectIds.push(id);
19
+ else bySlug.set(slug, { dir, slug, projectIds: [id] });
20
+ }
21
+ return [...bySlug.values()];
22
+ }
@@ -10,7 +10,7 @@ import { peerReading, peerSource, refreshPeers } from "../hosts/peer-load";
10
10
  import { projectMachines } from "../hosts/project-machines";
11
11
  import { viewOf } from "../load/machines";
12
12
  import { usesGitCrypt } from "../worktree-add";
13
- import { hostTasks } from "./remote";
13
+ import { list as hostTasks } from "./contract/client";
14
14
  import {
15
15
  asPlanPhase,
16
16
  place,
@@ -18,7 +18,7 @@ export const row = (id: string) =>
18
18
  | undefined;
19
19
 
20
20
  export const sessionOf = (id: string) =>
21
- db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
21
+ db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
22
22
  | Session
23
23
  | undefined;
24
24
 
@@ -60,7 +60,7 @@ export function runningIn(
60
60
  return (
61
61
  db
62
62
  .prepare(
63
- `SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
63
+ `SELECT s.id FROM session_rows s JOIN projects p ON p.id = s.project_id
64
64
  WHERE ${where} AND s.archived_at IS NULL AND s.quick_of IS NULL
65
65
  AND (s.pause_state IS NULL OR s.pause_state != 'paused')
66
66
  ${forSlots ? `AND NOT (COALESCE(s.plan_only, 0) = 1 AND COALESCE(s.plan_phase, '') != 'implementing')` : ""}
@@ -127,7 +127,7 @@ export function resolveAfter(
127
127
  const scope = workspaceId ? `AND p.workspace_id = @ws` : "";
128
128
  const candidates = db
129
129
  .prepare(
130
- `SELECT s.id, s.name FROM sessions s JOIN projects p ON p.id = s.project_id
130
+ `SELECT s.id, s.name FROM session_rows s JOIN projects p ON p.id = s.project_id
131
131
  WHERE s.archived_at IS NULL AND s.task_status IN ('running', 'moving') ${scope}
132
132
  UNION ALL
133
133
  SELECT q.id, COALESCE(q.name, q.prompt) AS name FROM task_queue q
@@ -227,7 +227,7 @@ export function lineSignature(): string {
227
227
  .prepare(
228
228
  `SELECT COUNT(*) AS n, COALESCE(MAX(position), 0) AS p,
229
229
  COALESCE(SUM(priority = 'normal'), 0) AS pr,
230
- (SELECT COUNT(*) FROM sessions WHERE task_status IS NOT NULL
230
+ (SELECT COUNT(*) FROM session_rows WHERE task_status IS NOT NULL
231
231
  AND task_status NOT IN ('merged', 'dropped')) AS running
232
232
  FROM task_queue WHERE status IN ('queued', 'starting')`
233
233
  )
@@ -27,6 +27,7 @@ import {
27
27
  type QueueRow,
28
28
  type StartOutcome,
29
29
  } from "./queue-types";
30
+ import { setPlacement, setTask } from "../db/session-parts";
30
31
 
31
32
  // Tells the orchestrator that queued it that it started.
32
33
  export function announce(
@@ -205,7 +206,8 @@ function stampStarted(r: QueueRow, placed: Placement | null): void {
205
206
  const priority =
206
207
  r.priority && r.priority !== DEFAULT_PRIORITY ? r.priority : null;
207
208
  if (!note && !priority) return;
208
- db.prepare(
209
- `UPDATE sessions SET priority = COALESCE(?, priority), placed_note = COALESCE(?, placed_note) WHERE id = ?`
210
- ).run(priority, note, r.id);
209
+ db.transaction(() => {
210
+ setTask(db, r.id, { priority: priority ?? undefined });
211
+ setPlacement(db, r.id, { placed_note: note ?? undefined });
212
+ })();
211
213
  }
@@ -128,8 +128,8 @@ export function tickQueue(): Promise<void> {
128
128
  tickState.ticking ??= (async () => {
129
129
  try {
130
130
  // Plans written on a linked machine, and approved plans coming here.
131
- await import("./remote-plan")
132
- .then((m) => m.syncRemotePlans())
131
+ await import("./contract/sync")
132
+ .then((m) => m.syncMirrors())
133
133
  .catch((error: unknown) => console.error("[queue] plans:", error));
134
134
  for (let i = 0; i < 3; i++) {
135
135
  if ((await tickOnce()) !== "stale") break;