@agentplate/cli 1.2.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 +43 -0
- package/package.json +1 -1
- package/src/agents/drive.ts +76 -6
- package/src/agents/turn-runner.test.ts +67 -0
- package/src/agents/turn-runner.ts +18 -1
- package/src/commands/reap.ts +29 -6
- package/src/commands/sling.ts +14 -3
- package/src/commands/stop.ts +43 -6
- package/src/commands/turn.ts +4 -29
- package/src/commands/watch.test.ts +136 -0
- package/src/commands/watch.ts +151 -0
- package/src/config.test.ts +14 -0
- package/src/config.ts +11 -0
- package/src/events/store.test.ts +22 -0
- package/src/events/store.ts +24 -1
- package/src/index.ts +2 -0
- package/src/merge/queue.test.ts +15 -0
- package/src/merge/queue.ts +20 -0
- package/src/runtimes/registry.test.ts +16 -2
- package/src/runtimes/registry.ts +13 -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 +22 -0
- package/src/version.ts +1 -1
- package/src/wizard/setup.ts +74 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate watch` tests. Real initialized temp project + real (mock) turns,
|
|
3
|
+
* driven deterministically via `--once`. Asserts which idle agents get advanced.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
AGENTPLATE_DIR,
|
|
12
|
+
CONFIG_FILE,
|
|
13
|
+
DEFAULT_CONFIG,
|
|
14
|
+
serializeConfig,
|
|
15
|
+
setProjectRootOverride,
|
|
16
|
+
} from "../config.ts";
|
|
17
|
+
import { createMailClient } from "../mail/client.ts";
|
|
18
|
+
import { sessionsDbPath } from "../paths.ts";
|
|
19
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
20
|
+
import type { AgentSession } from "../types.ts";
|
|
21
|
+
import { createWatchCommand } from "./watch.ts";
|
|
22
|
+
|
|
23
|
+
let root: string;
|
|
24
|
+
let worktree: string;
|
|
25
|
+
|
|
26
|
+
function seedSession(over: Partial<AgentSession>): void {
|
|
27
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
28
|
+
const now = new Date().toISOString();
|
|
29
|
+
try {
|
|
30
|
+
store.upsertSession({
|
|
31
|
+
id: `session-${crypto.randomUUID()}`,
|
|
32
|
+
agentName: "builder-1",
|
|
33
|
+
capability: "builder",
|
|
34
|
+
taskId: "task-1",
|
|
35
|
+
runId: "run-1",
|
|
36
|
+
worktreePath: worktree,
|
|
37
|
+
branchName: "agentplate/builder-1",
|
|
38
|
+
state: "idle",
|
|
39
|
+
parentAgent: "lead-1",
|
|
40
|
+
depth: 1,
|
|
41
|
+
pid: null,
|
|
42
|
+
runtimeSessionId: "sess-prior",
|
|
43
|
+
startedAt: now,
|
|
44
|
+
lastActivity: now,
|
|
45
|
+
...over,
|
|
46
|
+
});
|
|
47
|
+
} finally {
|
|
48
|
+
store.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sendMailTo(agent: string): void {
|
|
53
|
+
const mail = createMailClient(root);
|
|
54
|
+
try {
|
|
55
|
+
mail.send({ from: "lead-1", to: agent, subject: "ping", body: "continue", type: "status" });
|
|
56
|
+
} finally {
|
|
57
|
+
mail.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Run `watch --once --json` and return the parsed summary. */
|
|
62
|
+
async function watchOnce(): Promise<{ driven: number; turns: Array<{ agent: string }> }> {
|
|
63
|
+
const original = process.stdout.write.bind(process.stdout);
|
|
64
|
+
let out = "";
|
|
65
|
+
process.stdout.write = (chunk: string | Uint8Array): boolean => {
|
|
66
|
+
out += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
67
|
+
return true;
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
await createWatchCommand().parseAsync(["--once", "--json"], { from: "user" });
|
|
71
|
+
} finally {
|
|
72
|
+
process.stdout.write = original;
|
|
73
|
+
}
|
|
74
|
+
// jsonOutput wraps the payload in the standard { ok, data } envelope.
|
|
75
|
+
return JSON.parse(out.trim()).data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
root = mkdtempSync(join(tmpdir(), "agentplate-watch-"));
|
|
80
|
+
worktree = mkdtempSync(join(tmpdir(), "agentplate-watch-wt-"));
|
|
81
|
+
mkdirSync(join(root, AGENTPLATE_DIR), { recursive: true });
|
|
82
|
+
const config = structuredClone(DEFAULT_CONFIG);
|
|
83
|
+
config.runtime.default = "mock";
|
|
84
|
+
writeFileSync(join(root, AGENTPLATE_DIR, CONFIG_FILE), serializeConfig(config), "utf8");
|
|
85
|
+
setProjectRootOverride(root);
|
|
86
|
+
process.env.AGENTPLATE_MOCK_CMD = "true";
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
setProjectRootOverride(null);
|
|
91
|
+
rmSync(root, { recursive: true, force: true });
|
|
92
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
93
|
+
process.env.AGENTPLATE_MOCK_CMD = undefined;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("agentplate watch --once", () => {
|
|
97
|
+
test("drives an idle agent that has unread mail", async () => {
|
|
98
|
+
seedSession({ agentName: "builder-1", state: "idle" });
|
|
99
|
+
sendMailTo("builder-1");
|
|
100
|
+
const out = await watchOnce();
|
|
101
|
+
expect(out.driven).toBe(1);
|
|
102
|
+
expect(out.turns[0]?.agent).toBe("builder-1");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("skips an idle agent with no unread mail", async () => {
|
|
106
|
+
seedSession({ agentName: "builder-1", state: "idle" });
|
|
107
|
+
const out = await watchOnce();
|
|
108
|
+
expect(out.driven).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("never drives a terminal agent, even with mail", async () => {
|
|
112
|
+
seedSession({ agentName: "builder-1", state: "completed" });
|
|
113
|
+
sendMailTo("builder-1");
|
|
114
|
+
const out = await watchOnce();
|
|
115
|
+
expect(out.driven).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("drives only the idle-with-mail agents in a mixed fleet", async () => {
|
|
119
|
+
seedSession({ agentName: "has-mail", state: "idle" });
|
|
120
|
+
seedSession({ agentName: "no-mail", state: "idle" });
|
|
121
|
+
sendMailTo("has-mail");
|
|
122
|
+
const out = await watchOnce();
|
|
123
|
+
expect(out.driven).toBe(1);
|
|
124
|
+
expect(out.turns.map((t) => t.agent)).toEqual(["has-mail"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("drives multiple idle-with-mail agents in one pass (concurrent)", async () => {
|
|
128
|
+
for (const a of ["a1", "a2", "a3"]) {
|
|
129
|
+
seedSession({ agentName: a, state: "idle" });
|
|
130
|
+
sendMailTo(a);
|
|
131
|
+
}
|
|
132
|
+
const out = await watchOnce();
|
|
133
|
+
expect(out.driven).toBe(3);
|
|
134
|
+
expect(out.turns.map((t) => t.agent).sort()).toEqual(["a1", "a2", "a3"]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate watch` — the mail pump that makes warm-start automatic.
|
|
3
|
+
*
|
|
4
|
+
* Each tick it finds **idle** agents (paused after a turn, awaiting mail) that now
|
|
5
|
+
* have **unread mail**, and runs each one's next turn via {@link driveAgentTurn},
|
|
6
|
+
* which **resumes** the runtime session (warm start). Detection is non-destructive
|
|
7
|
+
* (`mail.check(..., { unreadOnly })`); the turn itself injects + marks the mail
|
|
8
|
+
* read. Each pass drives eligible agents concurrently, capped at the fleet's
|
|
9
|
+
* `maxConcurrent`; a failing turn is logged and the loop continues.
|
|
10
|
+
*
|
|
11
|
+
* Modes: `--once` (single pass), `--until-idle` (loop until no active agents
|
|
12
|
+
* remain — the run drained), or the default (loop until Ctrl-C).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { Command } from "commander";
|
|
17
|
+
import { driveAgentTurn } from "../agents/drive.ts";
|
|
18
|
+
import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
|
|
19
|
+
import { ValidationError } from "../errors.ts";
|
|
20
|
+
import { createEventStore } from "../events/store.ts";
|
|
21
|
+
import { jsonOutput } from "../json.ts";
|
|
22
|
+
import { brand, muted, printInfo, printSuccess, printWarning } from "../logging/color.ts";
|
|
23
|
+
import { createMailClient } from "../mail/client.ts";
|
|
24
|
+
import { currentRunPath, eventsDbPath, sessionsDbPath } from "../paths.ts";
|
|
25
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
26
|
+
import type { AgentSession } from "../types.ts";
|
|
27
|
+
|
|
28
|
+
interface WatchOptions {
|
|
29
|
+
run?: string;
|
|
30
|
+
interval?: string;
|
|
31
|
+
once?: boolean;
|
|
32
|
+
untilIdle?: boolean;
|
|
33
|
+
json?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
|
|
38
|
+
/** Current run id from `.agentplate/current-run.txt`, or undefined (= all runs). */
|
|
39
|
+
function readCurrentRun(root: string): string | undefined {
|
|
40
|
+
const path = currentRunPath(root);
|
|
41
|
+
if (!existsSync(path)) return undefined;
|
|
42
|
+
return readFileSync(path, "utf8").trim() || undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createWatchCommand(): Command {
|
|
46
|
+
return new Command("watch")
|
|
47
|
+
.description("Auto-advance idle agents: run their next (resumed) turn when they have mail")
|
|
48
|
+
.option("--run <id>", "scope to a run (default: the current run)")
|
|
49
|
+
.option("--interval <ms>", "poll interval in milliseconds", "2000")
|
|
50
|
+
.option("--once", "run a single pass, then exit")
|
|
51
|
+
.option("--until-idle", "exit once no active agents remain (the run has drained)")
|
|
52
|
+
.option("--json", "output a JSON summary")
|
|
53
|
+
.action(async (opts: WatchOptions, command: Command) => {
|
|
54
|
+
const useJson = command.optsWithGlobals().json === true;
|
|
55
|
+
const root = findProjectRoot();
|
|
56
|
+
if (!isInitialized(root)) {
|
|
57
|
+
throw new ValidationError("Not initialized. Run `agentplate setup` first.");
|
|
58
|
+
}
|
|
59
|
+
const config = loadConfig(root);
|
|
60
|
+
const interval = Number(opts.interval ?? "2000");
|
|
61
|
+
if (!Number.isFinite(interval) || interval < 0) {
|
|
62
|
+
throw new ValidationError("--interval must be a non-negative number of milliseconds.");
|
|
63
|
+
}
|
|
64
|
+
const runId = opts.run ?? readCurrentRun(root);
|
|
65
|
+
|
|
66
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
67
|
+
const mail = createMailClient(root);
|
|
68
|
+
const events = createEventStore(eventsDbPath(root));
|
|
69
|
+
|
|
70
|
+
let stop = false;
|
|
71
|
+
const onSigint = (): void => {
|
|
72
|
+
stop = true;
|
|
73
|
+
};
|
|
74
|
+
process.once("SIGINT", onSigint);
|
|
75
|
+
|
|
76
|
+
let totalDriven = 0;
|
|
77
|
+
const drivenLog: Array<{ agent: string; state: string }> = [];
|
|
78
|
+
// Cap simultaneous turns at the fleet's maxConcurrent (>= 1).
|
|
79
|
+
const concurrency = Math.max(1, config.agents.maxConcurrent);
|
|
80
|
+
|
|
81
|
+
/** Drive one agent's next turn, recording the outcome. */
|
|
82
|
+
const driveOne = async (session: AgentSession): Promise<void> => {
|
|
83
|
+
try {
|
|
84
|
+
const { finalState } = await driveAgentTurn({
|
|
85
|
+
root,
|
|
86
|
+
config,
|
|
87
|
+
session,
|
|
88
|
+
store,
|
|
89
|
+
events,
|
|
90
|
+
mail,
|
|
91
|
+
});
|
|
92
|
+
totalDriven++;
|
|
93
|
+
drivenLog.push({ agent: session.agentName, state: finalState });
|
|
94
|
+
if (!useJson) printInfo(` ${brand(session.agentName)} → ${finalState}`);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
97
|
+
if (!useJson) printWarning(` ${session.agentName}: turn failed — ${message}`);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One pass: drive every idle agent that has unread mail, up to `concurrency`
|
|
103
|
+
* turns at a time. Returns the number driven.
|
|
104
|
+
*/
|
|
105
|
+
const pass = async (): Promise<number> => {
|
|
106
|
+
const eligible = store
|
|
107
|
+
.listSessions({ runId, state: "idle" })
|
|
108
|
+
.filter((s) => mail.check(s.agentName, { unreadOnly: true }).length > 0);
|
|
109
|
+
let next = 0;
|
|
110
|
+
const worker = async (): Promise<void> => {
|
|
111
|
+
while (!stop) {
|
|
112
|
+
const session = eligible[next++];
|
|
113
|
+
if (!session) break;
|
|
114
|
+
await driveOne(session);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const lanes = Math.min(concurrency, eligible.length);
|
|
118
|
+
await Promise.all(Array.from({ length: lanes }, () => worker()));
|
|
119
|
+
return eligible.length;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
if (opts.once) {
|
|
124
|
+
await pass();
|
|
125
|
+
} else {
|
|
126
|
+
if (!useJson) {
|
|
127
|
+
printInfo(
|
|
128
|
+
`Watching${runId ? ` run ${muted(runId)}` : " all runs"} (every ${interval}ms; Ctrl-C to stop)…`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
while (!stop) {
|
|
132
|
+
await pass();
|
|
133
|
+
if (opts.untilIdle && store.countActive(runId) === 0) break;
|
|
134
|
+
if (stop) break;
|
|
135
|
+
await sleep(interval);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
process.removeListener("SIGINT", onSigint);
|
|
140
|
+
events.close();
|
|
141
|
+
mail.close();
|
|
142
|
+
store.close();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (useJson) {
|
|
146
|
+
jsonOutput({ runId: runId ?? null, driven: totalDriven, turns: drivenLog });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
printSuccess(`Watch finished — drove ${totalDriven} turn(s).`);
|
|
150
|
+
});
|
|
151
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -92,6 +92,20 @@ describe("validateConfig", () => {
|
|
|
92
92
|
cfg.merge.autoMerge = "always";
|
|
93
93
|
expect(() => validateConfig(cfg)).toThrow();
|
|
94
94
|
});
|
|
95
|
+
|
|
96
|
+
test("turn timeout + skip defaults are off", () => {
|
|
97
|
+
expect(DEFAULT_CONFIG.agents.turnTimeoutMinutes).toBe(0);
|
|
98
|
+
expect(DEFAULT_CONFIG.agents.skipScout).toBe(false);
|
|
99
|
+
expect(DEFAULT_CONFIG.agents.skipReview).toBe(false);
|
|
100
|
+
expect(DEFAULT_CONFIG.agents.skipGates).toBe(false);
|
|
101
|
+
expect(DEFAULT_CONFIG.agents.skipSkills).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("rejects a negative turnTimeoutMinutes", () => {
|
|
105
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
106
|
+
cfg.agents.turnTimeoutMinutes = -1;
|
|
107
|
+
expect(() => validateConfig(cfg)).toThrow();
|
|
108
|
+
});
|
|
95
109
|
});
|
|
96
110
|
|
|
97
111
|
describe("isInitialized / serializeConfig", () => {
|
package/src/config.ts
CHANGED
|
@@ -43,6 +43,12 @@ export const DEFAULT_CONFIG: AgentplateConfig = {
|
|
|
43
43
|
maxDepth: 2,
|
|
44
44
|
maxAgentsPerLead: 5,
|
|
45
45
|
idleTimeoutMinutes: 10,
|
|
46
|
+
turnTimeoutMinutes: 0,
|
|
47
|
+
skipScout: false,
|
|
48
|
+
skipReview: false,
|
|
49
|
+
skipGates: false,
|
|
50
|
+
skipSkills: false,
|
|
51
|
+
purgeOnReap: false,
|
|
46
52
|
},
|
|
47
53
|
merge: {
|
|
48
54
|
aiResolveEnabled: true,
|
|
@@ -176,6 +182,11 @@ export function validateConfig(config: AgentplateConfig): void {
|
|
|
176
182
|
"config.agents.idleTimeoutMinutes must be >= 0 (0 disables idle reaping)",
|
|
177
183
|
);
|
|
178
184
|
}
|
|
185
|
+
if (config.agents.turnTimeoutMinutes < 0) {
|
|
186
|
+
throw new ConfigError(
|
|
187
|
+
"config.agents.turnTimeoutMinutes must be >= 0 (0 disables the per-turn cap)",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
179
190
|
const autoMergeModes: AutoMergeMode[] = ["off", "on-gates-pass", "on-complete"];
|
|
180
191
|
if (!autoMergeModes.includes(config.merge.autoMerge)) {
|
|
181
192
|
throw new ConfigError(`config.merge.autoMerge must be one of: ${autoMergeModes.join(", ")}`);
|
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/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { createStatusCommand } from "./commands/status.ts";
|
|
|
35
35
|
import { createStopCommand } from "./commands/stop.ts";
|
|
36
36
|
import { createTuiCommand } from "./commands/tui.ts";
|
|
37
37
|
import { createTurnCommand } from "./commands/turn.ts";
|
|
38
|
+
import { createWatchCommand } from "./commands/watch.ts";
|
|
38
39
|
import { createWorktreeCommand } from "./commands/worktree.ts";
|
|
39
40
|
import { setProjectRootOverride } from "./config.ts";
|
|
40
41
|
import { isAgentplateError } from "./errors.ts";
|
|
@@ -98,6 +99,7 @@ function buildProgram(): Command {
|
|
|
98
99
|
program.addCommand(createCoordinatorCommand());
|
|
99
100
|
program.addCommand(createSlingCommand());
|
|
100
101
|
program.addCommand(createTurnCommand());
|
|
102
|
+
program.addCommand(createWatchCommand());
|
|
101
103
|
program.addCommand(createSpecCommand());
|
|
102
104
|
program.addCommand(createStatusCommand());
|
|
103
105
|
program.addCommand(createMailCommand());
|
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
|
},
|
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { ValidationError } from "../errors.ts";
|
|
3
|
-
import type { ResolvedModel } from "../types.ts";
|
|
3
|
+
import type { ResolvedModel, RuntimeConfig } from "../types.ts";
|
|
4
4
|
import { ClaudeRuntime } from "./claude.ts";
|
|
5
5
|
import { CodexRuntime } from "./codex.ts";
|
|
6
6
|
import { CursorRuntime } from "./cursor.ts";
|
|
7
7
|
import { GeminiRuntime } from "./gemini.ts";
|
|
8
8
|
import { MockRuntime } from "./mock.ts";
|
|
9
9
|
import { OpenCodeRuntime, opencodeModel } from "./opencode.ts";
|
|
10
|
-
import { getRuntime, getRuntimeNames } from "./registry.ts";
|
|
10
|
+
import { getRuntime, getRuntimeNames, runtimeNameForCapability } from "./registry.ts";
|
|
11
11
|
import type { AgentEvent, DirectSpawnOpts } from "./types.ts";
|
|
12
12
|
|
|
13
|
+
describe("runtimeNameForCapability", () => {
|
|
14
|
+
const rt: RuntimeConfig = { default: "claude", capabilities: { scout: "codex" } };
|
|
15
|
+
test("explicit override wins over everything", () => {
|
|
16
|
+
expect(runtimeNameForCapability(rt, "scout", "gemini")).toBe("gemini");
|
|
17
|
+
});
|
|
18
|
+
test("per-capability override applies when set", () => {
|
|
19
|
+
expect(runtimeNameForCapability(rt, "scout")).toBe("codex");
|
|
20
|
+
});
|
|
21
|
+
test("falls back to the default runtime", () => {
|
|
22
|
+
expect(runtimeNameForCapability(rt, "builder")).toBe("claude");
|
|
23
|
+
expect(runtimeNameForCapability({ default: "opencode" }, "scout")).toBe("opencode");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
13
27
|
// Minimal DirectSpawnOpts builder for argv-shape assertions. `cwd` and
|
|
14
28
|
// `instructionPath` are required by the type but irrelevant to argv here.
|
|
15
29
|
function spawnOpts(overrides: Partial<DirectSpawnOpts> = {}): DirectSpawnOpts {
|
package/src/runtimes/registry.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { ValidationError } from "../errors.ts";
|
|
11
|
+
import type { Capability, RuntimeConfig } from "../types.ts";
|
|
11
12
|
import { ClaudeRuntime } from "./claude.ts";
|
|
12
13
|
import { CodexRuntime } from "./codex.ts";
|
|
13
14
|
import { CursorRuntime } from "./cursor.ts";
|
|
@@ -54,6 +55,18 @@ export function getRuntime(name?: string, fallback?: string): AgentRuntime {
|
|
|
54
55
|
return factory();
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Resolve which runtime adapter drives a given capability: an explicit `override`
|
|
60
|
+
* wins, then a per-capability entry in `runtime.capabilities`, then the default.
|
|
61
|
+
*/
|
|
62
|
+
export function runtimeNameForCapability(
|
|
63
|
+
runtime: RuntimeConfig,
|
|
64
|
+
capability: Capability,
|
|
65
|
+
override?: string,
|
|
66
|
+
): string {
|
|
67
|
+
return override ?? runtime.capabilities?.[capability] ?? runtime.default;
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
/**
|
|
58
71
|
* Names of all registered runtimes, in registration order. Used to validate a
|
|
59
72
|
* user-supplied runtime name and to render the choices in help / errors.
|
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
|
})
|