@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.
- package/app/api/bus/branches/route.ts +25 -0
- package/app/api/bus/resurrect/route.test.ts +3 -6
- package/app/api/worktree-sweep/route.test.ts +40 -0
- package/app/api/worktree-sweep/route.ts +31 -0
- package/app/api/worktree-sweep/run/route.ts +26 -0
- package/components/Health/HealthPanel.tsx +4 -1
- package/components/Health/SweepRuns.tsx +125 -0
- package/components/Merge/GlobalMergeSection.tsx +2 -0
- package/components/Merge/ProjectMergeSection.tsx +2 -0
- package/components/Merge/WorktreeSweepSection.tsx +125 -0
- package/components/Settings/NoApprovalsSection.tsx +1 -1
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/data/worktree-sweep/index.ts +56 -0
- package/lib/away/report.ts +1 -1
- package/lib/db/backup.ts +49 -0
- package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
- package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
- package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
- package/lib/db/migrations/README.md +7 -3
- package/lib/db/session-parts/columns.ts +3 -3
- package/lib/db/session-parts/index.ts +1 -0
- package/lib/db/session-parts/rows.ts +69 -0
- package/lib/db/session-parts/view.ts +14 -7
- package/lib/db/session-parts/write.ts +4 -12
- package/lib/done/remote.ts +9 -20
- package/lib/orchestrator/armed.test-helpers.ts +228 -0
- package/lib/orchestrator/armed.ts +1 -1
- package/lib/orchestrator/ask-verdict.ts +111 -0
- package/lib/orchestrator/asks.ts +26 -0
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/escalate.ts +25 -12
- package/lib/orchestrator/external-gates.ts +1 -0
- package/lib/orchestrator/gates.ts +94 -25
- package/lib/orchestrator/held-asks.ts +5 -8
- package/lib/orchestrator/held-review.ts +8 -4
- package/lib/orchestrator/loop-guard.ts +145 -0
- package/lib/orchestrator/merge-approvals.ts +2 -2
- package/lib/orchestrator/quick.ts +1 -1
- package/lib/orchestrator/review-prompt.ts +2 -0
- package/lib/orchestrator/review.ts +14 -4
- package/lib/orchestrator/signoff.test-helpers.ts +322 -0
- package/lib/orchestrator/signoff.ts +46 -10
- package/lib/orchestrator/testing.ts +11 -14
- package/lib/orchestrator/watcher.ts +4 -0
- package/lib/recipes/test-fixtures.ts +2 -3
- package/lib/resurrect/index.ts +20 -2
- package/lib/resurrect/swept.ts +61 -0
- package/lib/stacks/testing.ts +17 -16
- package/lib/tasks/finish.ts +20 -14
- package/lib/tasks/move-testing.ts +16 -7
- package/lib/tasks/pr-activity.ts +11 -2
- package/lib/tasks/prune-branches.ts +193 -0
- package/lib/tasks/prune-scope.ts +22 -0
- package/lib/tasks/remote-branch.ts +170 -0
- package/lib/tasks/testing.ts +13 -7
- package/lib/worktree-sweep/commits.ts +65 -0
- package/lib/worktree-sweep/findOrphans.ts +139 -0
- package/lib/worktree-sweep/findSettled.ts +112 -0
- package/lib/worktree-sweep/index.ts +10 -0
- package/lib/worktree-sweep/judge.ts +31 -0
- package/lib/worktree-sweep/panes.ts +18 -0
- package/lib/worktree-sweep/runSweep.ts +243 -0
- package/lib/worktree-sweep/settings.ts +83 -0
- package/lib/worktree-sweep/start.ts +64 -0
- package/lib/worktree-sweep/state.ts +36 -0
- package/lib/worktree-sweep/store.ts +96 -0
- package/lib/worktree-sweep/types.ts +53 -0
- package/package.json +1 -1
- package/scripts/redeploy +4 -2
- package/scripts/screenshots/seed.ts +19 -18
- 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
|
|
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
|
-
|
|
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
|
|
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
|
|
8
|
-
* task is with Saad the orchestrator doesn't merge it; he signs
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
28
|
-
opts.
|
|
29
|
-
opts.
|
|
30
|
-
opts.
|
|
31
|
-
opts.
|
|
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
|
-
|
|
47
|
-
db.prepare(`UPDATE sessions SET ${k} = ? WHERE id = ?`).run(v, id);
|
|
46
|
+
updateSessionRow(db, id, fields);
|
|
48
47
|
return id;
|
|
49
48
|
}
|
package/lib/resurrect/index.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
+
}
|