@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,322 @@
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, expect, vi } from "vitest";
6
+ import type { TaskPR } from "@/lib/tasks/state";
7
+ import type { ClaudeRun } from "./claude-cli";
8
+ import type { SessionFacts } from "./facts";
9
+
10
+ // Shared fixtures for the signoff*.test.ts files: a real repository with a
11
+ // local bare remote, gh faked around it. Import this before anything it mocks.
12
+ // Tests change the fake world through S.
13
+ export const S: {
14
+ pr: TaskPR | null;
15
+ prError: Error | null;
16
+ pane: string;
17
+ refuseMerge: string | null;
18
+ probeOut: string | Error;
19
+ ciLog: string;
20
+ commitDate: string | undefined;
21
+ } = {
22
+ pr: null,
23
+ prError: null,
24
+ pane: "",
25
+ refuseMerge: null,
26
+ probeOut: "0",
27
+ ciLog:
28
+ "Test 1\tRun tests\t2026-01-01T00:00:00.0Z FAIL src/a.test.ts > adds\n",
29
+ commitDate: "2026-01-01T00:00:00Z",
30
+ };
31
+ export const ATTRIBUTED = "a Co-Authored-By trailer naming Claude" as const;
32
+ export const merges: string[][] = [];
33
+ export const JOB_URL = "https://github.com/o/r/actions/runs/1/job/2";
34
+ // CI failing on the head, with a log the loop guard can read.
35
+ export const failingCI = (failing = "unit") =>
36
+ ({ ...S.pr!, checks: "fail", failing, failingUrl: JOB_URL }) as TaskPR;
37
+ export const isProbe = (args: string[]) =>
38
+ args[0] === "api" ||
39
+ (args[0] === "pr" &&
40
+ args[1] === "list" &&
41
+ args.includes("statusCheckRollup"));
42
+
43
+ vi.mock("@/lib/tasks/gh", async (importOriginal) => {
44
+ const real = await importOriginal<typeof import("@/lib/tasks/gh")>();
45
+ return {
46
+ ...real,
47
+ run: async (cmd: string, args: string[], cwd: string, t?: number) => {
48
+ if (cmd === "gh" && args[0] === "run" && args[1] === "view")
49
+ return S.ciLog;
50
+ if (cmd === "gh" && isProbe(args)) {
51
+ if (S.probeOut instanceof Error) throw S.probeOut;
52
+ return S.probeOut;
53
+ }
54
+ if (cmd === "gh") {
55
+ if (S.refuseMerge) throw new Error(S.refuseMerge);
56
+ merges.push(args);
57
+ if (S.pr) S.pr = { ...S.pr, state: "MERGED" };
58
+ return "";
59
+ }
60
+ if (cmd === "tmux") throw new Error("no tmux in tests");
61
+ return real.run(cmd, args, cwd, t);
62
+ },
63
+ findPR: async () => S.pr,
64
+ findPRStrict: async () => {
65
+ if (S.prError) throw S.prError;
66
+ return S.pr;
67
+ },
68
+ };
69
+ });
70
+ // The test clones have a local origin; the CI probes need a GitHub name.
71
+ vi.mock("./repo-slug", async (importOriginal) => ({
72
+ ...(await importOriginal<typeof import("./repo-slug")>()),
73
+ repoSlug: async () => "o/r",
74
+ }));
75
+ vi.mock("@/lib/status-detector", () => ({
76
+ checkWaitingPatterns: (t: string) => /Allow\?/.test(t),
77
+ statusDetector: {
78
+ refreshCache: async () => {},
79
+ sessionExists: () => S.pane !== "",
80
+ getStatus: async () => "idle",
81
+ titleFor: () => "",
82
+ getTimestamp: () => 0,
83
+ hostFor: () => "local",
84
+ capturePane: async () => S.pane,
85
+ },
86
+ }));
87
+
88
+ export const { db, stackQueries, updateSessionRow } = await import("@/lib/db");
89
+ export const { createProject } = await import("@/lib/projects");
90
+ export const { createWorkspace, setProjectWorkspace } =
91
+ await import("@/lib/workspaces");
92
+ export const { ensureOrchestrator } = await import("./home");
93
+ export const { runTool } = await import("./serve");
94
+ export const { judge, landDeps, mergeJudged, refundIfRefused } =
95
+ await import("./signoff");
96
+ export const { armTask, mergeArmed } = await import("./armed");
97
+ export const { resumeReviews, review } = await import("./review");
98
+ export const { getCheck, putCheck } = await import("./checks");
99
+ export const { failureOf, recordFailure } = await import("./gates");
100
+ export const { LOOP_GUARD_COMMITS } = await import("./loop-guard");
101
+ export const { clearRepoCI } = await import("./repo-ci");
102
+ export const { listNotes } = await import("./notes");
103
+ export const { listItems } = await import("@/lib/chat/store");
104
+ export const { claudeArgs } = await import("./claude-cli");
105
+ export const { seedSession } = await import("./testing");
106
+ export const { answerAsk, getAsk, openAsks, raiseAsk, BRAKE_SUBJECT } =
107
+ await import("./asks");
108
+ export const { setMergeApprovals } = await import("./merge-approvals");
109
+ export const { releaseInterruptedClaims, spendApproval } =
110
+ await import("./ask-approvals");
111
+ export const { amendBrief } = await import("./amendments");
112
+ export const { fetchRefs } = await import("./repo");
113
+ export const { getTaskSession } = await import("@/lib/tasks/session");
114
+ export const { changedFiles } = await import("./diff");
115
+ export const { followAskHeads } = await import("./ask-heads");
116
+ export const { followAskVerdicts } = await import("./ask-verdict");
117
+ export const { raiseHeldAsks, HOLD_MIN_MS, HOLD_MAX_MS } =
118
+ await import("./held-asks");
119
+
120
+ // The one ask a task's escalation leaves on Saad's list.
121
+ export function oneGateAsk(w: string, task: string, sha: string) {
122
+ const asks = openAsks(w);
123
+ expect(asks).toHaveLength(1);
124
+ expect(asks[0]).toMatchObject({
125
+ subject: `task:${task}`,
126
+ kind: "gate",
127
+ title: "Merge add-a?",
128
+ link: "https://github.com/o/r/pull/7",
129
+ sha,
130
+ });
131
+ return asks[0];
132
+ }
133
+
134
+ // The loop guard's last strike: the gate already failed on the commits
135
+ // before this one.
136
+ export const failedBefore = (w: string, task: string, gate: string) => {
137
+ for (let i = 1; i < LOOP_GUARD_COMMITS; i++)
138
+ recordFailure(w, task, gate, `earlier ${i}`, String(i).repeat(40));
139
+ };
140
+
141
+ // Facts for the task: its agent idle, or busy.
142
+ export const taskFacts = (task: string, status: "idle" | "running" = "idle") =>
143
+ [
144
+ { id: task, name: "add-a", status, task: { state: "review", pr: S.pr } },
145
+ ] as unknown as SessionFacts[];
146
+
147
+ // A failed-twice escalation's ask goes up once the agent is idle and CI
148
+ // has settled; `after` ms from now.
149
+ // The first look at a head's checks starts CI's settle clock, so it looks
150
+ // twice, the second a settle period later.
151
+ export const askUp = async (
152
+ w: string,
153
+ task: string,
154
+ after = HOLD_MIN_MS + 1000,
155
+ status: "idle" | "running" = "idle"
156
+ ) => {
157
+ await raiseHeldAsks(w, taskFacts(task, status), Date.now() + after);
158
+ await raiseHeldAsks(w, taskFacts(task, status), Date.now() + after + 200_000);
159
+ };
160
+
161
+ // Poll for the condition, generously: a loaded machine is slow, not wrong.
162
+ export const POLL = { timeout: 20000, interval: 20 };
163
+
164
+ beforeAll(() => {
165
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "aos-orch-signoff-"));
166
+ vi.spyOn(os, "homedir").mockReturnValue(home);
167
+ });
168
+ beforeEach(() => {
169
+ merges.length = 0;
170
+ S.refuseMerge = null;
171
+ S.probeOut = "0";
172
+ S.ciLog =
173
+ "Test 1\tRun tests\t2026-01-01T00:00:00.0Z FAIL src/a.test.ts > adds\n";
174
+ clearRepoCI();
175
+ // A live terminal at its prompt, nothing waiting.
176
+ S.pane = "❯ ";
177
+ });
178
+
179
+ export const git = (cwd: string, ...args: string[]) =>
180
+ execFileSync("git", args, {
181
+ cwd,
182
+ encoding: "utf8",
183
+ stdio: "pipe",
184
+ env: {
185
+ ...process.env,
186
+ ...(S.commitDate ? { GIT_COMMITTER_DATE: S.commitDate } : {}),
187
+ },
188
+ }).trim();
189
+
190
+ export function commit(
191
+ repo: string,
192
+ file: string,
193
+ text: string,
194
+ link?: string
195
+ ): string {
196
+ fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
197
+ if (link) fs.symlinkSync(link, path.join(repo, file));
198
+ else fs.writeFileSync(path.join(repo, file), text);
199
+ git(repo, "add", "-A");
200
+ git(repo, "commit", "-qm", `change ${file}`);
201
+ git(repo, "push", "-q", "origin", "HEAD");
202
+ return git(repo, "rev-parse", "HEAD");
203
+ }
204
+
205
+ // A workspace whose project is a real clone, with a task on its branch
206
+ // and its PR open at the branch's head.
207
+ export function setup(
208
+ opts: {
209
+ card?: boolean;
210
+ onMain?: Array<[string, string]>;
211
+ approvals?: boolean;
212
+ } = {}
213
+ ) {
214
+ setMergeApprovals(opts.approvals ?? false);
215
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "aos-orch-repo-"));
216
+ const remote = path.join(root, "remote.git");
217
+ const repo = path.join(root, "repo");
218
+ git(root, "init", "-q", "--bare", "-b", "main", remote);
219
+ git(root, "clone", "-q", remote, repo);
220
+ git(repo, "config", "user.email", "t@example.com");
221
+ git(repo, "config", "user.name", "T");
222
+ commit(repo, "README.md", "hello\n");
223
+ for (const file of opts.onMain ?? []) commit(repo, ...file);
224
+ git(repo, "checkout", "-qb", "feature/t");
225
+ const sha = commit(repo, "src/a.ts", "export const a = 1;\n");
226
+
227
+ const workspace = createWorkspace(`ws-${path.basename(root)}`);
228
+ const project = createProject({
229
+ name: `app-${path.basename(root)}`,
230
+ workingDirectory: repo,
231
+ });
232
+ setProjectWorkspace(project.id, workspace.id);
233
+ ensureOrchestrator(workspace.id);
234
+ const task = seedSession({
235
+ projectId: project.id,
236
+ name: "add-a",
237
+ task: true,
238
+ branch: "feature/t",
239
+ });
240
+ updateSessionRow(db, task, {
241
+ base_branch: "main",
242
+ task_prompt: "Add a",
243
+ lh_card_id: opts.card ? "card-1" : null,
244
+ pr_number: 7,
245
+ });
246
+ S.pr = {
247
+ number: 7,
248
+ url: "https://github.com/o/r/pull/7",
249
+ state: "OPEN",
250
+ checks: "pass",
251
+ head: sha,
252
+ failing: null,
253
+ checkCount: 1,
254
+ attribution: null,
255
+ };
256
+ const w = workspace.id;
257
+ // CI has looked settled on every head for a while.
258
+ const settled = () =>
259
+ putCheck({
260
+ workspaceId: w,
261
+ sessionId: task,
262
+ sha: S.pr!.head!,
263
+ kind: "ci",
264
+ status: "pass",
265
+ detail: JSON.stringify({ count: S.pr!.checkCount, at: 0 }),
266
+ });
267
+ return {
268
+ w,
269
+ repo,
270
+ task,
271
+ sha,
272
+ signOff: (settle = true) => {
273
+ if (settle) settled();
274
+ return runTool(w, "sign_off", { task: "add-a" });
275
+ },
276
+ push: (file: string, text: string, link?: string) => {
277
+ const next = commit(repo, file, text, link);
278
+ S.pr = { ...S.pr!, head: next };
279
+ return next;
280
+ },
281
+ };
282
+ }
283
+
284
+ // A reviewer that answers without a model, and remembers how it was run.
285
+ export function reviewer(verdict: "pass" | "block", within = true) {
286
+ const runs: ClaudeRun[] = [];
287
+ const claude = async (run: ClaudeRun) => {
288
+ runs.push(run);
289
+ if (!run.tools.length)
290
+ return {
291
+ within,
292
+ reason: within ? "matches the card" : "adds billing, not on the card",
293
+ };
294
+ return verdict === "pass"
295
+ ? { verdict, summary: "Looks right.", findings: [] }
296
+ : {
297
+ verdict,
298
+ summary: "One bug.",
299
+ findings: [
300
+ {
301
+ severity: "blocking",
302
+ file: "src/a.ts",
303
+ line: 1,
304
+ summary: "off by one",
305
+ },
306
+ ],
307
+ };
308
+ };
309
+ return { claude, runs };
310
+ }
311
+
312
+ export const reviewNow = (
313
+ w: string,
314
+ verdict: "pass" | "block" = "pass",
315
+ within = true
316
+ ) => {
317
+ const r = reviewer(verdict, within);
318
+ return review(w, "add-a", { wait: true, claude: r.claude }).then((text) => ({
319
+ text,
320
+ runs: r.runs,
321
+ }));
322
+ };
@@ -4,8 +4,9 @@
4
4
  * anything touching CI config, build and hook scripts, agent config, deploys
