@saadnvd1/agent-os 0.3.639 → 0.3.646

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 (71) 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/worktree-sweep/route.test.ts +40 -0
  4. package/app/api/worktree-sweep/route.ts +31 -0
  5. package/app/api/worktree-sweep/run/route.ts +26 -0
  6. package/components/Health/HealthPanel.tsx +4 -1
  7. package/components/Health/SweepRuns.tsx +125 -0
  8. package/components/Merge/GlobalMergeSection.tsx +2 -0
  9. package/components/Merge/ProjectMergeSection.tsx +2 -0
  10. package/components/Merge/WorktreeSweepSection.tsx +125 -0
  11. package/components/Settings/NoApprovalsSection.tsx +1 -1
  12. package/components/Settings/SettingsDialog.tsx +1 -1
  13. package/data/worktree-sweep/index.ts +56 -0
  14. package/lib/away/report.ts +1 -1
  15. package/lib/db/backup.ts +49 -0
  16. package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
  17. package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
  18. package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
  19. package/lib/db/migrations/README.md +7 -3
  20. package/lib/db/session-parts/columns.ts +3 -3
  21. package/lib/db/session-parts/index.ts +1 -0
  22. package/lib/db/session-parts/rows.ts +69 -0
  23. package/lib/db/session-parts/view.ts +14 -7
  24. package/lib/db/session-parts/write.ts +4 -12
  25. package/lib/done/remote.ts +9 -20
  26. package/lib/orchestrator/armed.test-helpers.ts +228 -0
  27. package/lib/orchestrator/armed.ts +1 -1
  28. package/lib/orchestrator/ask-verdict.ts +111 -0
  29. package/lib/orchestrator/asks.ts +26 -0
  30. package/lib/orchestrator/brief.ts +2 -1
  31. package/lib/orchestrator/escalate.ts +25 -12
  32. package/lib/orchestrator/external-gates.ts +1 -0
  33. package/lib/orchestrator/gates.ts +94 -25
  34. package/lib/orchestrator/held-asks.ts +5 -8
  35. package/lib/orchestrator/held-review.ts +8 -4
  36. package/lib/orchestrator/loop-guard.ts +145 -0
  37. package/lib/orchestrator/merge-approvals.ts +2 -2
  38. package/lib/orchestrator/quick.ts +1 -1
  39. package/lib/orchestrator/review-prompt.ts +2 -0
  40. package/lib/orchestrator/review.ts +14 -4
  41. package/lib/orchestrator/signoff.test-helpers.ts +322 -0
  42. package/lib/orchestrator/signoff.ts +46 -10
  43. package/lib/orchestrator/testing.ts +11 -14
  44. package/lib/orchestrator/watcher.ts +4 -0
  45. package/lib/recipes/test-fixtures.ts +2 -3
  46. package/lib/resurrect/index.ts +20 -2
  47. package/lib/resurrect/swept.ts +61 -0
  48. package/lib/stacks/testing.ts +17 -16
  49. package/lib/tasks/finish.ts +20 -14
  50. package/lib/tasks/move-testing.ts +16 -7
  51. package/lib/tasks/pr-activity.ts +11 -2
  52. package/lib/tasks/prune-branches.ts +193 -0
  53. package/lib/tasks/prune-scope.ts +22 -0
  54. package/lib/tasks/remote-branch.ts +170 -0
  55. package/lib/tasks/testing.ts +13 -7
  56. package/lib/worktree-sweep/commits.ts +65 -0
  57. package/lib/worktree-sweep/findOrphans.ts +139 -0
  58. package/lib/worktree-sweep/findSettled.ts +112 -0
  59. package/lib/worktree-sweep/index.ts +10 -0
  60. package/lib/worktree-sweep/judge.ts +31 -0
  61. package/lib/worktree-sweep/panes.ts +18 -0
  62. package/lib/worktree-sweep/runSweep.ts +243 -0
  63. package/lib/worktree-sweep/settings.ts +83 -0
  64. package/lib/worktree-sweep/start.ts +64 -0
  65. package/lib/worktree-sweep/state.ts +36 -0
  66. package/lib/worktree-sweep/store.ts +96 -0
  67. package/lib/worktree-sweep/types.ts +53 -0
  68. package/package.json +1 -1
  69. package/scripts/redeploy +4 -2
  70. package/scripts/screenshots/seed.ts +19 -18
  71. package/server.ts +3 -0
