@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
@@ -0,0 +1,186 @@
1
+ import type { Session } from "@/lib/db/types";
2
+
3
+ // The sessions columns that live in their own tables, by concern. Each table
4
+ // is keyed by session_id and holds a row only for a session that has a value
5
+ // in the group; `session_rows` joins them back into the flat shape of a
6
+ // session. See docs/decisions/2026-10-15-split-sessions-table.md.
7
+ export const PART_COLUMNS = {
8
+ session_tasks: [
9
+ "task_prompt",
10
+ "task_brief",
11
+ "task_status",
12
+ "task_since",
13
+ "task_ended_at",
14
+ "merged_at",
15
+ "priority",
16
+ "quick_of",
17
+ "moved_to",
18
+ "moved_from",
19
+ "model_overrides",
20
+ "lh_card_id",
21
+ "lh_board_id",
22
+ "lh_card_list",
23
+ ],
24
+ session_prs: [
25
+ "pr_url",
26
+ "pr_number",
27
+ "pr_status",
28
+ "merge_armed_at",
29
+ "merge_armed_seen",
30
+ "rebased_from",
31
+ "rebased_to",
32
+ "rebase_asked_head",
33
+ "pr_wake_since",
34
+ ],
35
+ session_plans: [
36
+ "plan_phase",
37
+ "plan_only",
38
+ "plan_decided",
39
+ "plan_reason",
40
+ "plan_note",
41
+ "plan_edits",
42
+ "plan_waits_for_caller",
43
+ "plan_handoff_message",
44
+ ],
45
+ session_placement: [
46
+ "peer_mirror",
47
+ "placed_note",
48
+ "held_by_device",
49
+ "held_from",
50
+ "held_for",
51
+ "held_for_name",
52
+ "started_by_device",
53
+ ],
54
+ session_pauses: ["pause_state", "paused_until", "pause_id", "pause_note"],
55
+ } as const satisfies Record<string, readonly (keyof Session)[]>;
56
+
57
+ export type PartTable = keyof typeof PART_COLUMNS;
58
+ export type PartColumn<T extends PartTable> = (typeof PART_COLUMNS)[T][number];
59
+ export type PartPatch<T extends PartTable> = {
60
+ [C in PartColumn<T>]?: Session[C & keyof Session];
61
+ };
62
+
63
+ export const PART_TABLES = Object.keys(PART_COLUMNS) as PartTable[];
64
+
65
+ // Everything else is an INTEGER or TEXT by this list.
66
+ export const INTEGER_COLUMNS: ReadonlySet<string> = new Set([
67
+ "pr_number",
68
+ "plan_only",
69
+ "plan_decided",
70
+ "plan_waits_for_caller",
71
+ "peer_mirror",
72
+ "pause_id",
73
+ ]);
74
+
75
+ const partOfColumn = new Map<string, PartTable>(
76
+ PART_TABLES.flatMap((t) =>
77
+ (PART_COLUMNS[t] as readonly string[]).map((c) => [c, t] as const)
78
+ )
79
+ );
80
+
81
+ export const isMovedColumn = (column: string): boolean =>
82
+ partOfColumn.has(column);
83
+
84
+ export const partOf = (column: string): PartTable | undefined =>
85
+ partOfColumn.get(column);
86
+
87
+ // The shape of a session row and of `session_rows`, in order: the sessions
88
+ // columns and the moved ones where they always stood. A migration that adds a
89
+ // sessions column adds it here and rebuilds the view (createSessionRowsView).
90
+ export const SESSION_ROW_COLUMNS: readonly string[] = [
91
+ "id",
92
+ "name",
93
+ "created_at",
94
+ "updated_at",
95
+ "status",
96
+ "working_directory",
97
+ "parent_session_id",
98
+ "claude_session_id",
99
+ "model",
100
+ "system_prompt",
101
+ "group_path",
102
+ "agent_type",
103
+ "worktree_path",
104
+ "branch_name",
105
+ "base_branch",
106
+ "dev_server_port",
107
+ "pr_url",
108
+ "pr_number",
109
+ "pr_status",
110
+ "conductor_session_id",
111
+ "worker_task",
112
+ "worker_status",
113
+ "auto_approve",
114
+ "project_id",
115
+ "tmux_name",
116
+ "host_id",
117
+ "task_prompt",
118
+ "task_status",
119
+ "view",
120
+ "lh_card_id",
121
+ "lh_board_id",
122
+ "lh_card_list",
123
+ "chat_access",
124
+ "chat_resume_at",
125
+ "last_seen_at",
126
+ "role",
127
+ "workspace_id",
128
+ "orch_token",
129
+ "archived_at",
130
+ "pinned",
131
+ "chat_suggestion",
132
+ "chat_plan",
133
+ "chat_context",
134
+ "chat_usage",
135
+ "name_source",
136
+ "setup_status",
137
+ "setup_ms",
138
+ "setup_error",
139
+ "task_brief",
140
+ "moved_to",
141
+ "moved_from",
142
+ "port_slot",
143
+ "ports",
144
+ "database",
145
+ "peer_mirror",
146
+ "chat_restarted",
147
+ "chat_tools_digest",
148
+ "merge_armed_at",
149
+ "merge_armed_seen",
150
+ "rebased_from",
151
+ "rebased_to",
152
+ "rebase_asked_head",
153
+ "chat_refreshed",
154
+ "pr_wake_since",
155
+ "merged_at",
156
+ "task_ended_at",
157
+ "model_provider",
158
+ "model_overrides",
159
+ "plan_phase",
160
+ "quick_of",
161
+ "started_by",
162
+ "plan_decided",
163
+ "plan_reason",
164
+ "task_since",
165
+ "held_by_device",
166
+ "held_from",
167
+ "plan_note",
168
+ "held_for",
169
+ "held_for_name",
170
+ "plan_only",
171
+ "plan_edits",
172
+ "active_at",
173
+ "settled_at",
174
+ "snoozed_until",
175
+ "snoozed_at",
176
+ "priority",
177
+ "pause_state",
178
+ "paused_until",
179
+ "pause_id",
180
+ "placed_note",
181
+ "started_by_device",
182
+ "pause_note",
183
+ "started_via",
184
+ "plan_waits_for_caller",
185
+ "plan_handoff_message",
186
+ ];
@@ -0,0 +1,33 @@
1
+ import type Database from "better-sqlite3";
2
+ import { partOf, type PartTable } from "./columns";
3
+ import { writePart } from "./write";
4
+
5
+ // Copies values onto a session's moved columns where it has none yet: a
6
+ // column that already holds a value, and a null to copy, are left alone.
7
+ // Names that aren't moved columns are the caller's to write.
8
+ export function fillMissing(
9
+ db: Database.Database,
10
+ sessionId: string,
11
+ values: Record<string, string | number | null | undefined>
12
+ ): void {
13
+ const byTable = new Map<PartTable, string[]>();
14
+ for (const [column, value] of Object.entries(values)) {
15
+ const table = partOf(column);
16
+ if (!table || value === null || value === undefined) continue;
17
+ byTable.set(table, [...(byTable.get(table) ?? []), column]);
18
+ }
19
+ db.transaction(() => {
20
+ for (const [table, columns] of byTable) {
21
+ const current = db
22
+ .prepare(`SELECT ${columns.join(", ")} FROM session_rows WHERE id = ?`)
23
+ .get(sessionId) as Record<string, unknown> | undefined;
24
+ if (!current) continue;
25
+ const patch = Object.fromEntries(
26
+ columns
27
+ .filter((c) => current[c] === null)
28
+ .map((c) => [c, values[c]] as const)
29
+ );
30
+ writePart(db, table, sessionId, patch as never);
31
+ }
32
+ })();
33
+ }
@@ -0,0 +1,10 @@
1
+ export * from "./columns";
2
+ export { createSessionRowsView } from "./view";
3
+ export { writePart, type When } from "./write";
4
+ export { setTask, startTaskClock } from "./tasks";
5
+ export { setPr, recordPr } from "./prs";
6
+ export { setPlan, advancePlan } from "./plans";
7
+ export { setPlacement } from "./placement";
8
+ export { setPause } from "./pauses";
9
+ export { fillMissing } from "./fill";
10
+ export { insertSessionRow, updateSessionRow } from "./rows";
@@ -0,0 +1,11 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { PartPatch } from "./columns";
3
+ import { writePart, type When } from "./write";
4
+
5
+ // A task's timed pause.
6
+ export const setPause = (
7
+ db: Database.Database,
8
+ sessionId: string,
9
+ patch: PartPatch<"session_pauses">,
10
+ when?: When<"session_pauses">
11
+ ): boolean => writePart(db, "session_pauses", sessionId, patch, when);
@@ -0,0 +1,11 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { PartPatch } from "./columns";
3
+ import { writePart, type When } from "./write";
4
+
5
+ // Where a task came from and who holds it: mirror, holder, starting device.
6
+ export const setPlacement = (
7
+ db: Database.Database,
8
+ sessionId: string,
9
+ patch: PartPatch<"session_placement">,
10
+ when?: When<"session_placement">
11
+ ): boolean => writePart(db, "session_placement", sessionId, patch, when);
@@ -0,0 +1,31 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { PartPatch } from "./columns";
3
+ import { writePart, type When } from "./write";
4
+
5
+ // A task's plan step: phase, note, edits and the hand-off.
6
+ export const setPlan = (
7
+ db: Database.Database,
8
+ sessionId: string,
9
+ patch: PartPatch<"session_plans">,
10
+ when?: When<"session_plans">
11
+ ): boolean => writePart(db, "session_plans", sessionId, patch, when);
12
+
13
+ // Moves a plan from one phase to the next, only while the task is running
14
+ // and still in `from` (a step another caller took first returns false).
15
+ export function advancePlan(
16
+ db: Database.Database,
17
+ sessionId: string,
18
+ from: string,
19
+ to: NonNullable<PartPatch<"session_plans">["plan_phase"]>
20
+ ): boolean {
21
+ return db.transaction(() => {
22
+ const running = db
23
+ .prepare(
24
+ `SELECT 1 FROM session_rows WHERE id = ? AND task_status = 'running'`
25
+ )
26
+ .get(sessionId);
27
+ return running
28
+ ? setPlan(db, sessionId, { plan_phase: to }, { plan_phase: from })
29
+ : false;
30
+ })();
31
+ }
@@ -0,0 +1,31 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { Session } from "@/lib/db/types";
3
+ import type { PartPatch } from "./columns";
4
+ import { writePart, type When } from "./write";
5
+
6
+ // A session's PR and the merge and rebase state kept for it.
7
+ export const setPr = (
8
+ db: Database.Database,
9
+ sessionId: string,
10
+ patch: PartPatch<"session_prs">,
11
+ when?: When<"session_prs">
12
+ ): boolean => writePart(db, "session_prs", sessionId, patch, when);
13
+
14
+ // A PR found for the session: its link, number and state, and the session
15
+ // counts as touched.
16
+ export function recordPr(
17
+ db: Database.Database,
18
+ sessionId: string,
19
+ pr: { url: string; number: number; state: string }
20
+ ): void {
21
+ db.transaction(() => {
22
+ setPr(db, sessionId, {
23
+ pr_url: pr.url,
24
+ pr_number: pr.number,
25
+ pr_status: pr.state as Session["pr_status"],
26
+ });
27
+ db.prepare(
28
+ `UPDATE sessions SET updated_at = datetime('now') WHERE id = ?`
29
+ ).run(sessionId);
30
+ })();
31
+ }
@@ -0,0 +1,69 @@
1
+ import type Database from "better-sqlite3";
2
+ import { PART_TABLES, partOf, type PartTable } from "./columns";
3
+ import { writePart } from "./write";
4
+
5
+ type Value = string | number | null | undefined;
6
+
7
+ function split(row: Record<string, Value>) {
8
+ const own: Record<string, Value> = {};
9
+ const parts = new Map<PartTable, Record<string, Value>>();
10
+ for (const [column, value] of Object.entries(row)) {
11
+ if (value === undefined) continue;
12
+ const table = partOf(column);
13
+ if (!table) own[column] = value;
14
+ else parts.set(table, { ...parts.get(table), [column]: value });
15
+ }
16
+ return { own, parts };
17
+ }
18
+
19
+ const writeParts = (
20
+ db: Database.Database,
21
+ id: string,
22
+ parts: Map<PartTable, Record<string, Value>>
23
+ ) => {
24
+ for (const table of PART_TABLES) {
25
+ const patch = parts.get(table);
26
+ if (patch) writePart(db, table, id, patch as never);
27
+ }
28
+ };
29
+
30
+ // A session row in its flat shape (sessions columns and moved ones together),
31
+ // written to sessions and the part tables. A moved column left null or unset
32
+ // makes no part row. For seeds, imports and tests.
33
+ export function insertSessionRow(
34
+ db: Database.Database,
35
+ row: Record<string, Value> & { id: string }
36
+ ): void {
37
+ const { own, parts } = split(row);
38
+ for (const [table, patch] of parts) {
39
+ for (const [c, v] of Object.entries(patch))
40
+ if (v === null || (c === "peer_mirror" && v === 0)) delete patch[c];
41
+ if (Object.keys(patch).length === 0) parts.delete(table);
42
+ }
43
+ const columns = Object.keys(own);
44
+ db.transaction(() => {
45
+ db.prepare(
46
+ `INSERT INTO sessions (${columns.join(", ")})
47
+ VALUES (${columns.map((c) => `@${c}`).join(", ")})`
48
+ ).run(own);
49
+ writeParts(db, row.id, parts);
50
+ })();
51
+ }
52
+
53
+ // Updates any of a session's columns by name, wherever each one lives.
54
+ export function updateSessionRow(
55
+ db: Database.Database,
56
+ id: string,
57
+ patch: Record<string, Value>
58
+ ): void {
59
+ const { own, parts } = split(patch);
60
+ const columns = Object.keys(own);
61
+ db.transaction(() => {
62
+ if (columns.length > 0)
63
+ db.prepare(
64
+ `UPDATE sessions SET ${columns.map((c) => `${c} = @${c}`).join(", ")}
65
+ WHERE id = @__id`
66
+ ).run({ ...own, __id: id });
67
+ writeParts(db, id, parts);
68
+ })();
69
+ }
@@ -0,0 +1,27 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { PartPatch } from "./columns";
3
+ import { writePart, type When } from "./write";
4
+
5
+ // A task's brief, status, priority, models and card: lib/db/session-parts/columns.ts.
6
+ export const setTask = (
7
+ db: Database.Database,
8
+ sessionId: string,
9
+ patch: PartPatch<"session_tasks">,
10
+ when?: When<"session_tasks">
11
+ ): boolean => writePart(db, "session_tasks", sessionId, patch, when);
12
+
13
+ // The task's first start, kept across moves: set once, from created_at when
14
+ // the caller has no better time.
15
+ export function startTaskClock(
16
+ db: Database.Database,
17
+ sessionId: string,
18
+ at?: string | null
19
+ ): void {
20
+ const row = db
21
+ .prepare(`SELECT created_at, task_since FROM session_rows WHERE id = ?`)
22
+ .get(sessionId) as
23
+ | { created_at: string; task_since: string | null }
24
+ | undefined;
25
+ if (!row || row.task_since) return;
26
+ setTask(db, sessionId, { task_since: at ?? row.created_at });
27
+ }
@@ -0,0 +1,50 @@
1
+ import type Database from "better-sqlite3";
2
+ import { PART_COLUMNS, SESSION_ROW_COLUMNS, type PartTable } from "./columns";
3
+
4
+ const ALIAS: Record<PartTable, string> = {
5
+ session_tasks: "t",
6
+ session_prs: "pr",
7
+ session_plans: "pl",
8
+ session_placement: "pp",
9
+ session_pauses: "pa",
10
+ };
11
+
12
+ // `session_rows`: a session with each moved column read from its own table,
13
+ // in the order of SESSION_ROW_COLUMNS (the flat shape a session row always
14
+ // had). A column with a default (peer_mirror) keeps it. Rebuild it in any
15
+ // migration that adds a sessions column. `parts` is the split as that
16
+ // migration knew it; a column on both sessions and a part (before the old
17
+ // columns were dropped) is read from the part.
18
+ export function createSessionRowsView(
19
+ db: Database.Database,
20
+ parts: Record<PartTable, readonly string[]> = PART_COLUMNS
21
+ ): void {
22
+ const source = new Map<string, string>();
23
+ for (const table of Object.keys(parts) as PartTable[])
24
+ for (const c of parts[table]) source.set(c, ALIAS[table]);
25
+ const sessionColumns = (
26
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
27
+ ).map((c) => c.name);
28
+ const onSessions = new Set(sessionColumns);
29
+ const known = new Set(SESSION_ROW_COLUMNS);
30
+ const names = [
31
+ ...SESSION_ROW_COLUMNS.filter((c) => source.has(c) || onSessions.has(c)),
32
+ ...sessionColumns.filter((c) => !known.has(c)),
33
+ ];
34
+ const select = names
35
+ .map((c) => {
36
+ const alias = source.get(c);
37
+ if (!alias) return `s.${c}`;
38
+ return c === "peer_mirror"
39
+ ? `COALESCE(${alias}.${c}, 0) AS ${c}`
40
+ : `${alias}.${c} AS ${c}`;
41
+ })
42
+ .join(", ");
43
+ const joins = (Object.keys(parts) as PartTable[])
44
+ .map((t) => `LEFT JOIN ${t} ${ALIAS[t]} ON ${ALIAS[t]}.session_id = s.id`)
45
+ .join(" ");
46
+ db.exec(`DROP VIEW IF EXISTS session_rows`);
47
+ db.exec(
48
+ `CREATE VIEW session_rows AS SELECT ${select} FROM sessions s ${joins}`
49
+ );
50
+ }
@@ -0,0 +1,61 @@
1
+ import type Database from "better-sqlite3";
2
+ import { PART_COLUMNS, type PartPatch, type PartTable } from "./columns";
3
+
4
+ type Value = string | number | null;
5
+
6
+ // A condition on the part row's current values, checked with the write in one
7
+ // transaction: every column must equal one of its listed values (null for
8
+ // "unset").
9
+ export type When<T extends PartTable> = {
10
+ [C in keyof PartPatch<T>]?: Value | readonly Value[];
11
+ };
12
+
13
+ const sameAs = (current: unknown, wanted: Value | readonly Value[]): boolean =>
14
+ (Array.isArray(wanted) ? (wanted as Value[]) : [wanted as Value]).some(
15
+ (w) => (current ?? null) === w
16
+ );
17
+
18
+ // The one writer of the part tables: writes the patched columns to the part
19
+ // table (docs/decisions/2026-10-15-split-sessions-table.md). Returns whether
20
+ // it wrote (false: the session is gone or `when` no longer holds).
21
+ export function writePart<T extends PartTable>(
22
+ db: Database.Database,
23
+ table: T,
24
+ sessionId: string,
25
+ patch: PartPatch<T>,
26
+ when?: When<T>
27
+ ): boolean {
28
+ const allowed = PART_COLUMNS[table] as readonly string[];
29
+ const columns = Object.keys(patch).filter(
30
+ (c) => (patch as Record<string, unknown>)[c] !== undefined
31
+ );
32
+ for (const c of [...columns, ...Object.keys(when ?? {})])
33
+ if (!allowed.includes(c)) throw new Error(`${c} is not a ${table} column`);
34
+ if (columns.length === 0) return false;
35
+
36
+ const run = db.transaction((): boolean => {
37
+ if (when && Object.keys(when).length > 0) {
38
+ const current = db
39
+ .prepare(
40
+ `SELECT ${Object.keys(when).join(", ")} FROM session_rows WHERE id = ?`
41
+ )
42
+ .get(sessionId) as Record<string, unknown> | undefined;
43
+ if (!current) return false;
44
+ for (const [c, wanted] of Object.entries(when))
45
+ if (!sameAs(current[c], wanted as Value | readonly Value[]))
46
+ return false;
47
+ }
48
+ const values = { id: sessionId, ...(patch as Record<string, Value>) };
49
+ const inserted = db
50
+ .prepare(
51
+ `INSERT INTO ${table} (session_id, ${columns.join(", ")})
52
+ SELECT id, ${columns.map((c) => `@${c}`).join(", ")}
53
+ FROM sessions WHERE id = @id
54
+ ON CONFLICT(session_id) DO UPDATE SET
55
+ ${columns.map((c) => `${c} = excluded.${c}`).join(", ")}`
56
+ )
57
+ .run(values);
58
+ return inserted.changes > 0;
59
+ });
60
+ return run();
61
+ }
@@ -34,7 +34,7 @@ export function unarchiveSession(id: string): Session {
34
34
  `UPDATE sessions SET archived_at = NULL WHERE id = ? AND archived_at IS NOT NULL`
35
35
  )
