@saadnvd1/agent-os 0.3.639 → 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 (70) 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/screenshots/seed.ts +19 -18
  70. package/server.ts +3 -0
@@ -0,0 +1,173 @@
1
+ import type { Migration } from "../migration-types";
2
+ import type { PartTable } from "../session-parts/columns";
3
+ import { createSessionRowsView } from "../session-parts/view";
4
+ import { backupDatabase } from "../backup";
5
+
6
+ // The moved columns as 20261015T1800 split them, frozen here.
7
+ const PARTS: Record<PartTable, string[]> = {
8
+ session_tasks: [
9
+ "task_prompt",
10
+ "task_brief",
11
+ "task_status",
12
+ "task_since",
13
+ "task_ended_at",
14
+ "merged_at",
15
+ "priority",
16
+ "quick_of",
17
+ "moved_to",
18
+ "moved_from",
19
+ "model_overrides",
20
+ "lh_card_id",
21
+ "lh_board_id",
22
+ "lh_card_list",
23
+ ],
24
+ session_prs: [
25
+ "pr_url",
26
+ "pr_number",
27
+ "pr_status",
28
+ "merge_armed_at",
29
+ "merge_armed_seen",
30
+ "rebased_from",
31
+ "rebased_to",
32
+ "rebase_asked_head",
33
+ "pr_wake_since",
34
+ ],
35
+ session_plans: [
36
+ "plan_phase",
37
+ "plan_only",
38
+ "plan_decided",
39
+ "plan_reason",
40
+ "plan_note",
41
+ "plan_edits",
42
+ "plan_waits_for_caller",
43
+ "plan_handoff_message",
44
+ ],
45
+ session_placement: [
46
+ "peer_mirror",
47
+ "placed_note",
48
+ "held_by_device",
49
+ "held_from",
50
+ "held_for",
51
+ "held_for_name",
52
+ "started_by_device",
53
+ ],
54
+ session_pauses: ["pause_state", "paused_until", "pause_id", "pause_note"],
55
+ };
56
+
57
+ const hasValue = (prefix: string, column: string) =>
58
+ column === "peer_mirror"
59
+ ? `COALESCE(${prefix}.${column}, 0) != 0`
60
+ : `${prefix}.${column} IS NOT NULL`;
61
+
62
+ // A value in the part table wins (it is the newer write); one only on
63
+ // sessions is carried over.
64
+ const carried = (table: string, column: string) =>
65
+ column === "peer_mirror"
66
+ ? `MAX(COALESCE(${table}.${column}, 0), COALESCE(s.${column}, 0))`
67
+ : `COALESCE(${table}.${column}, s.${column})`;
68
+
69
+ export default {
70
+ key: "20261015T1830_drop_moved_session_columns",
71
+ up: (db) => {
72
+ // The destructive half of the split (docs/decisions/2026-10-15-split-sessions-table.md):
73
+ // the 42 moved columns leave sessions. A full copy of the database comes
74
+ // first, and nothing changes unless it was written. Every value still only
75
+ // on sessions is carried into its table before the columns go.
76
+ const parts = Object.entries(PARTS) as [PartTable, string[]][];
77
+ const remaining = () => {
78
+ const onSessions = new Set(
79
+ (
80
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
81
+ ).map((c) => c.name)
82
+ );
83
+ return parts.flatMap(([, cs]) => cs).filter((c) => onSessions.has(c));
84
+ };
85
+ if (remaining().length === 0) return;
86
+
87
+ const sessions = (
88
+ db.prepare(`SELECT COUNT(*) AS n FROM sessions`).get() as { n: number }
89
+ ).n;
90
+ // An empty sessions table holds nothing these columns could lose.
91
+ if (sessions > 0) backupDatabase(db, "drop-moved-session-columns");
92
+
93
+ // Immediate, and re-read inside: another process opening the same
94
+ // database may have run this between the check and the lock.
95
+ db.transaction(() => {
96
+ const left = remaining();
97
+ if (left.length === 0) return;
98
+ for (const [table, all] of parts) {
99
+ const columns = all.filter((c) => left.includes(c));
100
+ if (columns.length === 0) continue;
101
+ db.exec(
102
+ `INSERT INTO ${table} (session_id, ${columns.join(", ")})
103
+ SELECT s.id, ${columns.map((c) => `s.${c}`).join(", ")}
104
+ FROM sessions s
105
+ WHERE (${columns.map((c) => hasValue("s", c)).join(" OR ")})
106
+ AND NOT EXISTS (SELECT 1 FROM ${table} p WHERE p.session_id = s.id)`
107
+ );
108
+ db.exec(
109
+ `UPDATE ${table} SET
110
+ ${columns.map((c) => `${c} = ${carried(table, c)}`).join(",\n ")}
111
+ FROM sessions s WHERE s.id = ${table}.session_id`
112
+ );
113
+ db.exec(`DROP TRIGGER IF EXISTS ${table}_from_sessions_ins`);
114
+ db.exec(`DROP TRIGGER IF EXISTS ${table}_from_sessions_upd`);
115
+ }
116
+
117
+ // merged_at and task_ended_at are stamped where task_status now lives.
118
+ // A first write of a status inserts the row; a later one updates it.
119
+ db.exec(`DROP TRIGGER IF EXISTS sessions_task_ended_at`);
120
+ db.exec(`DROP TRIGGER IF EXISTS sessions_merged_at`);
121
+ db.exec(`
122
+ CREATE TRIGGER IF NOT EXISTS session_tasks_ended_at_upd
123
+ AFTER UPDATE OF task_status ON session_tasks
124
+ WHEN NEW.task_status IN ('dropped', 'done')
125
+ AND OLD.task_status IS NOT NEW.task_status
126
+ AND NEW.task_ended_at IS OLD.task_ended_at
127
+ BEGIN
128
+ UPDATE session_tasks SET task_ended_at = datetime('now')
129
+ WHERE session_id = NEW.session_id;
130
+ END
131
+ `);
132
+ db.exec(`
133
+ CREATE TRIGGER IF NOT EXISTS session_tasks_ended_at_ins
134
+ AFTER INSERT ON session_tasks
135
+ WHEN NEW.task_status IN ('dropped', 'done')
136
+ AND NEW.task_ended_at IS NULL
137
+ BEGIN
138
+ UPDATE session_tasks SET task_ended_at = datetime('now')
139
+ WHERE session_id = NEW.session_id;
140
+ END
141
+ `);
142
+ db.exec(`
143
+ CREATE TRIGGER IF NOT EXISTS session_tasks_merged_at_upd
144
+ AFTER UPDATE OF task_status ON session_tasks
145
+ WHEN NEW.task_status = 'merged' AND OLD.task_status IS NOT 'merged'
146
+ -- unless the same statement set it (an import keeps its own)
147
+ AND NEW.merged_at IS OLD.merged_at
148
+ BEGIN
149
+ UPDATE session_tasks SET merged_at = datetime('now')
150
+ WHERE session_id = NEW.session_id;
151
+ END
152
+ `);
153
+ db.exec(`
154
+ CREATE TRIGGER IF NOT EXISTS session_tasks_merged_at_ins
155
+ AFTER INSERT ON session_tasks
156
+ WHEN NEW.task_status = 'merged' AND NEW.merged_at IS NULL
157
+ BEGIN
158
+ UPDATE session_tasks SET merged_at = datetime('now')
159
+ WHERE session_id = NEW.session_id;
160
+ END
161
+ `);
162
+
163
+ db.exec(`DROP VIEW IF EXISTS session_rows`);
164
+ db.exec(`DROP INDEX IF EXISTS idx_sessions_task_status`);
165
+ db.exec(`DROP INDEX IF EXISTS idx_sessions_moved_from`);
166
+ db.exec(`DROP INDEX IF EXISTS idx_sessions_quick_of`);
167
+ for (const column of left)
168
+ db.exec(`ALTER TABLE sessions DROP COLUMN ${column}`);
169
+
170
+ createSessionRowsView(db, PARTS);
171
+ }).immediate();
172
+ },
173
+ } satisfies Migration;
@@ -0,0 +1,30 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261015T1900_worktree_sweep",
5
+ up: (db) => {
6
+ // One row per clean-up run, kept for good, with what it removed and
7
+ // skipped as JSON; and which sessions' worktrees a run removed, so a
8
+ // restore can say so. Additive.
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS worktree_sweep_runs (
11
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
12
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
13
+ finished_at TEXT,
14
+ dry_run INTEGER NOT NULL DEFAULT 0,
15
+ removed INTEGER NOT NULL DEFAULT 0,
16
+ freed_bytes INTEGER NOT NULL DEFAULT 0,
17
+ skipped INTEGER NOT NULL DEFAULT 0,
18
+ details TEXT,
19
+ error TEXT
20
+ );
21
+ CREATE TABLE IF NOT EXISTS session_worktree_sweeps (
22
+ session_id TEXT PRIMARY KEY,
23
+ path TEXT NOT NULL,
24
+ branch TEXT,
25
+ swept_at TEXT NOT NULL DEFAULT (datetime('now')),
26
+ run_id INTEGER
27
+ );
28
+ `);
29
+ },
30
+ } satisfies Migration;
@@ -35,8 +35,12 @@ export default {
35
35
  again.
36
36
  - Adding a column to `sessions`? `session_rows` (the view that reads a session
37
37
  with its columns from the tables they moved to) lists the columns at the time
38
- it was built. Add the name to `SESSION_ROW_COLUMNS` in
38
+ it was built, in the order of `SESSION_ROW_COLUMNS`. Add the name to `SESSION_ROW_COLUMNS` in
39
39
  `lib/db/session-parts/columns.ts` and call `createSessionRowsView(db)` after
40
40
  the `ALTER`; `lib/db/split-sessions-migration.test.ts` fails if the view and
41
- the table disagree. Columns that belong to a group go in `PART_COLUMNS` and a
42
- new migration for that table instead.
41
+ the table disagree.
42
+ Columns that belong to a group go in `PART_COLUMNS` and a new migration for
43
+ that table instead.
44
+ - A destructive step (dropping a column or table) takes a full copy first:
45
+ `backupDatabase` in `lib/db/backup.ts` (`VACUUM INTO` under
46
+ `~/.agent-os/backups/`), and throws so the migration stops if it can't.
@@ -84,9 +84,9 @@ export const isMovedColumn = (column: string): boolean =>
84
84
  export const partOf = (column: string): PartTable | undefined =>
85
85
  partOfColumn.get(column);
86
86
 
87
- // Every column of the sessions table, in order: the shape of a session row
88
- // and of `session_rows`. A migration that adds a sessions column adds it here
89
- // and rebuilds the view (createSessionRowsView).
87
+ // The shape of a session row and of `session_rows`, in order: the sessions
88
+ // columns and the moved ones where they always stood. A migration that adds a
89
+ // sessions column adds it here and rebuilds the view (createSessionRowsView).
90
90
  export const SESSION_ROW_COLUMNS: readonly string[] = [
91
91
  "id",
92
92
  "name",
@@ -7,3 +7,4 @@ export { setPlan, advancePlan } from "./plans";
7
7
  export { setPlacement } from "./placement";
8
8
  export { setPause } from "./pauses";
9
9
  export { fillMissing } from "./fill";
10
+ export { insertSessionRow, updateSessionRow } from "./rows";
@@ -0,0 +1,69 @@
1
+ import type Database from "better-sqlite3";
2
+ import { PART_TABLES, partOf, type PartTable } from "./columns";
3
+ import { writePart } from "./write";
4
+
5
+ type Value = string | number | null | undefined;
6
+
7
+ function split(row: Record<string, Value>) {
8
+ const own: Record<string, Value> = {};
9
+ const parts = new Map<PartTable, Record<string, Value>>();
10
+ for (const [column, value] of Object.entries(row)) {
11
+ if (value === undefined) continue;
12
+ const table = partOf(column);
13
+ if (!table) own[column] = value;
14
+ else parts.set(table, { ...parts.get(table), [column]: value });
15
+ }
16
+ return { own, parts };
17
+ }
18
+
19
+ const writeParts = (
20
+ db: Database.Database,
21
+ id: string,
22
+ parts: Map<PartTable, Record<string, Value>>
23
+ ) => {
24
+ for (const table of PART_TABLES) {
25
+ const patch = parts.get(table);
26
+ if (patch) writePart(db, table, id, patch as never);
27
+ }
28
+ };
29
+
30
+ // A session row in its flat shape (sessions columns and moved ones together),
31
+ // written to sessions and the part tables. A moved column left null or unset
32
+ // makes no part row. For seeds, imports and tests.
33
+ export function insertSessionRow(
34
+ db: Database.Database,
35
+ row: Record<string, Value> & { id: string }
36
+ ): void {
37
+ const { own, parts } = split(row);
38
+ for (const [table, patch] of parts) {
39
+ for (const [c, v] of Object.entries(patch))
40
+ if (v === null || (c === "peer_mirror" && v === 0)) delete patch[c];
41
+ if (Object.keys(patch).length === 0) parts.delete(table);
42
+ }
43
+ const columns = Object.keys(own);
44
+ db.transaction(() => {
45
+ db.prepare(
46
+ `INSERT INTO sessions (${columns.join(", ")})
47
+ VALUES (${columns.map((c) => `@${c}`).join(", ")})`
48
+ ).run(own);
49
+ writeParts(db, row.id, parts);
50
+ })();
51
+ }
52
+
53
+ // Updates any of a session's columns by name, wherever each one lives.
54
+ export function updateSessionRow(
55
+ db: Database.Database,
56
+ id: string,
57
+ patch: Record<string, Value>
58
+ ): void {
59
+ const { own, parts } = split(patch);
60
+ const columns = Object.keys(own);
61
+ db.transaction(() => {
62
+ if (columns.length > 0)
63
+ db.prepare(
64
+ `UPDATE sessions SET ${columns.map((c) => `${c} = @${c}`).join(", ")}
65
+ WHERE id = @__id`
66
+ ).run({ ...own, __id: id });
67
+ writeParts(db, id, parts);
68
+ })();
69
+ }
@@ -1,5 +1,5 @@
1
1
  import type Database from "better-sqlite3";
2
- import { PART_COLUMNS, type PartTable } from "./columns";
2
+ import { PART_COLUMNS, SESSION_ROW_COLUMNS, type PartTable } from "./columns";
3
3
 
4
4
  const ALIAS: Record<PartTable, string> = {
5
5
  session_tasks: "t",
@@ -9,11 +9,12 @@ const ALIAS: Record<PartTable, string> = {
9
9
  session_pauses: "pa",
10
10
  };
11
11
 
12
- // `session_rows`: the sessions table with each moved column read from its own
13
- // table, under the same names and in the same order, so a row is the flat
14
- // session it always was. A column with a default on sessions
15
- // (peer_mirror) keeps it. Rebuild it in any migration that adds a sessions
16
- // column. `parts` is the split as that migration knew it.
12
+ // `session_rows`: a session with each moved column read from its own table,
13
+ // in the order of SESSION_ROW_COLUMNS (the flat shape a session row always
14
+ // had). A column with a default (peer_mirror) keeps it. Rebuild it in any
15
+ // migration that adds a sessions column. `parts` is the split as that
16
+ // migration knew it; a column on both sessions and a part (before the old
17
+ // columns were dropped) is read from the part.
17
18
  export function createSessionRowsView(
18
19
  db: Database.Database,
19
20
  parts: Record<PartTable, readonly string[]> = PART_COLUMNS
@@ -24,7 +25,13 @@ export function createSessionRowsView(
24
25
  const sessionColumns = (
25
26
  db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
26
27
  ).map((c) => c.name);
27
- const select = sessionColumns
28
+ const onSessions = new Set(sessionColumns);
29
+ const known = new Set(SESSION_ROW_COLUMNS);
30
+ const names = [
31
+ ...SESSION_ROW_COLUMNS.filter((c) => source.has(c) || onSessions.has(c)),
32
+ ...sessionColumns.filter((c) => !known.has(c)),
33
+ ];
34
+ const select = names
28
35
  .map((c) => {
29
36
  const alias = source.get(c);
30
37
  if (!alias) return `s.${c}`;
@@ -15,12 +15,9 @@ const sameAs = (current: unknown, wanted: Value | readonly Value[]): boolean =>
15
15
  (w) => (current ?? null) === w
16
16
  );
17
17
 
18
- // The one writer of the part tables. Writes the patched columns to the part
19
- // table, then mirrors the same values onto the legacy sessions columns, which
20
- // stay until they are dropped (docs/decisions/2026-10-15-split-sessions-table.md):
21
- // that UPDATE is the only dual-write, and the step that drops the columns
22
- // deletes it. Returns whether it wrote (false: the session is gone or `when`
23
- // no longer holds).
18
+ // The one writer of the part tables: writes the patched columns to the part
19
+ // table (docs/decisions/2026-10-15-split-sessions-table.md). Returns whether
20
+ // it wrote (false: the session is gone or `when` no longer holds).
24
21
  export function writePart<T extends PartTable>(
25
22
  db: Database.Database,
26
23
  table: T,
@@ -58,12 +55,7 @@ export function writePart<T extends PartTable>(
58
55
  ${columns.map((c) => `${c} = excluded.${c}`).join(", ")}`
59
56
  )
