@agentplate/cli 1.3.0 → 1.4.0
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/CHANGELOG.md +21 -0
- package/package.json +1 -1
- package/src/commands/reap.ts +29 -6
- package/src/commands/stop.ts +43 -6
- package/src/config.ts +1 -0
- package/src/events/store.test.ts +22 -0
- package/src/events/store.ts +24 -1
- package/src/merge/queue.test.ts +15 -0
- package/src/merge/queue.ts +20 -0
- package/src/serve/server.ts +7 -5
- package/src/sessions/purge.test.ts +159 -0
- package/src/sessions/purge.ts +138 -0
- package/src/sessions/reaper.test.ts +84 -2
- package/src/sessions/reaper.ts +73 -32
- package/src/sessions/store.test.ts +18 -0
- package/src/sessions/store.ts +12 -0
- package/src/types.ts +8 -0
- package/src/version.ts +1 -1
- package/src/wizard/setup.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to Agentplate are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/), and the project aims to adhere to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.4.0] — 2026-06-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Full purge of reaped agents** (`agentplate reap --purge`, `agentplate stop
|
|
12
|
+
<agent> --purge`) — beyond stopping an agent and removing its worktree, purge
|
|
13
|
+
erases every trace it left behind: mail, events, queued merges, the on-disk
|
|
14
|
+
`.agentplate/agents/<name>/` state dir, the task spec (only once no sibling
|
|
15
|
+
session still references it), and the session row itself. A `PurgeReport` records
|
|
16
|
+
what was removed.
|
|
17
|
+
- **`agents.purgeOnReap`** (default `false`) — when set, the `agentplate serve`
|
|
18
|
+
idle-reaper loop purges idle agents automatically (not just marks them stopped).
|
|
19
|
+
Surfaced as a gated toggle in `agentplate setup`.
|
|
20
|
+
- **Store deletion APIs** backing the purge: `SessionStore.deleteSession`,
|
|
21
|
+
`EventStore.deleteByAgent`, and `MergeQueue.deleteByAgent`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Plain `reap` / `serve` behavior is unchanged — reaping still keeps records for
|
|
26
|
+
history by default. Full erasure is strictly opt-in via `--purge` / `purgeOnReap`.
|
|
27
|
+
|
|
7
28
|
## [1.3.0] — 2026-06-02
|
|
8
29
|
|
|
9
30
|
### Added
|
package/package.json
CHANGED
package/src/commands/reap.ts
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* live process, and removes its worktree + branch. The coordinator is never
|
|
7
7
|
* reaped. Run it manually or on a cron; `agentplate serve` also reaps on its own
|
|
8
8
|
* loop. Use `--dry-run` to preview and `--keep-worktrees` to leave worktrees.
|
|
9
|
+
*
|
|
10
|
+
* `--purge` goes further than a normal reap: it erases every trace of each reaped
|
|
11
|
+
* agent — mail, events, queued merges, the on-disk `.agentplate/agents/<name>/`
|
|
12
|
+
* state dir, and the session row itself — so the workspace is fully cleared. The
|
|
13
|
+
* plain reap keeps those records for history; purge is the opt-in clean slate.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
import { Command } from "commander";
|
|
@@ -22,11 +27,21 @@ export function createReapCommand(): Command {
|
|
|
22
27
|
.description("Terminate agents idle past the timeout (stop + kill + remove worktree)")
|
|
23
28
|
.option("--minutes <n>", "idle timeout in minutes (default: config.agents.idleTimeoutMinutes)")
|
|
24
29
|
.option("--keep-worktrees", "mark stopped + kill process but keep worktrees/branches")
|
|
30
|
+
.option(
|
|
31
|
+
"--purge",
|
|
32
|
+
"fully erase each reaped agent (mail, events, merges, state dir, session row)",
|
|
33
|
+
)
|
|
25
34
|
.option("--dry-run", "list which agents would be reaped without changing anything")
|
|
26
35
|
.option("--json", "output JSON")
|
|
27
36
|
.action(
|
|
28
37
|
async (
|
|
29
|
-
opts: {
|
|
38
|
+
opts: {
|
|
39
|
+
minutes?: string;
|
|
40
|
+
keepWorktrees?: boolean;
|
|
41
|
+
purge?: boolean;
|
|
42
|
+
dryRun?: boolean;
|
|
43
|
+
json?: boolean;
|
|
44
|
+
},
|
|
30
45
|
command: Command,
|
|
31
46
|
) => {
|
|
32
47
|
const useJson = command.optsWithGlobals().json === true;
|
|
@@ -51,11 +66,12 @@ export function createReapCommand(): Command {
|
|
|
51
66
|
now: Date.now(),
|
|
52
67
|
}).map((s) => ({ agent: s.agentName, capability: s.capability, state: s.state }));
|
|
53
68
|
if (useJson) {
|
|
54
|
-
jsonOutput({ dryRun: true, minutes, candidates });
|
|
69
|
+
jsonOutput({ dryRun: true, minutes, purge: opts.purge === true, candidates });
|
|
55
70
|
} else if (candidates.length === 0) {
|
|
56
71
|
printInfo(`No agents idle longer than ${minutes}m.`);
|
|
57
72
|
} else {
|
|
58
|
-
|
|
73
|
+
const how = opts.purge ? "reap + purge" : "reap";
|
|
74
|
+
printInfo(`Would ${how} ${candidates.length} agent(s) idle >${minutes}m:`);
|
|
59
75
|
for (const c of candidates) {
|
|
60
76
|
process.stdout.write(` ${c.agent} ${muted(`(${c.capability}, ${c.state})`)}\n`);
|
|
61
77
|
}
|
|
@@ -66,17 +82,24 @@ export function createReapCommand(): Command {
|
|
|
66
82
|
const reaped = await reapIdleSessions(store, root, {
|
|
67
83
|
idleMs,
|
|
68
84
|
removeWorktrees: opts.keepWorktrees !== true,
|
|
85
|
+
purge: opts.purge === true,
|
|
69
86
|
});
|
|
70
87
|
|
|
71
88
|
if (useJson) {
|
|
72
|
-
jsonOutput({ minutes, reapedCount: reaped.length, reaped });
|
|
89
|
+
jsonOutput({ minutes, purge: opts.purge === true, reapedCount: reaped.length, reaped });
|
|
73
90
|
} else if (reaped.length === 0) {
|
|
74
91
|
printInfo(`No agents idle longer than ${minutes}m.`);
|
|
75
92
|
} else {
|
|
76
|
-
|
|
93
|
+
const verb = opts.purge ? "Reaped + purged" : "Reaped";
|
|
94
|
+
printSuccess(`${verb} ${reaped.length} idle agent(s) (>${minutes}m):`);
|
|
77
95
|
for (const r of reaped) {
|
|
78
96
|
const wt = r.worktreeRemoved ? "worktree removed" : "worktree kept";
|
|
79
|
-
|
|
97
|
+
const extra = r.purged
|
|
98
|
+
? `, purged ${r.purged.mailDeleted} mail/${r.purged.eventsDeleted} events/${r.purged.mergeDeleted} merges`
|
|
99
|
+
: "";
|
|
100
|
+
process.stdout.write(
|
|
101
|
+
` ${r.agentName} ${muted(`(${r.capability}, ${wt}${extra})`)}\n`,
|
|
102
|
+
);
|
|
80
103
|
}
|
|
81
104
|
}
|
|
82
105
|
} finally {
|
package/src/commands/stop.ts
CHANGED
|
@@ -7,22 +7,30 @@
|
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { findProjectRoot, isInitialized } from "../config.ts";
|
|
9
9
|
import { NotFoundError, ValidationError } from "../errors.ts";
|
|
10
|
+
import { createEventStore } from "../events/store.ts";
|
|
10
11
|
import { jsonOutput } from "../json.ts";
|
|
11
12
|
import { printSuccess, printWarning } from "../logging/color.ts";
|
|
12
|
-
import {
|
|
13
|
+
import { createMailStore } from "../mail/store.ts";
|
|
14
|
+
import { createMergeQueue } from "../merge/queue.ts";
|
|
15
|
+
import { eventsDbPath, mailDbPath, mergeDbPath, sessionsDbPath } from "../paths.ts";
|
|
16
|
+
import { type PurgeReport, purgeAgentData } from "../sessions/purge.ts";
|
|
13
17
|
import { createSessionStore } from "../sessions/store.ts";
|
|
14
|
-
import { removeWorktree } from "../worktree/manager.ts";
|
|
18
|
+
import { deleteBranch, removeWorktree } from "../worktree/manager.ts";
|
|
15
19
|
|
|
16
20
|
export function createStopCommand(): Command {
|
|
17
21
|
return new Command("stop")
|
|
18
22
|
.description("Terminate an agent session")
|
|
19
23
|
.argument("<agent>", "agent name")
|
|
20
24
|
.option("--clean-worktree", "also remove the agent's worktree")
|
|
25
|
+
.option(
|
|
26
|
+
"--purge",
|
|
27
|
+
"fully erase the agent (worktree + mail, events, merges, state dir, session row)",
|
|
28
|
+
)
|
|
21
29
|
.option("--json", "output JSON")
|
|
22
30
|
.action(
|
|
23
31
|
async (
|
|
24
32
|
agent: string,
|
|
25
|
-
opts: { cleanWorktree?: boolean; json?: boolean },
|
|
33
|
+
opts: { cleanWorktree?: boolean; purge?: boolean; json?: boolean },
|
|
26
34
|
command: Command,
|
|
27
35
|
) => {
|
|
28
36
|
const useJson = command.optsWithGlobals().json === true;
|
|
@@ -30,6 +38,8 @@ export function createStopCommand(): Command {
|
|
|
30
38
|
if (!isInitialized(root)) {
|
|
31
39
|
throw new ValidationError("Not initialized. Run `agentplate setup` first.");
|
|
32
40
|
}
|
|
41
|
+
// --purge implies removing the worktree: a full wipe leaves no worktree.
|
|
42
|
+
const cleanWorktree = opts.cleanWorktree || opts.purge === true;
|
|
33
43
|
const store = createSessionStore(sessionsDbPath(root));
|
|
34
44
|
try {
|
|
35
45
|
const session = store.getSessionByAgent(agent);
|
|
@@ -37,17 +47,44 @@ export function createStopCommand(): Command {
|
|
|
37
47
|
store.updateSessionState(session.id, "stopped");
|
|
38
48
|
|
|
39
49
|
let worktreeRemoved = false;
|
|
40
|
-
if (
|
|
50
|
+
if (cleanWorktree) {
|
|
41
51
|
try {
|
|
42
52
|
await removeWorktree(root, session.worktreePath, { force: true });
|
|
43
53
|
worktreeRemoved = true;
|
|
54
|
+
// Best-effort branch cleanup (may be merged/shared — leave it then).
|
|
55
|
+
try {
|
|
56
|
+
await deleteBranch(root, session.branchName);
|
|
57
|
+
} catch {
|
|
58
|
+
// Branch kept; not fatal.
|
|
59
|
+
}
|
|
44
60
|
} catch (error) {
|
|
45
61
|
printWarning(`Could not remove worktree: ${(error as Error).message}`);
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
64
|
|
|
49
|
-
|
|
50
|
-
|
|
65
|
+
let purged: PurgeReport | null = null;
|
|
66
|
+
if (opts.purge) {
|
|
67
|
+
const events = createEventStore(eventsDbPath(root));
|
|
68
|
+
const merge = createMergeQueue(mergeDbPath(root));
|
|
69
|
+
const mail = createMailStore(mailDbPath(root));
|
|
70
|
+
try {
|
|
71
|
+
purged = purgeAgentData(root, session, { sessions: store, events, merge, mail });
|
|
72
|
+
} finally {
|
|
73
|
+
events.close();
|
|
74
|
+
merge.close();
|
|
75
|
+
mail.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (useJson) {
|
|
80
|
+
jsonOutput({ agent, stopped: true, worktreeRemoved, purged });
|
|
81
|
+
} else if (purged) {
|
|
82
|
+
printSuccess(
|
|
83
|
+
`Purged ${agent} (worktree removed, ${purged.mailDeleted} mail/${purged.eventsDeleted} events/${purged.mergeDeleted} merges erased)`,
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
printSuccess(`Stopped ${agent}${worktreeRemoved ? " (worktree removed)" : ""}`);
|
|
87
|
+
}
|
|
51
88
|
} finally {
|
|
52
89
|
store.close();
|
|
53
90
|
}
|
package/src/config.ts
CHANGED
package/src/events/store.test.ts
CHANGED
|
@@ -180,4 +180,26 @@ describe("createEventStore", () => {
|
|
|
180
180
|
// Newest two builder events.
|
|
181
181
|
expect(limited.map((e) => e.type)).toEqual(["c", "b"]);
|
|
182
182
|
});
|
|
183
|
+
|
|
184
|
+
test("deleteByAgent removes only that agent's rows and returns the count", () => {
|
|
185
|
+
store.record({ agentName: "builder", type: "a" });
|
|
186
|
+
store.record({ agentName: "builder", type: "b" });
|
|
187
|
+
store.record({ agentName: "scout", type: "a" });
|
|
188
|
+
|
|
189
|
+
expect(store.deleteByAgent("builder")).toBe(2);
|
|
190
|
+
expect(store.list({ agentName: "builder" })).toEqual([]);
|
|
191
|
+
expect(store.list({ agentName: "scout" }).length).toBe(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("deleteByAgent scoped to a run leaves the agent's other runs intact", () => {
|
|
195
|
+
store.record({ agentName: "builder", runId: "r1", type: "a" });
|
|
196
|
+
store.record({ agentName: "builder", runId: "r2", type: "b" });
|
|
197
|
+
|
|
198
|
+
expect(store.deleteByAgent("builder", "r1")).toBe(1);
|
|
199
|
+
expect(store.list({ agentName: "builder" }).map((e) => e.runId)).toEqual(["r2"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("deleteByAgent on an unknown agent returns 0", () => {
|
|
203
|
+
expect(store.deleteByAgent("nobody")).toBe(0);
|
|
204
|
+
});
|
|
183
205
|
});
|
package/src/events/store.ts
CHANGED
|
@@ -42,6 +42,12 @@ export interface ListEventsFilter {
|
|
|
42
42
|
export interface EventStore {
|
|
43
43
|
record(event: RecordEventInput): EventRecord;
|
|
44
44
|
list(filter?: ListEventsFilter): EventRecord[];
|
|
45
|
+
/**
|
|
46
|
+
* Delete every event logged by `agentName` (optionally scoped to one run);
|
|
47
|
+
* returns the number of rows removed. Used when fully purging a reaped agent's
|
|
48
|
+
* data. The log is otherwise append-only — this is the sole deletion path.
|
|
49
|
+
*/
|
|
50
|
+
deleteByAgent(agentName: string, runId?: string): number;
|
|
45
51
|
close(): void;
|
|
46
52
|
}
|
|
47
53
|
|
|
@@ -193,9 +199,26 @@ export function createEventStore(dbPath: string): EventStore {
|
|
|
193
199
|
return rows.map(rowToRecord);
|
|
194
200
|
}
|
|
195
201
|
|
|
202
|
+
function deleteByAgent(agentName: string, runId?: string): number {
|
|
203
|
+
// Count first so we can report how many rows were removed (bun:sqlite's
|
|
204
|
+
// run() does not surface a changes count through this query API uniformly).
|
|
205
|
+
const where =
|
|
206
|
+
runId !== undefined
|
|
207
|
+
? "agent_name = $agent_name AND run_id = $run_id"
|
|
208
|
+
: "agent_name = $agent_name";
|
|
209
|
+
const params: Record<string, string> = { $agent_name: agentName };
|
|
210
|
+
if (runId !== undefined) params.$run_id = runId;
|
|
211
|
+
const countRow = db.query(`SELECT COUNT(*) AS n FROM events WHERE ${where}`).get(params) as {
|
|
212
|
+
n: number;
|
|
213
|
+
} | null;
|
|
214
|
+
const n = countRow?.n ?? 0;
|
|
215
|
+
if (n > 0) db.query(`DELETE FROM events WHERE ${where}`).run(params);
|
|
216
|
+
return n;
|
|
217
|
+
}
|
|
218
|
+
|
|
196
219
|
function close(): void {
|
|
197
220
|
db.close();
|
|
198
221
|
}
|
|
199
222
|
|
|
200
|
-
return { record, list, close };
|
|
223
|
+
return { record, list, deleteByAgent, close };
|
|
201
224
|
}
|
package/src/merge/queue.test.ts
CHANGED
|
@@ -133,4 +133,19 @@ describe("createMergeQueue", () => {
|
|
|
133
133
|
queue = createMergeQueue(join(dir, "merge-queue.db"));
|
|
134
134
|
}
|
|
135
135
|
});
|
|
136
|
+
|
|
137
|
+
test("deleteByAgent removes every entry for the agent and returns the count", () => {
|
|
138
|
+
add({ agentName: "builder-1", branchName: "agent/a" });
|
|
139
|
+
add({ agentName: "builder-1", branchName: "agent/b" });
|
|
140
|
+
add({ agentName: "builder-2", branchName: "agent/c" });
|
|
141
|
+
|
|
142
|
+
expect(queue.deleteByAgent("builder-1")).toBe(2);
|
|
143
|
+
const pending = queue.listPending();
|
|
144
|
+
expect(pending).toHaveLength(1);
|
|
145
|
+
expect(pending[0]?.agentName).toBe("builder-2");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("deleteByAgent on an unknown agent returns 0", () => {
|
|
149
|
+
expect(queue.deleteByAgent("nobody")).toBe(0);
|
|
150
|
+
});
|
|
136
151
|
});
|
package/src/merge/queue.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface MergeQueue {
|
|
|
36
36
|
* The returned object reflects its pre-removal `status` ("pending").
|
|
37
37
|
*/
|
|
38
38
|
dequeue(): MergeEntry | null;
|
|
39
|
+
/**
|
|
40
|
+
* Delete every queue entry belonging to `agentName` regardless of status;
|
|
41
|
+
* returns the number removed. Used when fully purging a reaped agent's data so
|
|
42
|
+
* no stale pending merge survives the agent it came from.
|
|
43
|
+
*/
|
|
44
|
+
deleteByAgent(agentName: string): number;
|
|
39
45
|
/** Close the underlying database connection. */
|
|
40
46
|
close(): void;
|
|
41
47
|
}
|
|
@@ -131,6 +137,14 @@ export function createMergeQueue(dbPath: string): MergeQueue {
|
|
|
131
137
|
"DELETE FROM merge_queue WHERE seq = $seq",
|
|
132
138
|
);
|
|
133
139
|
|
|
140
|
+
const countByAgentStmt = db.query<{ n: number }, { $agent: string }>(
|
|
141
|
+
"SELECT COUNT(*) AS n FROM merge_queue WHERE agent_name = $agent",
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const deleteByAgentStmt = db.query<void, { $agent: string }>(
|
|
145
|
+
"DELETE FROM merge_queue WHERE agent_name = $agent",
|
|
146
|
+
);
|
|
147
|
+
|
|
134
148
|
return {
|
|
135
149
|
enqueue(entry): MergeEntry {
|
|
136
150
|
const row = insertStmt.get({
|
|
@@ -170,6 +184,12 @@ export function createMergeQueue(dbPath: string): MergeQueue {
|
|
|
170
184
|
return rowToEntry(row);
|
|
171
185
|
},
|
|
172
186
|
|
|
187
|
+
deleteByAgent(agentName): number {
|
|
188
|
+
const n = countByAgentStmt.get({ $agent: agentName })?.n ?? 0;
|
|
189
|
+
if (n > 0) deleteByAgentStmt.run({ $agent: agentName });
|
|
190
|
+
return n;
|
|
191
|
+
},
|
|
192
|
+
|
|
173
193
|
close(): void {
|
|
174
194
|
db.close();
|
|
175
195
|
},
|
package/src/serve/server.ts
CHANGED
|
@@ -221,23 +221,25 @@ export function startServer(opts: ServeOptions): ServeHandle {
|
|
|
221
221
|
// Idle-agent reaper: terminate workers idle past the configured timeout. Runs
|
|
222
222
|
// independently of connected clients (serve running = reaping active). Disabled
|
|
223
223
|
// when idleTimeoutMinutes is 0. Config is read once at startup.
|
|
224
|
-
const idleMinutes = (() => {
|
|
224
|
+
const { idleMinutes, purgeOnReap } = (() => {
|
|
225
225
|
try {
|
|
226
|
-
|
|
226
|
+
const agents = loadConfig(opts.root).agents;
|
|
227
|
+
return { idleMinutes: agents.idleTimeoutMinutes, purgeOnReap: agents.purgeOnReap };
|
|
227
228
|
} catch {
|
|
228
|
-
return 0;
|
|
229
|
+
return { idleMinutes: 0, purgeOnReap: false };
|
|
229
230
|
}
|
|
230
231
|
})();
|
|
231
232
|
const reapTimer =
|
|
232
233
|
idleMinutes > 0
|
|
233
234
|
? setInterval(() => {
|
|
234
235
|
const store = createSessionStore(sessionsDbPath(opts.root));
|
|
235
|
-
reapIdleSessions(store, opts.root, { idleMs: idleMinutes * 60_000 })
|
|
236
|
+
reapIdleSessions(store, opts.root, { idleMs: idleMinutes * 60_000, purge: purgeOnReap })
|
|
236
237
|
.then((reaped) => {
|
|
237
238
|
if (reaped.length > 0) {
|
|
238
239
|
const names = reaped.map((r) => r.agentName).join(", ");
|
|
240
|
+
const how = purgeOnReap ? "reaped + purged" : "reaped";
|
|
239
241
|
console.error(
|
|
240
|
-
`[agentplate]
|
|
242
|
+
`[agentplate] ${how} ${reaped.length} idle agent(s) (>${idleMinutes}m): ${names}`,
|
|
241
243
|
);
|
|
242
244
|
}
|
|
243
245
|
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the full agent-data purge ("clear the Office").
|
|
3
|
+
*
|
|
4
|
+
* Per project policy nothing is mocked: a real temp project root holds real
|
|
5
|
+
* SQLite stores (sessions/events/merge/mail) at their canonical paths, plus a
|
|
6
|
+
* real on-disk agent state dir and spec file. We seed each store, run
|
|
7
|
+
* `purgeAgentData`, and assert that every trace of the agent is gone while a
|
|
8
|
+
* second agent's data is untouched.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { createEventStore } from "../events/store.ts";
|
|
16
|
+
import { createMailStore } from "../mail/store.ts";
|
|
17
|
+
import { createMergeQueue } from "../merge/queue.ts";
|
|
18
|
+
import {
|
|
19
|
+
agentStateDir,
|
|
20
|
+
eventsDbPath,
|
|
21
|
+
mailDbPath,
|
|
22
|
+
mergeDbPath,
|
|
23
|
+
sessionsDbPath,
|
|
24
|
+
specPath,
|
|
25
|
+
} from "../paths.ts";
|
|
26
|
+
import type { AgentSession } from "../types.ts";
|
|
27
|
+
import { type PurgeStores, purgeAgentData } from "./purge.ts";
|
|
28
|
+
import { createSessionStore } from "./store.ts";
|
|
29
|
+
|
|
30
|
+
function mk(root: string, overrides: Partial<AgentSession>): AgentSession {
|
|
31
|
+
const now = new Date().toISOString();
|
|
32
|
+
return {
|
|
33
|
+
id: `s-${overrides.agentName ?? "a"}`,
|
|
34
|
+
agentName: "a",
|
|
35
|
+
capability: "builder",
|
|
36
|
+
taskId: "task-1",
|
|
37
|
+
runId: "run-1",
|
|
38
|
+
worktreePath: join(root, ".agentplate", "worktrees", overrides.agentName ?? "a"),
|
|
39
|
+
branchName: "agentplate/a",
|
|
40
|
+
state: "stopped",
|
|
41
|
+
parentAgent: null,
|
|
42
|
+
depth: 1,
|
|
43
|
+
pid: null,
|
|
44
|
+
runtimeSessionId: null,
|
|
45
|
+
startedAt: now,
|
|
46
|
+
lastActivity: now,
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("purgeAgentData", () => {
|
|
52
|
+
let root: string;
|
|
53
|
+
let stores: PurgeStores;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
root = mkdtempSync(join(tmpdir(), "ap-purge-"));
|
|
57
|
+
stores = {
|
|
58
|
+
sessions: createSessionStore(sessionsDbPath(root)),
|
|
59
|
+
events: createEventStore(eventsDbPath(root)),
|
|
60
|
+
merge: createMergeQueue(mergeDbPath(root)),
|
|
61
|
+
mail: createMailStore(mailDbPath(root)),
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
stores.sessions.close();
|
|
67
|
+
stores.events.close();
|
|
68
|
+
stores.merge.close();
|
|
69
|
+
stores.mail.close();
|
|
70
|
+
rmSync(root, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("erases every trace of the agent and reports the counts", () => {
|
|
74
|
+
const session = mk(root, { id: "s-doomed", agentName: "doomed" });
|
|
75
|
+
stores.sessions.upsertSession(session);
|
|
76
|
+
|
|
77
|
+
// Seed each store + on-disk artifact for the doomed agent.
|
|
78
|
+
stores.events.record({ agentName: "doomed", runId: "run-1", type: "tool-start" });
|
|
79
|
+
stores.events.record({ agentName: "doomed", runId: "run-1", type: "tool-end" });
|
|
80
|
+
stores.mail.send({ from: "doomed", to: "lead", subject: "s", body: "b", type: "status" });
|
|
81
|
+
stores.mail.send({ from: "lead", to: "doomed", subject: "s", body: "b", type: "dispatch" });
|
|
82
|
+
stores.merge.enqueue({
|
|
83
|
+
branchName: "agentplate/doomed",
|
|
84
|
+
agentName: "doomed",
|
|
85
|
+
taskId: "task-1",
|
|
86
|
+
targetBranch: "main",
|
|
87
|
+
});
|
|
88
|
+
mkdirSync(agentStateDir(root, "doomed"), { recursive: true });
|
|
89
|
+
writeFileSync(join(agentStateDir(root, "doomed"), "identity.yaml"), "sessions: 1\n");
|
|
90
|
+
mkdirSync(join(root, ".agentplate", "specs"), { recursive: true });
|
|
91
|
+
writeFileSync(specPath(root, "task-1"), "# spec\n");
|
|
92
|
+
|
|
93
|
+
const report = purgeAgentData(root, session, stores);
|
|
94
|
+
|
|
95
|
+
expect(report).toEqual({
|
|
96
|
+
mailDeleted: 2,
|
|
97
|
+
eventsDeleted: 2,
|
|
98
|
+
mergeDeleted: 1,
|
|
99
|
+
stateDirRemoved: true,
|
|
100
|
+
specRemoved: true,
|
|
101
|
+
sessionDeleted: true,
|
|
102
|
+
});
|
|
103
|
+
// Nothing of the agent remains.
|
|
104
|
+
expect(stores.sessions.getSession("s-doomed")).toBeNull();
|
|
105
|
+
expect(stores.events.list({ agentName: "doomed" })).toEqual([]);
|
|
106
|
+
expect(stores.mail.list({ to: "doomed" })).toEqual([]);
|
|
107
|
+
expect(stores.mail.list({ from: "doomed" })).toEqual([]);
|
|
108
|
+
expect(stores.merge.listPending()).toEqual([]);
|
|
109
|
+
expect(existsSync(agentStateDir(root, "doomed"))).toBe(false);
|
|
110
|
+
expect(existsSync(specPath(root, "task-1"))).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("leaves a second agent's data untouched", () => {
|
|
114
|
+
const doomed = mk(root, { id: "s-doomed", agentName: "doomed", taskId: "task-1" });
|
|
115
|
+
const keeper = mk(root, { id: "s-keeper", agentName: "keeper", taskId: "task-2" });
|
|
116
|
+
stores.sessions.upsertSession(doomed);
|
|
117
|
+
stores.sessions.upsertSession(keeper);
|
|
118
|
+
stores.events.record({ agentName: "keeper", runId: "run-1", type: "tool-start" });
|
|
119
|
+
stores.mail.send({ from: "keeper", to: "lead", subject: "s", body: "b", type: "status" });
|
|
120
|
+
|
|
121
|
+
purgeAgentData(root, doomed, stores);
|
|
122
|
+
|
|
123
|
+
expect(stores.sessions.getSession("s-keeper")).not.toBeNull();
|
|
124
|
+
expect(stores.events.list({ agentName: "keeper" }).length).toBe(1);
|
|
125
|
+
expect(stores.mail.list({ from: "keeper" }).length).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("keeps the spec file while another session still references the task", () => {
|
|
129
|
+
const a = mk(root, { id: "s-a", agentName: "agent-a", taskId: "shared" });
|
|
130
|
+
const b = mk(root, { id: "s-b", agentName: "agent-b", taskId: "shared" });
|
|
131
|
+
stores.sessions.upsertSession(a);
|
|
132
|
+
stores.sessions.upsertSession(b);
|
|
133
|
+
mkdirSync(join(root, ".agentplate", "specs"), { recursive: true });
|
|
134
|
+
writeFileSync(specPath(root, "shared"), "# shared spec\n");
|
|
135
|
+
|
|
136
|
+
// Purging the first sibling keeps the spec (b still uses it).
|
|
137
|
+
const first = purgeAgentData(root, a, stores);
|
|
138
|
+
expect(first.specRemoved).toBe(false);
|
|
139
|
+
expect(existsSync(specPath(root, "shared"))).toBe(true);
|
|
140
|
+
|
|
141
|
+
// Purging the last sibling finally removes the spec.
|
|
142
|
+
const second = purgeAgentData(root, b, stores);
|
|
143
|
+
expect(second.specRemoved).toBe(true);
|
|
144
|
+
expect(existsSync(specPath(root, "shared"))).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("is resilient when there is nothing to purge", () => {
|
|
148
|
+
const session = mk(root, { id: "s-empty", agentName: "empty" });
|
|
149
|
+
stores.sessions.upsertSession(session);
|
|
150
|
+
|
|
151
|
+
const report = purgeAgentData(root, session, stores);
|
|
152
|
+
expect(report.mailDeleted).toBe(0);
|
|
153
|
+
expect(report.eventsDeleted).toBe(0);
|
|
154
|
+
expect(report.mergeDeleted).toBe(0);
|
|
155
|
+
expect(report.stateDirRemoved).toBe(false); // dir never existed
|
|
156
|
+
expect(report.sessionDeleted).toBe(true);
|
|
157
|
+
expect(stores.sessions.getSession("s-empty")).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full data purge for a terminated agent — "clear the Office".
|
|
3
|
+
*
|
|
4
|
+
* Reaping (see {@link ./reaper.ts}) stops an idle agent and removes its worktree,
|
|
5
|
+
* but deliberately leaves its records behind for history: the session row stays
|
|
6
|
+
* (marked `stopped`), and its mail / events / queued merges / on-disk state dir
|
|
7
|
+
* all persist. That is the right default for auditability, but it accumulates —
|
|
8
|
+
* a long-running project fills up with the debris of agents that came and went.
|
|
9
|
+
*
|
|
10
|
+
* This module is the opt-in opposite: given a session, erase *everything* that
|
|
11
|
+
* agent left behind so nothing of it remains. It is intentionally thorough and
|
|
12
|
+
* destructive; callers gate it behind an explicit `--purge` / `purgeOnReap`.
|
|
13
|
+
*
|
|
14
|
+
* Design: the orchestrator is handed already-open stores (the reaper opens them
|
|
15
|
+
* once per sweep; tests pass in-memory ones) and is responsible only for the
|
|
16
|
+
* deletions, not store lifecycle. Every step is best-effort and isolated — a
|
|
17
|
+
* failure to clear one store never aborts the rest — and a {@link PurgeReport}
|
|
18
|
+
* records exactly what was removed.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, rmSync } from "node:fs";
|
|
22
|
+
import { resolve, sep } from "node:path";
|
|
23
|
+
import type { EventStore } from "../events/store.ts";
|
|
24
|
+
import type { MailStore } from "../mail/store.ts";
|
|
25
|
+
import type { MergeQueue } from "../merge/queue.ts";
|
|
26
|
+
import { agentStateDir, specPath } from "../paths.ts";
|
|
27
|
+
import type { AgentSession } from "../types.ts";
|
|
28
|
+
import type { SessionStore } from "./store.ts";
|
|
29
|
+
|
|
30
|
+
/** The open stores a purge needs. The session store is the one the reaper holds. */
|
|
31
|
+
export interface PurgeStores {
|
|
32
|
+
sessions: SessionStore;
|
|
33
|
+
events: EventStore;
|
|
34
|
+
merge: MergeQueue;
|
|
35
|
+
mail: MailStore;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What a single agent purge removed. Counts are best-effort (0 on failure). */
|
|
39
|
+
export interface PurgeReport {
|
|
40
|
+
/** Mail messages deleted (sent or received by the agent). */
|
|
41
|
+
mailDeleted: number;
|
|
42
|
+
/** Event-log rows deleted for the agent. */
|
|
43
|
+
eventsDeleted: number;
|
|
44
|
+
/** Merge-queue entries deleted for the agent. */
|
|
45
|
+
mergeDeleted: number;
|
|
46
|
+
/** Whether the on-disk `.agentplate/agents/<name>/` state dir was removed. */
|
|
47
|
+
stateDirRemoved: boolean;
|
|
48
|
+
/** Whether the task spec file was removed (only when no session still uses it). */
|
|
49
|
+
specRemoved: boolean;
|
|
50
|
+
/** Whether the session row itself was deleted. */
|
|
51
|
+
sessionDeleted: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Erase all data belonging to `session`'s agent. Assumes the agent is already
|
|
56
|
+
* terminated (pid killed, worktree removed) — this only clears records, not
|
|
57
|
+
* processes or git state. Returns a {@link PurgeReport} of what was removed.
|
|
58
|
+
*
|
|
59
|
+
* Ordering note: the session row is deleted last and the spec check runs against
|
|
60
|
+
* the *remaining* sessions, so the agent's own row never keeps its spec alive.
|
|
61
|
+
*/
|
|
62
|
+
export function purgeAgentData(
|
|
63
|
+
root: string,
|
|
64
|
+
session: AgentSession,
|
|
65
|
+
stores: PurgeStores,
|
|
66
|
+
): PurgeReport {
|
|
67
|
+
const report: PurgeReport = {
|
|
68
|
+
mailDeleted: 0,
|
|
69
|
+
eventsDeleted: 0,
|
|
70
|
+
mergeDeleted: 0,
|
|
71
|
+
stateDirRemoved: false,
|
|
72
|
+
specRemoved: false,
|
|
73
|
+
sessionDeleted: false,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Mail: everything the agent sent or received.
|
|
77
|
+
try {
|
|
78
|
+
report.mailDeleted = stores.mail.purge({ agent: session.agentName });
|
|
79
|
+
} catch {
|
|
80
|
+
// Best-effort: a store error here must not block the rest of the purge.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Events: the agent's slice of the append-only log (scoped to its run so a
|
|
84
|
+
// reused agent name in a later run keeps its own history).
|
|
85
|
+
try {
|
|
86
|
+
report.eventsDeleted = stores.events.deleteByAgent(session.agentName, session.runId);
|
|
87
|
+
} catch {
|
|
88
|
+
// Best-effort.
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Merge queue: any pending/merged/failed entries it enqueued.
|
|
92
|
+
try {
|
|
93
|
+
report.mergeDeleted = stores.merge.deleteByAgent(session.agentName);
|
|
94
|
+
} catch {
|
|
95
|
+
// Best-effort.
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// On-disk state dir: identity CV + applied-skills.json. Guarded so we only ever
|
|
99
|
+
// remove a path that resolves inside `.agentplate/agents/` (defence in depth;
|
|
100
|
+
// the path is already derived from a constant, not user input).
|
|
101
|
+
try {
|
|
102
|
+
const dir = resolve(agentStateDir(root, session.agentName));
|
|
103
|
+
const agentsRoot = resolve(agentStateDir(root, ""));
|
|
104
|
+
// Only report a removal when a dir actually existed: rmSync with force:true
|
|
105
|
+
// is a silent no-op for a missing path, which would otherwise read as "removed".
|
|
106
|
+
if (dir.startsWith(agentsRoot + sep) && dir !== agentsRoot && existsSync(dir)) {
|
|
107
|
+
rmSync(dir, { recursive: true, force: true });
|
|
108
|
+
report.stateDirRemoved = true;
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Best-effort.
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Session row: delete it before checking the spec so this agent's own row is
|
|
115
|
+
// not counted as "still using" the task.
|
|
116
|
+
try {
|
|
117
|
+
stores.sessions.deleteSession(session.id);
|
|
118
|
+
report.sessionDeleted = true;
|
|
119
|
+
} catch {
|
|
120
|
+
// Best-effort.
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Spec file: shared by task id, so only remove it once no remaining session
|
|
124
|
+
// references the task. (A multi-agent task keeps its spec until the last one
|
|
125
|
+
// is purged.)
|
|
126
|
+
try {
|
|
127
|
+
const stillUsed = stores.sessions.listSessions().some((s) => s.taskId === session.taskId);
|
|
128
|
+
const spec = specPath(root, session.taskId);
|
|
129
|
+
if (!stillUsed && existsSync(spec)) {
|
|
130
|
+
rmSync(spec, { force: true });
|
|
131
|
+
report.specRemoved = true;
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// Best-effort.
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return report;
|
|
138
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { createEventStore } from "../events/store.ts";
|
|
6
|
+
import { createMailStore } from "../mail/store.ts";
|
|
7
|
+
import { createMergeQueue } from "../merge/queue.ts";
|
|
8
|
+
import { agentStateDir, eventsDbPath, mailDbPath, mergeDbPath, sessionsDbPath } from "../paths.ts";
|
|
6
9
|
import type { AgentSession } from "../types.ts";
|
|
7
10
|
import { reapIdleSessions, selectIdleSessions } from "./reaper.ts";
|
|
8
11
|
import { createSessionStore } from "./store.ts";
|
|
@@ -159,4 +162,83 @@ describe("reapIdleSessions", () => {
|
|
|
159
162
|
store.close();
|
|
160
163
|
}
|
|
161
164
|
});
|
|
165
|
+
|
|
166
|
+
test("purge:true erases the reaped agent's data; default reap keeps it", async () => {
|
|
167
|
+
// Seed the auxiliary stores at their canonical paths, then close them so the
|
|
168
|
+
// reaper opens its own handles (matching production). The `.agentplate` dir
|
|
169
|
+
// must exist first — only the session store mkdirs it, and these open earlier.
|
|
170
|
+
mkdirSync(join(root, ".agentplate"), { recursive: true });
|
|
171
|
+
const events = createEventStore(eventsDbPath(root));
|
|
172
|
+
const merge = createMergeQueue(mergeDbPath(root));
|
|
173
|
+
const mail = createMailStore(mailDbPath(root));
|
|
174
|
+
// Same runId as the session (mk defaults to "r1") so the run-scoped purge matches.
|
|
175
|
+
events.record({ agentName: "old", runId: "r1", type: "tool-start" });
|
|
176
|
+
mail.send({ from: "old", to: "lead", subject: "s", body: "b", type: "status" });
|
|
177
|
+
merge.enqueue({
|
|
178
|
+
branchName: "agentplate/old",
|
|
179
|
+
agentName: "old",
|
|
180
|
+
taskId: "t",
|
|
181
|
+
targetBranch: "main",
|
|
182
|
+
});
|
|
183
|
+
events.close();
|
|
184
|
+
merge.close();
|
|
185
|
+
mail.close();
|
|
186
|
+
mkdirSync(agentStateDir(root, "old"), { recursive: true });
|
|
187
|
+
writeFileSync(join(agentStateDir(root, "old"), "identity.yaml"), "sessions: 1\n");
|
|
188
|
+
|
|
189
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
190
|
+
try {
|
|
191
|
+
store.upsertSession(
|
|
192
|
+
mk({ id: "s-old", agentName: "old", taskId: "t", lastActivity: minsAgo(30) }),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const reaped = await reapIdleSessions(store, root, {
|
|
196
|
+
idleMs: IDLE_MS,
|
|
197
|
+
now: NOW,
|
|
198
|
+
purge: true,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// The report carries the purge counts...
|
|
202
|
+
expect(reaped[0]?.purged).toEqual({
|
|
203
|
+
mailDeleted: 1,
|
|
204
|
+
eventsDeleted: 1,
|
|
205
|
+
mergeDeleted: 1,
|
|
206
|
+
stateDirRemoved: true,
|
|
207
|
+
specRemoved: false, // no spec file was created
|
|
208
|
+
sessionDeleted: true,
|
|
209
|
+
});
|
|
210
|
+
// ...and the session row is gone, not merely stopped.
|
|
211
|
+
expect(store.getSession("s-old")).toBeNull();
|
|
212
|
+
expect(existsSync(agentStateDir(root, "old"))).toBe(false);
|
|
213
|
+
|
|
214
|
+
// Verify the aux stores were actually cleared (reopen fresh handles).
|
|
215
|
+
const ev = createEventStore(eventsDbPath(root));
|
|
216
|
+
const mq = createMergeQueue(mergeDbPath(root));
|
|
217
|
+
const ml = createMailStore(mailDbPath(root));
|
|
218
|
+
try {
|
|
219
|
+
expect(ev.list({ agentName: "old" })).toEqual([]);
|
|
220
|
+
expect(mq.listPending()).toEqual([]);
|
|
221
|
+
expect(ml.list({ from: "old" })).toEqual([]);
|
|
222
|
+
} finally {
|
|
223
|
+
ev.close();
|
|
224
|
+
mq.close();
|
|
225
|
+
ml.close();
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
store.close();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("default reap (no purge) keeps the session row and reports purged: null", async () => {
|
|
233
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
234
|
+
try {
|
|
235
|
+
store.upsertSession(mk({ id: "s-old", agentName: "old", lastActivity: minsAgo(30) }));
|
|
236
|
+
const reaped = await reapIdleSessions(store, root, { idleMs: IDLE_MS, now: NOW });
|
|
237
|
+
expect(reaped[0]?.purged).toBeNull();
|
|
238
|
+
// Row kept for history, just marked stopped.
|
|
239
|
+
expect(store.getSession("s-old")?.state).toBe("stopped");
|
|
240
|
+
} finally {
|
|
241
|
+
store.close();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
162
244
|
});
|
package/src/sessions/reaper.ts
CHANGED
|
@@ -15,9 +15,13 @@
|
|
|
15
15
|
* worktree IS the project root, and it does not stream per-turn events).
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { createEventStore } from "../events/store.ts";
|
|
19
|
+
import { createMailStore } from "../mail/store.ts";
|
|
20
|
+
import { createMergeQueue } from "../merge/queue.ts";
|
|
21
|
+
import { eventsDbPath, mailDbPath, mergeDbPath, worktreesDir } from "../paths.ts";
|
|
19
22
|
import type { AgentSession, SessionState } from "../types.ts";
|
|
20
23
|
import { deleteBranch, removeWorktree, worktreeExists } from "../worktree/manager.ts";
|
|
24
|
+
import { type PurgeReport, purgeAgentData } from "./purge.ts";
|
|
21
25
|
import type { SessionStore } from "./store.ts";
|
|
22
26
|
|
|
23
27
|
/** States that can be reaped — the pre-terminal (still-counted) ones. */
|
|
@@ -62,6 +66,8 @@ export interface ReapedAgent {
|
|
|
62
66
|
idleMs: number;
|
|
63
67
|
/** Whether its worktree was removed (false if kept, missing, or removal failed). */
|
|
64
68
|
worktreeRemoved: boolean;
|
|
69
|
+
/** What a full data purge removed, or null when purge was not requested. */
|
|
70
|
+
purged: PurgeReport | null;
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
export interface ReapOptions {
|
|
@@ -71,6 +77,13 @@ export interface ReapOptions {
|
|
|
71
77
|
now?: number;
|
|
72
78
|
/** Remove the reaped agent's worktree + branch (default true). */
|
|
73
79
|
removeWorktrees?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Fully erase each reaped agent's data — mail, events, queued merges, on-disk
|
|
82
|
+
* state dir, and the session row itself — so nothing of it remains ("clear the
|
|
83
|
+
* Office"). Default false: reaping keeps records for history. When true, the
|
|
84
|
+
* auxiliary stores are opened from `root` for the sweep and closed afterward.
|
|
85
|
+
*/
|
|
86
|
+
purge?: boolean;
|
|
74
87
|
/** Capabilities to skip (defaults to `["coordinator"]`). */
|
|
75
88
|
excludeCapabilities?: readonly string[];
|
|
76
89
|
}
|
|
@@ -88,52 +101,80 @@ export async function reapIdleSessions(
|
|
|
88
101
|
): Promise<ReapedAgent[]> {
|
|
89
102
|
const now = opts.now ?? Date.now();
|
|
90
103
|
const removeWorktrees = opts.removeWorktrees !== false;
|
|
104
|
+
const purge = opts.purge === true;
|
|
91
105
|
const stale = selectIdleSessions(store.listSessions(), {
|
|
92
106
|
idleMs: opts.idleMs,
|
|
93
107
|
now,
|
|
94
108
|
excludeCapabilities: opts.excludeCapabilities,
|
|
95
109
|
});
|
|
96
110
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
// Already gone / not ours — nothing to do.
|
|
111
|
+
// Open the auxiliary stores once for the whole sweep, only when purging. They
|
|
112
|
+
// share the session store's `root` and are closed in `finally` below.
|
|
113
|
+
const aux = purge
|
|
114
|
+
? {
|
|
115
|
+
events: createEventStore(eventsDbPath(root)),
|
|
116
|
+
merge: createMergeQueue(mergeDbPath(root)),
|
|
117
|
+
mail: createMailStore(mailDbPath(root)),
|
|
105
118
|
}
|
|
106
|
-
|
|
119
|
+
: null;
|
|
107
120
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
121
|
+
const reaped: ReapedAgent[] = [];
|
|
122
|
+
try {
|
|
123
|
+
for (const s of stale) {
|
|
124
|
+
// 1. Kill any live process (spawn-per-turn workers usually have no pid).
|
|
125
|
+
if (s.pid != null) {
|
|
126
|
+
try {
|
|
127
|
+
process.kill(s.pid, "SIGTERM");
|
|
128
|
+
} catch {
|
|
129
|
+
// Already gone / not ours — nothing to do.
|
|
114
130
|
}
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 2. Remove the worktree + branch (guarded against the project root).
|
|
134
|
+
let worktreeRemoved = false;
|
|
135
|
+
if (removeWorktrees && isRemovableWorktree(root, s.worktreePath)) {
|
|
117
136
|
try {
|
|
118
|
-
await
|
|
137
|
+
if (await worktreeExists(root, s.worktreePath)) {
|
|
138
|
+
await removeWorktree(root, s.worktreePath, { force: true });
|
|
139
|
+
}
|
|
140
|
+
worktreeRemoved = true;
|
|
141
|
+
// Branch delete is separate and best-effort (no-op if already gone).
|
|
142
|
+
try {
|
|
143
|
+
await deleteBranch(root, s.branchName);
|
|
144
|
+
} catch {
|
|
145
|
+
// Branch may be merged away or shared — leave it.
|
|
146
|
+
}
|
|
119
147
|
} catch {
|
|
120
|
-
|
|
148
|
+
worktreeRemoved = false;
|
|
121
149
|
}
|
|
122
|
-
} catch {
|
|
123
|
-
worktreeRemoved = false;
|
|
124
150
|
}
|
|
125
|
-
}
|
|
126
151
|
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
// 3. Mark the session terminated so it stops counting and shows as stopped.
|
|
153
|
+
// (When purging, the row is deleted in step 4 — but we still transition it
|
|
154
|
+
// first so any concurrent reader observes a terminal state, never a gap.)
|
|
155
|
+
store.updateSessionState(s.id, "stopped");
|
|
156
|
+
|
|
157
|
+
// 4. Optionally erase every trace of the agent so the workspace is cleared.
|
|
158
|
+
let purged: PurgeReport | null = null;
|
|
159
|
+
if (aux) {
|
|
160
|
+
purged = purgeAgentData(root, s, { sessions: store, ...aux });
|
|
161
|
+
}
|
|
129
162
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
163
|
+
reaped.push({
|
|
164
|
+
id: s.id,
|
|
165
|
+
agentName: s.agentName,
|
|
166
|
+
capability: s.capability,
|
|
167
|
+
idleMs: now - Date.parse(s.lastActivity),
|
|
168
|
+
worktreeRemoved,
|
|
169
|
+
purged,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
if (aux) {
|
|
174
|
+
aux.events.close();
|
|
175
|
+
aux.merge.close();
|
|
176
|
+
aux.mail.close();
|
|
177
|
+
}
|
|
137
178
|
}
|
|
138
179
|
return reaped;
|
|
139
180
|
}
|
|
@@ -344,6 +344,24 @@ describe("createSessionStore — file-backed database", () => {
|
|
|
344
344
|
rmSync(dir, { recursive: true, force: true });
|
|
345
345
|
});
|
|
346
346
|
|
|
347
|
+
test("deleteSession removes the row entirely (not just a state change)", () => {
|
|
348
|
+
const store = createSessionStore(":memory:");
|
|
349
|
+
try {
|
|
350
|
+
const run = store.createRun();
|
|
351
|
+
const session = makeSession({ runId: run.id, state: "idle" });
|
|
352
|
+
store.upsertSession(session);
|
|
353
|
+
expect(store.getSession(session.id)).not.toBeNull();
|
|
354
|
+
|
|
355
|
+
store.deleteSession(session.id);
|
|
356
|
+
expect(store.getSession(session.id)).toBeNull();
|
|
357
|
+
expect(store.countActive(run.id)).toBe(0);
|
|
358
|
+
// Deleting a missing id is a no-op (no throw).
|
|
359
|
+
expect(() => store.deleteSession("does-not-exist")).not.toThrow();
|
|
360
|
+
} finally {
|
|
361
|
+
store.close();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
347
365
|
test("creates the parent directory and persists across reopen", () => {
|
|
348
366
|
// Point at a NON-existent nested path to exercise the mkdir-parent logic.
|
|
349
367
|
const dbPath = join(dir, "nested", "sessions.db");
|
package/src/sessions/store.ts
CHANGED
|
@@ -56,6 +56,12 @@ export interface SessionStore {
|
|
|
56
56
|
updateSessionState(id: string, state: SessionState): void;
|
|
57
57
|
setRuntimeSessionId(id: string, runtimeSessionId: string): void;
|
|
58
58
|
touch(id: string): void;
|
|
59
|
+
/**
|
|
60
|
+
* Permanently delete a session row by id (no-op if absent). Unlike
|
|
61
|
+
* {@link updateSessionState}(id, "stopped") — which keeps the row for history —
|
|
62
|
+
* this removes it entirely, used when fully purging a reaped agent's data.
|
|
63
|
+
*/
|
|
64
|
+
deleteSession(id: string): void;
|
|
59
65
|
countActive(runId?: string): number;
|
|
60
66
|
countActiveByParent(parentAgent: string, runId?: string): number;
|
|
61
67
|
// --- Lifecycle ---
|
|
@@ -312,6 +318,11 @@ export function createSessionStore(dbPath: string): SessionStore {
|
|
|
312
318
|
db.query("UPDATE sessions SET last_activity = ? WHERE id = ?").run(now, id);
|
|
313
319
|
}
|
|
314
320
|
|
|
321
|
+
function deleteSession(id: string): void {
|
|
322
|
+
// Hard delete. Safe to call for a missing id (DELETE simply affects 0 rows).
|
|
323
|
+
db.query("DELETE FROM sessions WHERE id = ?").run(id);
|
|
324
|
+
}
|
|
325
|
+
|
|
315
326
|
function countActive(runId?: string): number {
|
|
316
327
|
// Active = booting | working | idle (see ACTIVE_STATES). Used by schedulers
|
|
317
328
|
// to enforce per-run / global concurrency caps.
|
|
@@ -363,6 +374,7 @@ export function createSessionStore(dbPath: string): SessionStore {
|
|
|
363
374
|
updateSessionState,
|
|
364
375
|
setRuntimeSessionId,
|
|
365
376
|
touch,
|
|
377
|
+
deleteSession,
|
|
366
378
|
countActive,
|
|
367
379
|
countActiveByParent,
|
|
368
380
|
close,
|
package/src/types.ts
CHANGED
|
@@ -167,6 +167,14 @@ export interface AgentsConfig {
|
|
|
167
167
|
skipGates: boolean;
|
|
168
168
|
/** Skip the post-turn skill-distillation loop. */
|
|
169
169
|
skipSkills: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* When the idle reaper fires (in `agentplate serve`'s loop), also fully erase
|
|
172
|
+
* each reaped agent — mail, events, queued merges, on-disk state dir, and the
|
|
173
|
+
* session row — so the workspace is cleared, not just stopped. Default false
|
|
174
|
+
* (reaping keeps records for history). The `agentplate reap --purge` flag does
|
|
175
|
+
* the same on demand regardless of this setting.
|
|
176
|
+
*/
|
|
177
|
+
purgeOnReap: boolean;
|
|
170
178
|
}
|
|
171
179
|
|
|
172
180
|
/**
|
package/src/version.ts
CHANGED
package/src/wizard/setup.ts
CHANGED
|
@@ -384,6 +384,13 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
|
|
|
384
384
|
agents.skipReview = skips.includes("skipReview");
|
|
385
385
|
agents.skipGates = skips.includes("skipGates");
|
|
386
386
|
agents.skipSkills = skips.includes("skipSkills");
|
|
387
|
+
|
|
388
|
+
agents.purgeOnReap = ensure(
|
|
389
|
+
await p.confirm({
|
|
390
|
+
message: "Fully erase idle agents when reaped (mail, events, merges, files, session)?",
|
|
391
|
+
initialValue: agents.purgeOnReap,
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
387
394
|
}
|
|
388
395
|
|
|
389
396
|
// 7. Summary -----------------------------------------------------------
|
|
@@ -403,6 +410,7 @@ export async function runSetupWizard(currentConfig: AgentplateConfig): Promise<W
|
|
|
403
410
|
previewProvider.baseUrl ? `base URL: ${previewProvider.baseUrl}` : undefined,
|
|
404
411
|
`gates: ${qualityGates.length ? qualityGates.map((g) => g.name).join(", ") : "none"}`,
|
|
405
412
|
`auto-merge:${autoMerge}`,
|
|
413
|
+
agents.purgeOnReap ? "purge-on-reap: on (idle agents fully erased)" : undefined,
|
|
406
414
|
modelsByCapability?.scout
|
|
407
415
|
? `fast model: ${modelsByCapability.scout} (scout, reviewer)`
|
|
408
416
|
: undefined,
|