@agentplate/cli 1.1.0 → 1.3.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 +46 -0
- package/agents/coordinator.md +6 -0
- package/agents/lead.md +3 -1
- package/package.json +1 -1
- package/src/agents/capacity.test.ts +55 -0
- package/src/agents/capacity.ts +50 -0
- package/src/agents/drive.test.ts +155 -0
- package/src/agents/drive.ts +270 -0
- package/src/agents/turn-runner.test.ts +67 -0
- package/src/agents/turn-runner.ts +18 -1
- package/src/commands/sling.ts +46 -117
- package/src/commands/turn.test.ts +101 -0
- package/src/commands/turn.ts +88 -0
- package/src/commands/watch.test.ts +136 -0
- package/src/commands/watch.ts +151 -0
- package/src/config.test.ts +32 -0
- package/src/config.ts +16 -1
- package/src/errors.ts +11 -0
- package/src/index.ts +4 -0
- package/src/insights/quality-gates.test.ts +43 -0
- package/src/insights/quality-gates.ts +30 -31
- package/src/merge/auto.test.ts +157 -0
- package/src/merge/auto.ts +118 -0
- package/src/runtimes/registry.test.ts +16 -2
- package/src/runtimes/registry.ts +13 -0
- package/src/runtimes/resolve.test.ts +49 -0
- package/src/runtimes/resolve.ts +11 -7
- package/src/sessions/store.test.ts +13 -0
- package/src/sessions/store.ts +20 -0
- package/src/types.ts +30 -1
- package/src/version.ts +1 -1
- package/src/wizard/setup.test.ts +45 -0
- package/src/wizard/setup.ts +181 -2
|
@@ -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
|
@@ -45,6 +45,13 @@ describe("loadConfig", () => {
|
|
|
45
45
|
expect(cfg.merge.aiResolveEnabled).toBe(true);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
test("an old config without merge.autoMerge inherits the 'off' default (deep merge)", () => {
|
|
49
|
+
writeConfig(root, "config.yaml", "merge:\n aiResolveEnabled: true\n");
|
|
50
|
+
const cfg = loadConfig(root);
|
|
51
|
+
expect(cfg.merge.autoMerge).toBe("off");
|
|
52
|
+
expect(cfg.merge.aiResolveEnabled).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
48
55
|
test("config.local.yaml overrides config.yaml", () => {
|
|
49
56
|
writeConfig(root, "config.yaml", "runtime:\n default: claude\n");
|
|
50
57
|
writeConfig(root, "config.local.yaml", "runtime:\n default: gemini\n");
|
|
@@ -74,6 +81,31 @@ describe("validateConfig", () => {
|
|
|
74
81
|
test("accepts the defaults", () => {
|
|
75
82
|
expect(() => validateConfig(structuredClone(DEFAULT_CONFIG))).not.toThrow();
|
|
76
83
|
});
|
|
84
|
+
|
|
85
|
+
test("auto-merge defaults to off", () => {
|
|
86
|
+
expect(DEFAULT_CONFIG.merge.autoMerge).toBe("off");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("rejects an unknown merge.autoMerge mode", () => {
|
|
90
|
+
const cfg = structuredClone(DEFAULT_CONFIG);
|
|
91
|
+
// @ts-expect-error intentionally invalid value
|
|
92
|
+
cfg.merge.autoMerge = "always";
|
|
93
|
+
expect(() => validateConfig(cfg)).toThrow();
|
|
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
|
+
});
|
|
77
109
|
});
|
|
78
110
|
|
|
79
111
|
describe("isInitialized / serializeConfig", () => {
|
package/src/config.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
13
13
|
import { join, resolve } from "node:path";
|
|
14
14
|
import yaml from "js-yaml";
|
|
15
15
|
import { ConfigError } from "./errors.ts";
|
|
16
|
-
import type { AgentplateConfig } from "./types.ts";
|
|
16
|
+
import type { AgentplateConfig, AutoMergeMode } from "./types.ts";
|
|
17
17
|
|
|
18
18
|
/** Directory (relative to project root) holding all Agentplate state. */
|
|
19
19
|
export const AGENTPLATE_DIR = ".agentplate";
|
|
@@ -43,9 +43,15 @@ 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,
|
|
46
51
|
},
|
|
47
52
|
merge: {
|
|
48
53
|
aiResolveEnabled: true,
|
|
54
|
+
autoMerge: "off",
|
|
49
55
|
},
|
|
50
56
|
skills: {
|
|
51
57
|
enabled: true,
|
|
@@ -175,6 +181,15 @@ export function validateConfig(config: AgentplateConfig): void {
|
|
|
175
181
|
"config.agents.idleTimeoutMinutes must be >= 0 (0 disables idle reaping)",
|
|
176
182
|
);
|
|
177
183
|
}
|
|
184
|
+
if (config.agents.turnTimeoutMinutes < 0) {
|
|
185
|
+
throw new ConfigError(
|
|
186
|
+
"config.agents.turnTimeoutMinutes must be >= 0 (0 disables the per-turn cap)",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const autoMergeModes: AutoMergeMode[] = ["off", "on-gates-pass", "on-complete"];
|
|
190
|
+
if (!autoMergeModes.includes(config.merge.autoMerge)) {
|
|
191
|
+
throw new ConfigError(`config.merge.autoMerge must be one of: ${autoMergeModes.join(", ")}`);
|
|
192
|
+
}
|
|
178
193
|
}
|
|
179
194
|
|
|
180
195
|
/**
|
package/src/errors.ts
CHANGED
|
@@ -63,6 +63,17 @@ export class NotFoundError extends AgentplateError {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* A spawn was refused because it would exceed a configured orchestration limit
|
|
68
|
+
* (maxConcurrent / maxAgentsPerLead / maxDepth). Distinct exit code so callers
|
|
69
|
+
* (a lead/coordinator) can recognize "at capacity — back off and retry later".
|
|
70
|
+
*/
|
|
71
|
+
export class CapacityError extends AgentplateError {
|
|
72
|
+
constructor(message: string) {
|
|
73
|
+
super(message, "CAPACITY_EXCEEDED", 5);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
66
77
|
/** Type guard: is the given value a AgentplateError? */
|
|
67
78
|
export function isAgentplateError(value: unknown): value is AgentplateError {
|
|
68
79
|
return value instanceof AgentplateError;
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,8 @@ import { createSpecCommand } from "./commands/spec.ts";
|
|
|
34
34
|
import { createStatusCommand } from "./commands/status.ts";
|
|
35
35
|
import { createStopCommand } from "./commands/stop.ts";
|
|
36
36
|
import { createTuiCommand } from "./commands/tui.ts";
|
|
37
|
+
import { createTurnCommand } from "./commands/turn.ts";
|
|
38
|
+
import { createWatchCommand } from "./commands/watch.ts";
|
|
37
39
|
import { createWorktreeCommand } from "./commands/worktree.ts";
|
|
38
40
|
import { setProjectRootOverride } from "./config.ts";
|
|
39
41
|
import { isAgentplateError } from "./errors.ts";
|
|
@@ -96,6 +98,8 @@ function buildProgram(): Command {
|
|
|
96
98
|
// Orchestration
|
|
97
99
|
program.addCommand(createCoordinatorCommand());
|
|
98
100
|
program.addCommand(createSlingCommand());
|
|
101
|
+
program.addCommand(createTurnCommand());
|
|
102
|
+
program.addCommand(createWatchCommand());
|
|
99
103
|
program.addCommand(createSpecCommand());
|
|
100
104
|
program.addCommand(createStatusCommand());
|
|
101
105
|
program.addCommand(createMailCommand());
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for runQualityGates. Real subprocesses (no mocks) — gates are shell
|
|
3
|
+
* commands, so we use `true`/`false`/`sleep` to exercise pass/fail/partial and to
|
|
4
|
+
* prove the gates run concurrently (wall-clock < sum of durations).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import type { QualityGate } from "../types.ts";
|
|
9
|
+
import { runQualityGates } from "./quality-gates.ts";
|
|
10
|
+
|
|
11
|
+
const gate = (name: string, command: string): QualityGate => ({ name, command });
|
|
12
|
+
|
|
13
|
+
describe("runQualityGates", () => {
|
|
14
|
+
test("returns null when there are no gates", async () => {
|
|
15
|
+
expect(await runQualityGates([], process.cwd())).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("all passing → success", async () => {
|
|
19
|
+
const out = await runQualityGates([gate("a", "true"), gate("b", "true")], process.cwd());
|
|
20
|
+
expect(out?.status).toBe("success");
|
|
21
|
+
expect(out?.results.every((r) => r.passed)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("all failing → failure", async () => {
|
|
25
|
+
const out = await runQualityGates([gate("a", "false"), gate("b", "false")], process.cwd());
|
|
26
|
+
expect(out?.status).toBe("failure");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("mixed → partial", async () => {
|
|
30
|
+
const out = await runQualityGates([gate("ok", "true"), gate("bad", "false")], process.cwd());
|
|
31
|
+
expect(out?.status).toBe("partial");
|
|
32
|
+
// Order is preserved across the concurrent run.
|
|
33
|
+
expect(out?.results.map((r) => r.name)).toEqual(["ok", "bad"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("gates run concurrently (wall-clock well under the sum)", async () => {
|
|
37
|
+
const gates = [gate("s1", "sleep 0.3"), gate("s2", "sleep 0.3"), gate("s3", "sleep 0.3")];
|
|
38
|
+
const out = await runQualityGates(gates, process.cwd());
|
|
39
|
+
expect(out?.status).toBe("success");
|
|
40
|
+
// Sequential would be ~900ms; concurrent should be well under that.
|
|
41
|
+
expect(out?.totalDurationMs ?? Number.POSITIVE_INFINITY).toBeLessThan(700);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -34,37 +34,36 @@ export async function runQualityGates(
|
|
|
34
34
|
): Promise<QualityGateOutcome | null> {
|
|
35
35
|
if (gates.length === 0) return null;
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
stderr: "pipe"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
37
|
+
// Gates are independent checks, so run them concurrently and measure the
|
|
38
|
+
// outcome by wall-clock — `totalDurationMs` is the elapsed time of the whole
|
|
39
|
+
// batch (the slowest gate), not the sum of all gates.
|
|
40
|
+
const overallStart = performance.now();
|
|
41
|
+
const results: GateResult[] = await Promise.all(
|
|
42
|
+
gates.map(async (gate): Promise<GateResult> => {
|
|
43
|
+
const started = performance.now();
|
|
44
|
+
let exitCode = 1;
|
|
45
|
+
try {
|
|
46
|
+
// Run the gate through the platform shell: `cmd /c` on Windows (no bash
|
|
47
|
+
// there), `bash -lc` elsewhere. `.cmd` shims (biome/tsc) resolve under both.
|
|
48
|
+
const shellArgv =
|
|
49
|
+
process.platform === "win32"
|
|
50
|
+
? ["cmd", "/d", "/s", "/c", gate.command]
|
|
51
|
+
: ["bash", "-lc", gate.command];
|
|
52
|
+
const proc = Bun.spawn(shellArgv, { cwd, stdout: "pipe", stderr: "pipe" });
|
|
53
|
+
exitCode = await proc.exited;
|
|
54
|
+
} catch {
|
|
55
|
+
exitCode = 1;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
name: gate.name,
|
|
59
|
+
command: gate.command,
|
|
60
|
+
passed: exitCode === 0,
|
|
61
|
+
exitCode,
|
|
62
|
+
durationMs: Math.round(performance.now() - started),
|
|
63
|
+
};
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
const total = Math.round(performance.now() - overallStart);
|
|
68
67
|
|
|
69
68
|
const passed = results.filter((r) => r.passed).length;
|
|
70
69
|
const status: OutcomeStatus =
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for auto-merge (maybeAutoMerge).
|
|
3
|
+
*
|
|
4
|
+
* Real git repos in temp dirs (per "never mock what you can use for real"), the
|
|
5
|
+
* real merge queue + lock, and a recording mail stub so we can assert the outcome
|
|
6
|
+
* notifications without a mail DB. Covers the mode gate, the capability skip, the
|
|
7
|
+
* gates-pass fail-closed rule, a clean landing, and a reported conflict.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { Capability, NewMail } from "../types.ts";
|
|
15
|
+
import { type AutoMergeParams, maybeAutoMerge } from "./auto.ts";
|
|
16
|
+
|
|
17
|
+
let repo: string;
|
|
18
|
+
|
|
19
|
+
async function git(...args: string[]): Promise<string> {
|
|
20
|
+
const proc = Bun.spawn(["git", ...args], { cwd: repo, stdout: "pipe", stderr: "pipe" });
|
|
21
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
22
|
+
new Response(proc.stdout).text(),
|
|
23
|
+
new Response(proc.stderr).text(),
|
|
24
|
+
proc.exited,
|
|
25
|
+
]);
|
|
26
|
+
if (exitCode !== 0) throw new Error(`git ${args.join(" ")} failed (${exitCode}): ${stderr}`);
|
|
27
|
+
return stdout;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Recording mail stub satisfying the `{ send }` surface maybeAutoMerge needs. */
|
|
31
|
+
function recordingMail() {
|
|
32
|
+
const sent: Array<{ type: string; subject: string; to: string }> = [];
|
|
33
|
+
return {
|
|
34
|
+
sent,
|
|
35
|
+
send: (m: NewMail) => {
|
|
36
|
+
sent.push({ type: m.type, subject: m.subject, to: m.to });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build params with sensible defaults; override per test. */
|
|
42
|
+
function params(
|
|
43
|
+
over: Partial<AutoMergeParams> & { mail: ReturnType<typeof recordingMail> },
|
|
44
|
+
): AutoMergeParams {
|
|
45
|
+
return {
|
|
46
|
+
root: repo,
|
|
47
|
+
branchName: "agentplate/builder-x",
|
|
48
|
+
targetBranch: "main",
|
|
49
|
+
capability: "builder" as Capability,
|
|
50
|
+
agentName: "builder-x",
|
|
51
|
+
taskId: "task-x",
|
|
52
|
+
parent: "lead-1",
|
|
53
|
+
mode: "on-complete",
|
|
54
|
+
aiResolveEnabled: true,
|
|
55
|
+
gateStatus: null,
|
|
56
|
+
...over,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
repo = mkdtempSync(join(tmpdir(), "agentplate-automerge-"));
|
|
62
|
+
mkdirSync(join(repo, ".agentplate"), { recursive: true });
|
|
63
|
+
await git("init", "-q");
|
|
64
|
+
await git("config", "user.email", "test@agentplate.dev");
|
|
65
|
+
await git("config", "user.name", "Agentplate Test");
|
|
66
|
+
await git("checkout", "-q", "-b", "main");
|
|
67
|
+
await Bun.write(join(repo, "base.txt"), "base\n");
|
|
68
|
+
await git("add", "-A");
|
|
69
|
+
await git("commit", "-q", "-m", "initial");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
rmSync(repo, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** Create a worker branch with a new-file commit, then return to main. */
|
|
77
|
+
async function workerBranchWithCommit(branch: string, file: string): Promise<void> {
|
|
78
|
+
await git("checkout", "-q", "-b", branch);
|
|
79
|
+
await Bun.write(join(repo, file), "from worker\n");
|
|
80
|
+
await git("add", "-A");
|
|
81
|
+
await git("commit", "-q", "-m", `add ${file}`);
|
|
82
|
+
await git("checkout", "-q", "main");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("maybeAutoMerge — gating", () => {
|
|
86
|
+
test("mode 'off' never merges", async () => {
|
|
87
|
+
const mail = recordingMail();
|
|
88
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
89
|
+
const out = await maybeAutoMerge(params({ mail, mode: "off" }));
|
|
90
|
+
expect(out).toEqual({ merged: false, reason: "disabled" });
|
|
91
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(false);
|
|
92
|
+
expect(mail.sent).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("read-only capabilities are skipped", async () => {
|
|
96
|
+
const mail = recordingMail();
|
|
97
|
+
for (const capability of ["scout", "merger"] as Capability[]) {
|
|
98
|
+
const out = await maybeAutoMerge(params({ mail, capability }));
|
|
99
|
+
expect(out).toEqual({ merged: false, reason: "capability-skipped" });
|
|
100
|
+
}
|
|
101
|
+
expect(mail.sent).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("on-gates-pass holds (fail-closed) when gates did not pass", async () => {
|
|
105
|
+
const mail = recordingMail();
|
|
106
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
107
|
+
for (const gateStatus of [null, "failure", "partial"] as const) {
|
|
108
|
+
const out = await maybeAutoMerge(params({ mail, mode: "on-gates-pass", gateStatus }));
|
|
109
|
+
expect(out).toEqual({ merged: false, reason: "gates-not-passed" });
|
|
110
|
+
}
|
|
111
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(false);
|
|
112
|
+
expect(mail.sent.every((m) => m.type === "status")).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("maybeAutoMerge — landing", () => {
|
|
117
|
+
test("on-complete lands the branch and reports 'merged'", async () => {
|
|
118
|
+
const mail = recordingMail();
|
|
119
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
120
|
+
const out = await maybeAutoMerge(params({ mail, mode: "on-complete" }));
|
|
121
|
+
expect(out).toEqual({ merged: true, status: "merged", tier: "clean-merge" });
|
|
122
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(true); // landed on main
|
|
123
|
+
expect(mail.sent).toHaveLength(1);
|
|
124
|
+
expect(mail.sent[0]).toMatchObject({ type: "merged", to: "lead-1" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("on-gates-pass merges on a clean gate success", async () => {
|
|
128
|
+
const mail = recordingMail();
|
|
129
|
+
await workerBranchWithCommit("agentplate/builder-x", "feature.txt");
|
|
130
|
+
const out = await maybeAutoMerge(
|
|
131
|
+
params({ mail, mode: "on-gates-pass", gateStatus: "success" }),
|
|
132
|
+
);
|
|
133
|
+
expect(out).toMatchObject({ merged: true, status: "merged" });
|
|
134
|
+
expect(existsSync(join(repo, "feature.txt"))).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("an unresolved conflict reports 'merge_failed' (never throws)", async () => {
|
|
138
|
+
const mail = recordingMail();
|
|
139
|
+
// Both branches edit base.txt differently -> conflict; aiResolveEnabled=false
|
|
140
|
+
// makes mergeBranch abort and fail.
|
|
141
|
+
await git("checkout", "-q", "-b", "agentplate/builder-x");
|
|
142
|
+
await Bun.write(join(repo, "base.txt"), "worker change\n");
|
|
143
|
+
await git("add", "-A");
|
|
144
|
+
await git("commit", "-q", "-m", "worker edits base");
|
|
145
|
+
await git("checkout", "-q", "main");
|
|
146
|
+
await Bun.write(join(repo, "base.txt"), "main change\n");
|
|
147
|
+
await git("add", "-A");
|
|
148
|
+
await git("commit", "-q", "-m", "main edits base");
|
|
149
|
+
|
|
150
|
+
const out = await maybeAutoMerge(
|
|
151
|
+
params({ mail, mode: "on-complete", aiResolveEnabled: false }),
|
|
152
|
+
);
|
|
153
|
+
expect(out.merged).toBe(false);
|
|
154
|
+
expect(out).toMatchObject({ reason: "merge-failed" });
|
|
155
|
+
expect(mail.sent[0]).toMatchObject({ type: "merge_failed" });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-merge: land a completed worker's branch onto the canonical branch without
|
|
3
|
+
* an operator running `agentplate merge`. Gated by `config.merge.autoMerge`:
|
|
4
|
+
*
|
|
5
|
+
* off → never (manual merge by the operator/coordinator)
|
|
6
|
+
* on-gates-pass → merge only when the task's quality gates passed cleanly
|
|
7
|
+
* on-complete → merge as soon as the agent finished without error
|
|
8
|
+
*
|
|
9
|
+
* This reuses the exact same path as the manual `merge` command — the merge queue
|
|
10
|
+
* (audit), the cross-process merge lock (so parallel auto-merges serialize), and
|
|
11
|
+
* {@link mergeBranch} (clean-merge / auto-resolve per `aiResolveEnabled`). Outcomes
|
|
12
|
+
* are reported to the parent/coordinator over mail (`merged` / `merge_failed`), so
|
|
13
|
+
* the existing coordination flow still sees every landing and handles conflicts.
|
|
14
|
+
*
|
|
15
|
+
* Pulled out of `sling` into its own unit so it can be tested against a real temp
|
|
16
|
+
* git repo without driving an agent turn.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createMailClient } from "../mail/client.ts";
|
|
20
|
+
import { mergeDbPath } from "../paths.ts";
|
|
21
|
+
import type { AutoMergeMode, Capability, MergeStatus, MergeTier, OutcomeStatus } from "../types.ts";
|
|
22
|
+
import { withMergeLock } from "./lock.ts";
|
|
23
|
+
import { createMergeQueue } from "./queue.ts";
|
|
24
|
+
import { mergeBranch } from "./resolver.ts";
|
|
25
|
+
|
|
26
|
+
/** Capabilities that never produce a branch worth landing on the canonical branch. */
|
|
27
|
+
const NON_MERGING_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>(["scout", "merger"]);
|
|
28
|
+
|
|
29
|
+
export interface AutoMergeParams {
|
|
30
|
+
root: string;
|
|
31
|
+
branchName: string;
|
|
32
|
+
targetBranch: string;
|
|
33
|
+
capability: Capability;
|
|
34
|
+
agentName: string;
|
|
35
|
+
taskId: string;
|
|
36
|
+
/** Who to notify of the outcome (falls back to the coordinator). */
|
|
37
|
+
parent: string | null;
|
|
38
|
+
mode: AutoMergeMode;
|
|
39
|
+
/** Conflict strategy passed through to `mergeBranch`. */
|
|
40
|
+
aiResolveEnabled: boolean;
|
|
41
|
+
/** The task's quality-gate outcome, or null if gates did not run. */
|
|
42
|
+
gateStatus: OutcomeStatus | null;
|
|
43
|
+
/** Mail surface for reporting the outcome (injectable for tests). */
|
|
44
|
+
mail?: { send: (m: Parameters<ReturnType<typeof createMailClient>["send"]>[0]) => unknown };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type AutoMergeOutcome =
|
|
48
|
+
| { merged: true; status: MergeStatus; tier: MergeTier | null }
|
|
49
|
+
| {
|
|
50
|
+
merged: false;
|
|
51
|
+
reason: "disabled" | "capability-skipped" | "gates-not-passed" | "merge-failed";
|
|
52
|
+
conflictFiles?: string[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decide whether to auto-merge `branchName` and, if so, do it under the merge lock.
|
|
57
|
+
* Never throws on a merge conflict — it reports `merge_failed` and returns a
|
|
58
|
+
* non-merged outcome so the spawn that called it is never failed by a landing.
|
|
59
|
+
*/
|
|
60
|
+
export async function maybeAutoMerge(params: AutoMergeParams): Promise<AutoMergeOutcome> {
|
|
61
|
+
const mail = params.mail ?? createMailClient(params.root);
|
|
62
|
+
const to = params.parent ?? "coordinator";
|
|
63
|
+
|
|
64
|
+
if (params.mode === "off") return { merged: false, reason: "disabled" };
|
|
65
|
+
if (NON_MERGING_CAPABILITIES.has(params.capability)) {
|
|
66
|
+
return { merged: false, reason: "capability-skipped" };
|
|
67
|
+
}
|
|
68
|
+
// Fail closed: on-gates-pass merges ONLY on a clean pass. A null status (gates
|
|
69
|
+
// not configured / not run) or any non-success holds the merge for a human.
|
|
70
|
+
if (params.mode === "on-gates-pass" && params.gateStatus !== "success") {
|
|
71
|
+
mail.send({
|
|
72
|
+
from: params.agentName,
|
|
73
|
+
to,
|
|
74
|
+
subject: `Auto-merge held: ${params.branchName}`,
|
|
75
|
+
body: `Quality gates did not pass (status: ${params.gateStatus ?? "not run"}); not merging. Review and merge manually.`,
|
|
76
|
+
type: "status",
|
|
77
|
+
});
|
|
78
|
+
return { merged: false, reason: "gates-not-passed" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const queue = createMergeQueue(mergeDbPath(params.root));
|
|
82
|
+
try {
|
|
83
|
+
const entry = queue.enqueue({
|
|
84
|
+
branchName: params.branchName,
|
|
85
|
+
agentName: params.agentName,
|
|
86
|
+
taskId: params.taskId,
|
|
87
|
+
targetBranch: params.targetBranch,
|
|
88
|
+
});
|
|
89
|
+
const result = await withMergeLock(params.root, () =>
|
|
90
|
+
mergeBranch(params.root, params.branchName, params.targetBranch, {
|
|
91
|
+
autoResolve: params.aiResolveEnabled,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
queue.markStatus(entry.id, result.status);
|
|
95
|
+
|
|
96
|
+
if (result.status === "merged") {
|
|
97
|
+
mail.send({
|
|
98
|
+
from: params.agentName,
|
|
99
|
+
to,
|
|
100
|
+
subject: `Auto-merged: ${params.branchName} → ${params.targetBranch}`,
|
|
101
|
+
body: `Landed via ${result.tier ?? "merge"}.`,
|
|
102
|
+
type: "merged",
|
|
103
|
+
});
|
|
104
|
+
return { merged: true, status: result.status, tier: result.tier };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
mail.send({
|
|
108
|
+
from: params.agentName,
|
|
109
|
+
to,
|
|
110
|
+
subject: `Auto-merge failed: ${params.branchName}`,
|
|
111
|
+
body: `Conflicts in: ${result.conflictFiles.join(", ") || "unknown"}. Merge manually.`,
|
|
112
|
+
type: "merge_failed",
|
|
113
|
+
});
|
|
114
|
+
return { merged: false, reason: "merge-failed", conflictFiles: result.conflictFiles };
|
|
115
|
+
} finally {
|
|
116
|
+
queue.close();
|
|
117
|
+
}
|
|
118
|
+
}
|