@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.
- 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/screenshots/seed.ts +19 -18
- 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
|
|
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.
|
|
42
|
-
new migration for
|
|
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
|
-
//
|
|
88
|
-
// and
|
|
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",
|
|
@@ -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`:
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// column
|
|
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
|
|
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
|
|
19
|
-
// table
|
|
20
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/lib/done/remote.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return
|
|
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
|
|
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);
|