36
36
  .run(id).changes;
37
- const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
37
+ const row = db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
38
38
  | Session
39
39
  | undefined;
40
40
  if (!row) throw new Error("Session not found");
@@ -48,7 +48,7 @@ export function listArchived(workspaceId?: string): ArchivedView[] {
48
48
  .prepare(
49
49
  `SELECT s.id, s.name, s.project_id, s.view, s.task_status, s.branch_name,
50
50
  s.pr_url, s.worktree_path, s.archived_at, p.name AS project_name, p.workspace_id AS project_workspace
51
- FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
51
+ FROM session_rows s LEFT JOIN projects p ON p.id = s.project_id
52
52
  WHERE s.archived_at IS NOT NULL
53
53
  AND (? IS NULL OR p.workspace_id = ?)
54
54
  ORDER BY s.archived_at DESC, s.updated_at DESC`
@@ -33,7 +33,7 @@ const inDone = (s: Session) =>
33
33
  export async function archiveStaleDone(now = Date.now()): Promise<string[]> {
34
34
  const rows = db
35
35
  .prepare(
36
- `SELECT * FROM sessions
36
+ `SELECT * FROM session_rows
37
37
  WHERE archived_at IS NULL AND pinned = 0 AND role IS NULL
38
38
  AND peer_mirror = 0 AND quick_of IS NULL
39
39
  AND (pr_status IS NULL OR pr_status != 'open')`
package/lib/done/bulk.ts CHANGED
@@ -50,7 +50,7 @@ function scopeSessions(scope: DoneScope): Session[] {
50
50
  if (!projectIds.length) return [];
51
51
  return db
52
52
  .prepare(
53
- `SELECT * FROM sessions
53
+ `SELECT * FROM session_rows
54
54
  WHERE project_id IN (${projectIds.map(() => "?").join(", ")})
55
55
  AND role IS NULL AND archived_at IS NULL
56
56
  -- A linked machine's own sessions are cleaned up there.
package/lib/done/index.ts CHANGED
@@ -31,10 +31,11 @@ import { releasePorts } from "../ports";
31
31
  import { dropSessionDatabase } from "../project-config/database";
32
32
  import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
33
33
  import { hostLink } from "../hosts/remote-api";
34
- import { doneHeldTask, isHeldRemote } from "../tasks/held-remote";
34
+ import { doneHeldTask, isHeldRemote } from "../tasks/contract/held-calls";
35
35
  import { captureMergedTask } from "../session-logs";
36
36
  import { sessionEnded } from "../experiments/hooks";
37
37
  import { pushMerged } from "../notify/push/events";
38
+ import { setPr, setTask } from "../db/session-parts";
38
39
 
39
40
  export interface DoneOptions {
40
41
  // Who asked: the orchestrator's gate failures count toward Saad, as its
@@ -108,11 +109,10 @@ async function mergeThroughGates(
108
109
  }
109
110
 
110
111
  function mark(s: Session, status: "merged" | "done", pr: TaskPR | null) {
111
- db.prepare(
112
- status === "merged"
113
- ? `UPDATE sessions SET task_status = 'merged', pr_status = 'merged' WHERE id = ?`
114
- : `UPDATE sessions SET task_status = 'done' WHERE id = ?`
115
- ).run(s.id);
112
+ db.transaction(() => {
113
+ setTask(db, s.id, { task_status: status });
114
+ if (status === "merged") setPr(db, s.id, { pr_status: "merged" });
115
+ })();
116
116
  endPauseOnFinish(s.id);
117
117
  syncTaskCardInBackground(s, status, pr);
118
118
  void sessionEnded(s.id, status);
@@ -133,10 +133,7 @@ export async function doneSession(
133
133
  if (s.role === "orchestrator")
134
134
  throw new Error("An orchestrator isn't marked done.");
135
135
  if (s.archived_at) throw new Error(`${s.name} is already archived.`);
136
- if (s.task_prompt)
137
- db.prepare(`UPDATE sessions SET task_status = 'done' WHERE id = ?`).run(
138
- s.id
139
- );
136
+ if (s.task_prompt) setTask(db, s.id, { task_status: "done" });
140
137
  archiveSession(s.id);
141
138
  return {
142
139
  id: s.id,
@@ -180,10 +177,7 @@ export async function doneSession(
180
177
  // agent and removes the worktree, which is where they are.
181
178
  await doneHeldTask(s);
182
179
  if (plan.mark) mark(s, plan.mark, plan.pr);
183
- else
184
- db.prepare(
185
- `UPDATE sessions SET task_status = 'done' WHERE id = ? AND task_status = 'running'`
186
- ).run(s.id);
180
+ else setTask(db, s.id, { task_status: "done" }, { task_status: "running" });
187
181
  worktree = {
188
182
  action: "removed",
189
183
  why: `on ${hostLink(s.host_id)?.hostName ?? "its machine"}`,
@@ -4,9 +4,8 @@
4
4
 
5
5
  import type { Session } from "../db";
6
6
  import type { TaskPR } from "../tasks";
7
- import { git, gitPush } from "../git-exec";
8
7
  import { projectPathFor } from "../tasks/session";
9
- import { PROTECTED_BRANCHES } from "../tasks/finish";
8
+ import { deleteTaskBranch } from "../tasks/remote-branch";
10
9
  import { liveChildren } from "../stacks/guard";
11
10
 
12
11
  export async function deleteMergedRemote(
@@ -14,23 +13,13 @@ export async function deleteMergedRemote(
14
13
  pr: TaskPR | null
15
14
  ): Promise<boolean> {
16
15
  const repo = projectPathFor(s);
17
- const branch = s.branch_name;
18
- if (
19
- !repo ||
20
- !branch ||
21
- PROTECTED_BRANCHES.has(branch) ||
22
- !pr?.head ||
23
- liveChildren(s.id).length
24
- )
25
- return false;
26
- const line = await git(
16
+ if (!repo || !s.branch_name) return false;
17
+ const out = await deleteTaskBranch({
27
18
  repo,
28
- ["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
29
- { timeout: 30000 }
30
- ).catch(() => "");
31
- const tip = line.trim().split(/\s+/)[0];
32
- if (!tip || tip !== pr.head) return false;
33
- return gitPush(repo, ["origin", "--delete", branch], { timeout: 60000 })
34
- .then(() => true)
35
- .catch(() => false);
19
+ branch: s.branch_name,
20
+ prHead: pr?.head,
21
+ keep: [s.base_branch],
22
+ hasChildren: liveChildren(s.id).length > 0,
23
+ });
24
+ return out.deleted;
36
25
  }
@@ -61,7 +61,7 @@ function schedulerSnapshot(f: HealthFacts): string {
61
61
  const running = db
62
62
  .prepare(
63
63
  `SELECT s.name, s.pause_state, s.plan_only, s.plan_phase, s.task_status
64
- FROM sessions s WHERE s.task_status IN ('running', 'moving')
64
+ FROM session_rows s WHERE s.task_status IN ('running', 'moving')
65
65
  AND s.archived_at IS NULL AND s.quick_of IS NULL LIMIT 20`
66
66
  )
67
67
  .all() as {