5
5
  * or secrets handling goes to Saad whatever the gates say. A repository with
6
6
  * no CI (decided from the repository, never from one head with no checks)
7
- * and a gate failing on two different commits go to him either way. Once a
8
- * task is with Saad the orchestrator doesn't merge it; he signs it off or drops it.
7
+ * and a task the loop guard finds stuck (loop-guard.ts) go to him either
8
+ * way. Once a task is with Saad the orchestrator doesn't merge it; he signs
9
+ * it off or drops it.
9
10
  */
10
11
 
11
12
  import { MERGED_AS, type MergeMethod } from "../tasks/merge-policy";
@@ -28,9 +29,17 @@ import { countFailure, escalate } from "./escalate";
28
29
  import {
29
30
  ciGate,
30
31
  evaluateGates,
32
+ failureCounted,
31
33
  holdingEscalations,
34
+ recordPass,
32
35
  type GateInput,
36
+ type GateOutcome,
33
37
  } from "./gates";
38
+ import {
39
+ readCiSignature,
40
+ reasonSignature,
41
+ reviewSignature,
42
+ } from "./loop-guard";
34
43
  import { addNote } from "./notes";
35
44
  import { latestApproval, refundApproval, spendApproval } from "./ask-approvals";
36
45
  import { workSubject } from "./asks";