@@ -24,7 +24,8 @@ import { runClaude, type ClaudeRunner } from "./claude-cli";
24
24
  import { changedFiles, fullDiff } from "./diff";
25
25
  import { diffParts } from "./diff-parts";
26
26
  import { countFailure, escalate } from "./escalate";
27
- import { checkGate, holdingEscalations } from "./gates";
27
+ import { checkGate, holdingEscalations, recordPass } from "./gates";
28
+ import { reasonSignature, reviewSignature } from "./loop-guard";
28
29
  import { reviewWanted } from "./held-review";
29
30
  import { mergeApprovalsOn, mergeApprovalsSetting } from "./merge-approvals";
30
31
  import {
@@ -222,10 +223,19 @@ function countVerdicts(
222
223
  const escalated: string[] = [];
223
224
  for (const gate of ["review", "scope"] as const) {
224
225
  if (gate === "scope" && !target.task?.lh_card_id) continue;
225
- const o = checkGate(gate, getCheck(target.id, sha, gate), sha);
226
+ const row = getCheck(target.id, sha, gate);
227
+ const o = checkGate(gate, row, sha);
228
+ if (o.state === "pass") recordPass(target.id, gate);
226
229
  if (o.state !== "fail") continue;
227
230
  try {
228
- if (countFailure(workspaceId, target, o, target.pr.url, sha).escalated)
231
+ const signature =
232
+ gate === "review"
233
+ ? reviewSignature(row?.detail ?? null)
234
+ : reasonSignature(row?.detail);
235
+ if (
236
+ countFailure(workspaceId, target, o, target.pr.url, sha, { signature })
237
+ .escalated
238
+ )
229
239
  escalated.push(gate);
230
240
  } catch (e) {
231
241
  // The verdict's event still goes out; sign_off counts it again.
@@ -233,7 +243,7 @@ function countVerdicts(
233
243
  }
234
244
  }
235
245
  return escalated.length
236
- ? `; the ${escalated.join(" and ")} gate failed twice, so it's with Saad now`
246
+ ? `; the ${escalated.join(" and ")} gate is stuck in a loop (the loop guard), so it's with Saad now`
237
247
  : "";
238
248
  }
239
249
 
@@ -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(
@@ -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
  })()
@@ -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
  }
@@ -44,6 +44,8 @@ import { launchClaude } from "../agents/launch";
44
44
  import { PLAN_FILE, preparePlanDir } from "../models/plan-step";
45
45
  import { planFromTranscript } from "./plan";
46
46
  import { dropSnapshot, restoreWorktree, type RestoredAs } from "./snapshot";
47
+ import { restoreSwept, sweptNote } from "./swept";
48
+ import { forgetSwept } from "../worktree-sweep/store";
47
49
  import { setPr, setTask } from "../db/session-parts";
48
50
 