60
57
  .run(values);
61
- if (inserted.changes === 0) return false;
62
- db.prepare(
63
- `UPDATE sessions SET ${columns.map((c) => `${c} = @${c}`).join(", ")}
64
- WHERE id = @id`
65
- ).run(values);
66
- return true;
58
+ return inserted.changes > 0;
67
59
  });
68
60
  return run();
69
61
  }
@@ -4,9 +4,8 @@
4
4
 
5
5
  import type { Session } from "../db";
6
6
  import type { TaskPR } from "../tasks";
7
- import { git, gitPush } from "../git-exec";
8
7
  import { projectPathFor } from "../tasks/session";
9
- import { PROTECTED_BRANCHES } from "../tasks/finish";
8
+ import { deleteTaskBranch } from "../tasks/remote-branch";
10
9
  import { liveChildren } from "../stacks/guard";
11
10
 
12
11
  export async function deleteMergedRemote(
@@ -14,23 +13,13 @@ export async function deleteMergedRemote(
14
13
  pr: TaskPR | null
15
14
  ): Promise<boolean> {
16
15
  const repo = projectPathFor(s);
17
- const branch = s.branch_name;
18
- if (
19
- !repo ||
20
- !branch ||
21
- PROTECTED_BRANCHES.has(branch) ||
22
- !pr?.head ||
23
- liveChildren(s.id).length
24
- )
25
- return false;
26
- const line = await git(
16
+ if (!repo || !s.branch_name) return false;
17
+ const out = await deleteTaskBranch({
27
18
  repo,
28
- ["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
29
- { timeout: 30000 }
30
- ).catch(() => "");
31
- const tip = line.trim().split(/\s+/)[0];
32
- if (!tip || tip !== pr.head) return false;
33
- return gitPush(repo, ["origin", "--delete", branch], { timeout: 60000 })
34
- .then(() => true)
35
- .catch(() => false);
19
+ branch: s.branch_name,
20
+ prHead: pr?.head,
21
+ keep: [s.base_branch],
22
+ hasChildren: liveChildren(s.id).length > 0,
23
+ });
24
+ return out.deleted;
36
25
  }
@@ -0,0 +1,228 @@
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, vi } from "vitest";
6
+ import type { TaskPR } from "@/lib/tasks/state";
7
+ import type { SessionFacts } from "./facts";
8
+
9
+ // Shared fixtures for the armed*.test.ts files: a real repository with a
10
+ // local bare remote, gh faked around it. Import this before anything it mocks.
11
+ // Tests change the fake world through S.
12
+ export const S: { pr: TaskPR | null } = { pr: null };
13
+ export const merges: string[][] = [];
14
+ export const isProbe = (args: string[]) =>
15
+ args[0] === "api" ||
16
+ (args[0] === "pr" &&
17
+ args[1] === "list" &&
18
+ args.includes("statusCheckRollup"));
19
+
20
+ vi.mock("@/lib/tasks/gh", async (importOriginal) => {
21
+ const real = await importOriginal<typeof import("@/lib/tasks/gh")>();
22
+ return {
23
+ ...real,
24
+ run: async (cmd: string, args: string[], cwd: string, t?: number) => {
25
+ if (cmd === "gh" && isProbe(args)) return "0";
26
+ if (cmd === "gh") {
27
+ merges.push(args);
28
+ if (S.pr) S.pr = { ...S.pr, state: "MERGED" };
29
+ return "";
30
+ }
31
+ if (cmd === "tmux") throw new Error("no tmux in tests");
32
+ return real.run(cmd, args, cwd, t);
33
+ },
34
+ findPR: async () => S.pr,
35
+ };
36
+ });
37
+ vi.mock("@/lib/status-detector", () => ({
38
+ checkWaitingPatterns: () => false,
39
+ statusDetector: {
40
+ refreshCache: async () => {},
41
+ sessionExists: () => true,
42
+ getStatus: async () => "idle",
43
+ titleFor: () => "",
44
+ getTimestamp: () => 0,
45
+ hostFor: () => "local",
46
+ capturePane: async () => "❯ ",
47
+ },
48
+ }));
49
+
50
+ export const { db, updateSessionRow } = await import("@/lib/db");
51
+ export const { createProject } = await import("@/lib/projects");
52
+ export const { createWorkspace, setProjectWorkspace } =
53
+ await import("@/lib/workspaces");
54
+ export const { ensureOrchestrator } = await import("./home");
55
+ export const { runTool } = await import("./serve");
56
+ export const { getCheck, putCheck } = await import("./checks");
57
+ export const { failureOf, recordFailure } = await import("./gates");
58
+ export const { LOOP_GUARD_COMMITS } = await import("./loop-guard");
59
+ export const { seedSession } = await import("./testing");
60
+ export const { setMergeApprovals } = await import("./merge-approvals");
61
+ export const { mergeArmed, armTask } = await import("./armed");
62
+ export const { setPaused } = await import("./pause");
63
+ export const { escalate } = await import("./escalate");
64
+ export const { openAsks, raiseAsk } = await import("./asks");
65
+ export const { judge, mergeJudged } = await import("./signoff");
66
+ export const { rebaseConflicting } = await import("./rebase");
67
+ export const { recordConditions } = await import("./events");
68
+
69
+ beforeAll(() => {
70
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "aos-orch-armed-"));
71
+ vi.spyOn(os, "homedir").mockReturnValue(home);
72
+ });
73
+ beforeEach(() => {
74
+ merges.length = 0;
75
+ });
76
+
77
+ export const git = (cwd: string, ...args: string[]) =>
78
+ execFileSync("git", args, {
79
+ cwd,
80
+ encoding: "utf8",
81
+ stdio: "pipe",
82
+ env: { ...process.env, GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z" },
83
+ }).trim();
84
+
85
+ export function commit(repo: string, file: string, text: string): string {
86
+ fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
87
+ fs.writeFileSync(path.join(repo, file), text);
88
+ git(repo, "add", "-A");
89
+ git(repo, "commit", "-qm", `change ${file}`);
90
+ git(repo, "push", "-q", "origin", "HEAD");
91
+ return git(repo, "rev-parse", "HEAD");
92
+ }
93
+
94
+ export const lines = (n: number, change: Record<number, string> = {}) =>
95
+ Array.from({ length: n }, (_, i) => change[i + 1] ?? `line ${i + 1}`).join(
96
+ "\n"
97
+ ) + "\n";
98
+
99
+ // A workspace whose project is a real clone, which is also the task's
100
+ // worktree, on its branch with its PR open at the branch's head. `other`
101
+ // is a second clone for moving main.
102
+ export function setup() {
103
+ setMergeApprovals(false);
104
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "aos-armed-repo-"));
105
+ const remote = path.join(root, "remote.git");
106
+ const repo = path.join(root, "repo");
107
+ const other = path.join(root, "other");
108
+ git(root, "init", "-q", "--bare", "-b", "main", remote);
109
+ git(root, "clone", "-q", remote, repo);
110
+ for (const r of [repo]) {
111
+ git(r, "config", "user.email", "t@example.com");
112
+ git(r, "config", "user.name", "T");
113
+ }
114
+ commit(repo, "src/b.ts", lines(12));
115
+ git(root, "clone", "-q", remote, other);
116
+ git(other, "config", "user.email", "o@example.com");
117
+ git(other, "config", "user.name", "O");
118
+ git(repo, "checkout", "-qb", "feature/t");
119
+ const sha = commit(repo, "src/b.ts", lines(12, { 5: "changed five" }));
120
+
121
+ const workspace = createWorkspace(`ws-${path.basename(root)}`);
122
+ const project = createProject({
123
+ name: `app-${path.basename(root)}`,
124
+ workingDirectory: repo,
125
+ });
126
+ setProjectWorkspace(project.id, workspace.id);
127
+ ensureOrchestrator(workspace.id);
128
+ const task = seedSession({
129
+ projectId: project.id,
130
+ name: "add-a",
131
+ task: true,
132
+ branch: "feature/t",
133
+ });
134
+ updateSessionRow(db, task, {
135
+ base_branch: "main",
136
+ task_prompt: "Add a",
137
+ pr_number: 7,
138
+ worktree_path: repo,
139
+ });
140
+ S.pr = {
141
+ number: 7,
142
+ url: "https://github.com/o/r/pull/7",
143
+ state: "OPEN",
144
+ checks: "pass",
145
+ head: sha,
146
+ failing: null,
147
+ checkCount: 1,
148
+ attribution: null,
149
+ };
150
+ const w = workspace.id;
151
+ const put = (
152
+ kind: "review" | "ci",
153
+ status: "pass" | "block",
154
+ at = S.pr!.head!
155
+ ) =>
156
+ putCheck({
157
+ workspaceId: w,
158
+ sessionId: task,
159
+ sha: at,
160
+ kind,
161
+ status,
162
+ detail:
163
+ kind === "ci"
164
+ ? JSON.stringify({ count: S.pr!.checkCount, at: 0 })
165
+ : "Looks right.",
166
+ });
167
+ // CI has looked settled on the head for a while.
168
+ put("ci", "pass");
169
+ return {
170
+ w,
171
+ repo,
172
+ other,
173
+ remote,
174
+ task,
175
+ sha,
176
+ put,
177
+ // Someone else's PR merged: main moves.
178
+ moveMain: (file: string, text: string) => {
179
+ git(other, "pull", "-q", "origin", "main");
180
+ return commit(other, file, text);
181
+ },
182
+ facts: (status: SessionFacts["status"] = "idle"): SessionFacts[] => [
183
+ {
184
+ id: task,
185
+ name: "add-a",
186
+ project: project.name,
187
+ view: "terminal",
188
+ status,
189
+ activity: null,
190
+ needsInput: false,
191
+ lastActive: 0,
192
+ branch: "feature/t",
193
+ task: { state: "review", pr: S.pr, blocked: null, ciSettleIn: 0 },
194
+ stack: null,
195
+ },
196
+ ],
197
+ };
198
+ }
199
+
200
+ export const events = (w: string) =>
201
+ (
202
+ db
203
+ .prepare(
204
+ `SELECT key, line FROM orchestrator_events WHERE workspace_id = ? AND key LIKE 'once:%' ORDER BY id`
205
+ )
206
+ .all(w) as { key: string; line: string }[]
207
+ ).map((e) => e.line);
208
+
209
+ export const armedAt = (task: string) =>
210
+ (
211
+ db
212
+ .prepare(`SELECT merge_armed_at FROM session_rows WHERE id = ?`)
213
+ .get(task) as { merge_armed_at: string | null }
214
+ ).merge_armed_at;
215
+
216
+ export function armedDeps() {
217
+ const reviews: string[] = [];
218
+ return {
219
+ reviews,
220
+ deps: {
221
+ judge,
222
+ merge: mergeJudged,
223
+ review: async (_w: string, id: string) => {
224
+ reviews.push(id);
225
+ },
226
+ },
227
+ };
228
+ }
@@ -258,7 +258,7 @@ async function mergeOne(
258
258
  const seen = `${pr.head}:${failures(verdict.text)}`;
259
259
  if (task.merge_armed_seen === seen) return;
260
260
  // Counted as sign_off would count it: once per head (the database keys
261
- // it), and a gate failing on a second commit goes to Saad.
261
+ // it), and only a task stuck in a loop goes to Saad (loop-guard.ts).
262
262
  if (!stillArmed(workspaceId, task.id)) return;
263
263
  const counted = await deps.judge(workspaceId, task);
264
264
  if (counted.ok) return merge(workspaceId, task, counted, deps);