@@ -121,6 +130,7 @@ export async function judge(
121
130
  async () => ({
122
131
  checks: pr.checks,
123
132
  failing: pr.failing,
133
+ failingUrl: pr.failingUrl,
124
134
  settleIn: ciSettleIn({
125
135
  workspaceId,
126
136
  taskId: task.id,
@@ -257,17 +267,43 @@ export async function gateVerdict(
257
267
  const waiting = outcomes.filter((o) => o.state === "wait");
258
268
  if (!failed.length && !waiting.length)
259
269
  return { ok: true, sha, pr: pr.number };
260
- const lines = [
261
- ...failed.map((o) =>
262
- opts.count === false
263
- ? `- ${o.gate} failed: ${o.reason}`
264
- : countFailure(workspaceId, work, o, pr.url, sha).line
265
- ),
266
- ...waiting.map((o) => `- ${o.gate}: not yet, ${o.reason}`),
267
- ];
270
+ const lines: string[] = [];
271
+ if (opts.count === false)
272
+ lines.push(...failed.map((o) => `- ${o.gate} failed: ${o.reason}`));
273
+ else {
274
+ for (const o of outcomes)
275
+ if (o.state === "pass") recordPass(work.id, o.gate);
276
+ for (const o of failed) {
277
+ const signature = await failureSignature(work, o, live);
278
+ lines.push(
279
+ countFailure(workspaceId, work, o, pr.url, sha, { signature }).line
280
+ );
281
+ }
282
+ }
283
+ lines.push(...waiting.map((o) => `- ${o.gate}: not yet, ${o.reason}`));
268
284
  return no(`${head}\n${lines.join("\n")}`, !failed.length);
269
285
  }
270
286
 
287
+ // What the loop guard compares between this failure and the one before.
288
+ // The CI log is read once per head: a head already counted needs none.
289
+ async function failureSignature(
290
+ work: GateWork,
291
+ o: GateOutcome,
292
+ i: Omit<GateInput, "sha" | "ruleBreaks">
293
+ ): Promise<string | null> {
294
+ if (o.gate === "review") return reviewSignature(i.review?.detail ?? null);
295
+ if (o.gate === "scope" && i.scope?.status === "block")
296
+ return reasonSignature(i.scope.detail);
297
+ if (o.gate !== "ci" || i.checks !== "fail") return reasonSignature(o.reason);
298
+ if (failureCounted(work.id, "ci", work.sha)) return null;
299
+ return readCiSignature({
300
+ repo: work.repo,
301
+ prUrl: work.pr.url,
302
+ failing: i.failing,
303
+ failingUrl: i.failingUrl,
304
+ });
305
+ }
306
+
271
307
  // Asked only when it can change the answer: a head with no checks that
272
308
  // isn't settling or conflicting.
273
309
  async function repoCIFor(
@@ -331,7 +367,9 @@ export async function refundIfRefused(
331
367
  await merge();
332
368
  } catch (e) {
333
369
  const merged = db
334
- .prepare(`SELECT 1 FROM sessions WHERE id = ? AND task_status = 'merged'`)
370
+ .prepare(
371
+ `SELECT 1 FROM session_rows WHERE id = ? AND task_status = 'merged'`
372
+ )
335
373
  .get(taskId);
336
374
  if (approval && !merged) refundApproval(approval);
337
375
  throw e;
@@ -56,7 +56,7 @@ export function workspaceTask(
56
56
  if (pr !== null) {
57
57
  const rows = db
58
58
  .prepare(
59
- `SELECT s.*, p.name AS project_name FROM sessions s
59
+ `SELECT s.*, p.name AS project_name FROM session_rows s
60
60
  JOIN projects p ON p.id = s.project_id
61
61
  WHERE p.workspace_id = ? AND s.pr_number = ? AND s.task_status IS NOT NULL
62
62
  ORDER BY s.created_at DESC`
@@ -9,8 +9,8 @@ import { blockedReason } from "../tasks/state";
9
9
  import { checkWaitingPatterns, statusDetector } from "../status-detector";
10
10
  import { getCheck, putCheck } from "./checks";
11
11
  import { cleanRemoteText, hostLink, type HostLink } from "../hosts/remote-api";
12
- import { heldChatFacts, isHeldRemote } from "../tasks/held-remote";
13
- import { hostTasks } from "../tasks/remote";
12
+ import { heldChatFacts, isHeldRemote } from "../tasks/contract/held-calls";
13
+ import { list as hostTasks } from "../tasks/contract/client";
14
14
  import { peerPane } from "../hosts/peer-sessions";
15
15
  import { untrusted } from "./untrusted";
16
16
 
@@ -104,6 +104,8 @@ async function peerChatWaiting(
104
104
  : `its chat on ${link.hostName} couldn't say whether a question is open`,
105
105
  };
106
106
  }
107
+ // compat: task-contract-1. A machine without held-chat-facts is asked
108
+ // through its task list; drop this once every linked machine has it.
107
109
  const view = await hostTasks(link, true)
108
110
  .then(({ tasks, capabilities }) =>
109
111
  // One that can't say its chat's turn can't rule out an open question.
@@ -2,7 +2,7 @@
2
2
  // test database.
3
3
 
4
4
  import { randomUUID } from "crypto";
5
- import { db } from "../db";
5
+ import { db, insertSessionRow } from "../db";
6
6
  import { saveItem } from "../chat/store";
7
7
  import { createProject } from "../projects";
8
8
  import { createWorkspace, setProjectWorkspace } from "../workspaces";
@@ -16,20 +16,17 @@ export function seedSession(opts: {
16
16
  updatedAt?: string;
17
17
  }): string {
18
18
  const id = randomUUID();
19
- db.prepare(
20
- `INSERT INTO sessions (id, name, tmux_name, working_directory, project_id, view,
21
- task_status, branch_name, updated_at)
22
- VALUES (?, ?, ?, '/tmp', ?, ?, ?, ?, COALESCE(?, datetime('now')))`
23
- ).run(
19
+ insertSessionRow(db, {
24
20
  id,
25
- opts.name,
26
- `claude-${id}`,
27
- opts.projectId,
28
- opts.view ?? "terminal",
29
- opts.task ? "running" : null,
30
- opts.branch ?? null,
31
- opts.updatedAt ?? null
32
- );
21
+ name: opts.name,
22
+ tmux_name: `claude-${id}`,
23
+ working_directory: "/tmp",
24
+ project_id: opts.projectId,
25
+ view: opts.view ?? "terminal",
26
+ task_status: opts.task ? "running" : null,
27
+ branch_name: opts.branch ?? null,
28
+ updated_at: opts.updatedAt,
29
+ });
33
30
  return id;
34
31
  }
35
32
 
@@ -18,6 +18,7 @@ import { settleStaleAsks } from "./ask-settle";
18
18
  import { brakesOn, liftBrake } from "./brakes";
19
19
  import { anyArmed, mergeArmed } from "./armed";
20
20
  import { followAskHeads } from "./ask-heads";
21
+ import { followAskVerdicts } from "./ask-verdict";
21
22
  import { raiseHeldAsks } from "./held-asks";
22
23
  import { rebaseConflicting } from "./rebase";
23
24
  import { wakeTasks } from "./pr-wake";
@@ -91,6 +92,9 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
91
92
  await raiseHeldAsks(workspaceId, facts).catch((error) =>
92
93
  console.error(`Raising held asks in ${workspaceId}:`, error)
93
94
  );
95
+ await followAskVerdicts(workspaceId, facts).catch((error) =>
96
+ console.error(`Judging asked commits in ${workspaceId}:`, error)
97
+ );
94
98
  await rebaseConflicting(workspaceId, facts);
95
99
  await mergeArmed(workspaceId, facts);
96
100
  })()
@@ -160,7 +160,7 @@ export function pieceOfSession(sessionId: string): FeaturePiece | undefined {
160
160
  export function prOf(p: FeaturePiece): number | null {
161
161
  if (!p.session_id) return null;
162
162
  const s = db
163
- .prepare(`SELECT pr_number, project_id FROM sessions WHERE id = ?`)
163
+ .prepare(`SELECT pr_number, project_id FROM session_rows WHERE id = ?`)
164
164
  .get(p.session_id) as
165
165
  | { pr_number: number | null; project_id: string | null }
166
166
  | undefined;
@@ -38,7 +38,7 @@ export interface ReleaseDeps {
38
38
 
39
39
  const sessionOf = (id: string | null) =>
40
40
  id
41
- ? (db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
41
+ ? (db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as
42
42
  | Session
43
43
  | undefined)
44
44
  : undefined;
package/lib/ports.ts CHANGED
@@ -70,7 +70,7 @@ async function reclaimEnded(
70
70
  ): Promise<void> {
71
71
  const rows = db
72
72
  .prepare(
73
- `SELECT id, tmux_name, task_status, host_id FROM sessions
73
+ `SELECT id, tmux_name, task_status, host_id FROM session_rows
74
74
  WHERE port_slot IS NOT NULL
75
75
  AND (archived_at IS NOT NULL OR task_status IN ('done', 'merged'))`
76
76
  )
@@ -2,7 +2,7 @@
2
2
  // them, and a session standing in for a run's.
3
3
 
4
4
  import { randomUUID } from "crypto";
5
- import { db } from "../db";
5
+ import { db, updateSessionRow } from "../db";
6
6
  import { createProject } from "../projects";
7
7
  import { createWorkspace, setProjectWorkspace } from "../workspaces";
8
8
  import { createRecipe } from "./store";
@@ -43,7 +43,6 @@ export function makeSession(
43
43
  db.prepare(
44
44
  `INSERT INTO sessions (id, name, tmux_name, working_directory, view) VALUES (?, 's', ?, '/tmp', 'chat')`
45
45
  ).run(id, `claude-${id}`);
46
- for (const [k, v] of Object.entries(fields))
47
- db.prepare(`UPDATE sessions SET ${k} = ? WHERE id = ?`).run(v, id);
46
+ updateSessionRow(db, id, fields);
48
47
  return id;
49
48
  }
@@ -65,7 +65,7 @@ function rows(scope: EndedScope, limit: number, kind?: EndedKind): Row[] {
65
65
  `SELECT s.id, s.name, s.tmux_name, s.project_id, p.name AS project_name,
66
66
  s.task_status, s.archived_at, s.plan_phase,
67
67
  COALESCE(s.task_ended_at, s.archived_at, s.settled_at, s.updated_at) AS ended_at
68
- FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
68
+ FROM session_rows s LEFT JOIN projects p ON p.id = s.project_id
69
69
  WHERE ${where.join(" AND ")}
70
70
  ORDER BY ended_at DESC LIMIT ?`
71
71
  )