49
51
  export interface ResurrectOptions {
@@ -69,6 +71,7 @@ const ENDED_TASK = new Set(["dropped", "done"]);
69
71
  // Restores in flight here, so two clicks can't both build a worktree.
70
72
  const g = globalThis as unknown as { __agentosResurrecting?: Set<string> };
71
73
  const inFlight = (g.__agentosResurrecting ??= new Set());
74
+ export const restoring = (id: string) => inFlight.has(id);
72
75
 
73
76
  interface PrInfo {
74
77
  number: number;
@@ -124,12 +127,22 @@ export async function resurrectSession(
124
127
  // restoring it is the whole of it.
125
128
  if (!endedTask || (s.archived_at && s.task_status !== "dropped")) {
126
129
  if (s.archived_at) {
130
+ const swept = await restoreSwept(s);
127
131
  unarchiveSession(id);
128
- return outcome(s, "unarchived", `${s.name} is back in the sidebar.`);
132
+ return outcome(
133
+ s,
134
+ "unarchived",
135
+ `${s.name} is back in the sidebar${swept ? `; ${swept}` : ""}.`
136
+ );
129
137
  }
130
138
  if (s.settled_at) {
139
+ const swept = await restoreSwept(s);
131
140
  reopenSession(id);
132
- return outcome(s, "reopened", `${s.name} reopened.`);
141
+ return outcome(
142
+ s,
143
+ "reopened",
144
+ `${s.name} reopened${swept ? `; ${swept}` : ""}.`
145
+ );
133
146
  }
134
147
  throw new Error(`${s.name} isn't dropped, done or archived.`);
135
148
  }
@@ -357,6 +370,11 @@ async function bringBack(
357
370
  if (restored && after.view !== "chat") await relaunch(after, restored.path);
358
371
  syncTaskCardInBackground(after, "working", null);
359
372
  void dropSnapshot(repo, s.id);
373
+ const swept = sweptNote(s.id);
374
+ if (swept) {
375
+ notes.unshift(swept);
376
+ forgetSwept(s.id);
377
+ }
360
378
  const text = `${s.name} is back${restored ? ` in ${restored.path}` : ""}${reopened ? `, PR #${reopened} reopened` : ""}${notes.length ? `; ${notes.join("; ")}` : ""}.`;
361
379
  return outcome(s, "task", text, {
362
380
  worktree: restored?.mode ?? "chat-only",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * A session whose worktree the age clean-up removed: bringing it back
3
+ * recreates the worktree from its branch, or says plainly that it's gone.
4
+ */
5
+
6
+ import fs from "fs";
7
+ import { db, type Session } from "../db";
8
+ import { getProject } from "../projects";
9
+ import { expandHome, projectPathFor } from "../tasks/session";
10
+ import { forgetSwept, sweptAt } from "../worktree-sweep/store";
11
+ import { restoreWorktree } from "./snapshot";
12
+
13
+ const day = (t: string) => t.slice(0, 10);
14
+
15
+ // "its worktree was cleaned up on 2026-10-15" for a session the sweep removed
16
+ // the worktree of; null for any other.
17
+ export function sweptNote(id: string): string | null {
18
+ const at = sweptAt(id);
19
+ return at ? `its worktree was cleaned up on ${day(at)}` : null;
20
+ }
21
+
22
+ /**
23
+ * For a session coming back without a task restore (unarchive, reopen):
24
+ * rebuild a swept worktree. The note says what happened; null when the
25
+ * session wasn't swept or its worktree is still there.
26
+ */
27
+ export async function restoreSwept(s: Session): Promise<string | null> {
28
+ const note = sweptNote(s.id);
29
+ if (!note || !s.worktree_path) return null;
30
+ const old = expandHome(s.worktree_path);
31
+ if (fs.existsSync(old)) {
32
+ forgetSwept(s.id);
33
+ return null;
34
+ }
35
+ const project = s.project_id ? getProject(s.project_id) : null;
36
+ const repo = project ? projectPathFor(s) : null;
37
+ const restored =
38
+ repo && s.branch_name && fs.existsSync(repo)
39
+ ? await restoreWorktree({
40
+ sessionId: s.id,
41
+ repo,
42
+ branch: s.branch_name,
43
+ base: s.base_branch,
44
+ oldPath: old,
45
+ allowFresh: false,
46
+ }).catch(() => null)
47
+ : null;
48
+ if (restored) {
49
+ db.prepare(
50
+ `UPDATE sessions SET worktree_path = ?, working_directory = ? WHERE id = ?`
51
+ ).run(restored.path, restored.path, s.id);
52
+ forgetSwept(s.id);
53
+ return `${note} and recreated from ${s.branch_name}`;
54
+ }
55
+ // No branch to build from: the conversation alone, in the project folder.
56
+ db.prepare(
57
+ `UPDATE sessions SET worktree_path = NULL, working_directory = ? WHERE id = ?`
58
+ ).run(repo ?? s.working_directory, s.id);
59
+ forgetSwept(s.id);
60
+ return `${note} and its branch is gone, so only the conversation came back`;
61
+ }