@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
|
@@ -23,6 +23,8 @@ export interface RunTurnOptions {
|
|
|
23
23
|
env?: Record<string, string>;
|
|
24
24
|
/** Prior runtime session id, to resume across turns. */
|
|
25
25
|
resumeSessionId?: string;
|
|
26
|
+
/** Hard wall-clock cap in ms; the child is killed past it. 0/undefined = none. */
|
|
27
|
+
timeoutMs?: number;
|
|
26
28
|
/** Called for each parsed event (e.g. to record tool calls). */
|
|
27
29
|
onEvent?: (event: AgentEvent) => void;
|
|
28
30
|
}
|
|
@@ -33,6 +35,8 @@ export interface TurnResult {
|
|
|
33
35
|
runtimeSessionId: string | null;
|
|
34
36
|
/** Captured stderr (already bounded by the child). */
|
|
35
37
|
stderr: string;
|
|
38
|
+
/** True if the turn was killed by the wall-clock cap. */
|
|
39
|
+
timedOut: boolean;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/** Run a single headless turn and resolve when the child process exits. */
|
|
@@ -59,6 +63,18 @@ export async function runTurn(opts: RunTurnOptions): Promise<TurnResult> {
|
|
|
59
63
|
stdin: "ignore",
|
|
60
64
|
});
|
|
61
65
|
|
|
66
|
+
// Hard wall-clock cap: kill a turn that runs past the limit even if it keeps
|
|
67
|
+
// streaming (idle reaping only catches inactivity). Closing the child's pipes
|
|
68
|
+
// ends the drain/parse loops below, so the turn resolves with a non-zero exit.
|
|
69
|
+
let timedOut = false;
|
|
70
|
+
const timer =
|
|
71
|
+
opts.timeoutMs && opts.timeoutMs > 0
|
|
72
|
+
? setTimeout(() => {
|
|
73
|
+
timedOut = true;
|
|
74
|
+
proc.kill(); // SIGTERM
|
|
75
|
+
}, opts.timeoutMs)
|
|
76
|
+
: null;
|
|
77
|
+
|
|
62
78
|
// Read stderr concurrently so a full pipe buffer can't deadlock the child.
|
|
63
79
|
const stderrPromise = new Response(proc.stderr).text();
|
|
64
80
|
|
|
@@ -75,5 +91,6 @@ export async function runTurn(opts: RunTurnOptions): Promise<TurnResult> {
|
|
|
75
91
|
|
|
76
92
|
const stderr = await stderrPromise;
|
|
77
93
|
const exitCode = await proc.exited;
|
|
78
|
-
|
|
94
|
+
if (timer) clearTimeout(timer);
|
|
95
|
+
return { exitCode, runtimeSessionId, stderr, timedOut };
|
|
79
96
|
}
|
package/src/commands/sling.ts
CHANGED
|
@@ -6,36 +6,35 @@
|
|
|
6
6
|
* the agent identity + session row → dispatch the task over mail → run the first
|
|
7
7
|
* headless turn → observe the agent's terminal mail to transition the session.
|
|
8
8
|
*
|
|
9
|
-
* Headless spawn-per-turn: this runs the FIRST turn
|
|
10
|
-
*
|
|
9
|
+
* Headless spawn-per-turn: this runs the FIRST turn (a fresh runtime session).
|
|
10
|
+
* Follow-up turns are run by `agentplate turn <agent>`, which resumes the same
|
|
11
|
+
* session (warm start). Both share the {@link driveTurn} core.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
15
|
import { Command } from "commander";
|
|
15
|
-
import {
|
|
16
|
+
import { assertCapacity } from "../agents/capacity.ts";
|
|
17
|
+
import { driveTurn } from "../agents/drive.ts";
|
|
18
|
+
import { createIdentity } from "../agents/identity.ts";
|
|
16
19
|
import { buildDefaultManifest, getDefinition, loadManifest } from "../agents/manifest.ts";
|
|
17
20
|
import { writeOverlay } from "../agents/overlay.ts";
|
|
18
|
-
import { runTurn } from "../agents/turn-runner.ts";
|
|
19
21
|
import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
|
|
20
22
|
import { ValidationError } from "../errors.ts";
|
|
21
23
|
import { createEventStore } from "../events/store.ts";
|
|
22
|
-
import { runQualityGates } from "../insights/quality-gates.ts";
|
|
23
24
|
import { jsonOutput } from "../json.ts";
|
|
24
25
|
import { brand, muted, printHint, printInfo, printSuccess } from "../logging/color.ts";
|
|
25
26
|
import { createMailClient } from "../mail/client.ts";
|
|
26
|
-
import { createMailStore } from "../mail/store.ts";
|
|
27
27
|
import {
|
|
28
28
|
currentRunPath,
|
|
29
29
|
eventsDbPath,
|
|
30
|
-
mailDbPath,
|
|
31
30
|
manifestFilePath,
|
|
32
31
|
packageAgentDefPath,
|
|
33
32
|
sessionsDbPath,
|
|
34
33
|
} from "../paths.ts";
|
|
35
|
-
import { getRuntime } from "../runtimes/registry.ts";
|
|
34
|
+
import { getRuntime, runtimeNameForCapability } from "../runtimes/registry.ts";
|
|
36
35
|
import { resolveModel } from "../runtimes/resolve.ts";
|
|
37
36
|
import { createSessionStore } from "../sessions/store.ts";
|
|
38
|
-
import { retrieveSkillsForSpawn
|
|
37
|
+
import { retrieveSkillsForSpawn } from "../skills/lifecycle.ts";
|
|
39
38
|
import type { AgentManifest, AgentSession, Capability, OverlayConfig } from "../types.ts";
|
|
40
39
|
import { SUPPORTED_CAPABILITIES } from "../types.ts";
|
|
41
40
|
import { createWorktree } from "../worktree/manager.ts";
|
|
@@ -130,6 +129,17 @@ export function createSlingCommand(): Command {
|
|
|
130
129
|
// Resolve the run this agent belongs to.
|
|
131
130
|
const runId = resolveRun(store, root, opts);
|
|
132
131
|
|
|
132
|
+
// Enforce orchestration capacity BEFORE any worktree/session is created,
|
|
133
|
+
// so a runaway fan-out is refused cleanly instead of spawning unbounded.
|
|
134
|
+
const parentAgent = opts.parent ?? null;
|
|
135
|
+
assertCapacity({
|
|
136
|
+
depth: Number(opts.depth ?? "0"),
|
|
137
|
+
active: store.countActive(runId),
|
|
138
|
+
parentAgent,
|
|
139
|
+
parentActiveChildren: parentAgent ? store.countActiveByParent(parentAgent, runId) : 0,
|
|
140
|
+
limits: config.agents,
|
|
141
|
+
});
|
|
142
|
+
|
|
133
143
|
const name = uniqueName(store, opts.name ?? `${capability}-${taskId}`);
|
|
134
144
|
const branchName = `agentplate/${name}`;
|
|
135
145
|
|
|
@@ -149,7 +159,18 @@ export function createSlingCommand(): Command {
|
|
|
149
159
|
});
|
|
150
160
|
|
|
151
161
|
// 3. Overlay (base definition + assignment + skills) → instruction file.
|
|
152
|
-
const runtime = getRuntime(
|
|
162
|
+
const runtime = getRuntime(
|
|
163
|
+
runtimeNameForCapability(config.runtime, capability, opts.runtime),
|
|
164
|
+
config.runtime.default,
|
|
165
|
+
);
|
|
166
|
+
// Surface project skip-defaults as constraints the spawning agent obeys.
|
|
167
|
+
const skipDirectives: string[] = [];
|
|
168
|
+
if (def.canSpawn && config.agents.skipScout) {
|
|
169
|
+
skipDirectives.push("Skip the scout step — dispatch builders directly (--skip-scout).");
|
|
170
|
+
}
|
|
171
|
+
if (def.canSpawn && config.agents.skipReview) {
|
|
172
|
+
skipDirectives.push("Skip the reviewer step before integrating (--skip-review).");
|
|
173
|
+
}
|
|
153
174
|
const overlayConfig: OverlayConfig = {
|
|
154
175
|
agentName: name,
|
|
155
176
|
capability,
|
|
@@ -163,7 +184,7 @@ export function createSlingCommand(): Command {
|
|
|
163
184
|
baseDefinition: readBaseDefinition(root, def.file),
|
|
164
185
|
canSpawn: def.canSpawn,
|
|
165
186
|
qualityGates: config.project.qualityGates ?? [],
|
|
166
|
-
constraints: def.constraints,
|
|
187
|
+
constraints: [...def.constraints, ...skipDirectives],
|
|
167
188
|
siblings: opts.siblings ? opts.siblings.split(",").map((s) => s.trim()) : undefined,
|
|
168
189
|
skillsOverlay: skillsOverlay || undefined,
|
|
169
190
|
};
|
|
@@ -199,91 +220,22 @@ export function createSlingCommand(): Command {
|
|
|
199
220
|
type: "dispatch",
|
|
200
221
|
});
|
|
201
222
|
|
|
202
|
-
// 5. Run the first
|
|
203
|
-
|
|
204
|
-
|
|
223
|
+
// 5. Run the first turn. Follow-up turns warm-start (resume) via
|
|
224
|
+
// `agentplate turn`; driveTurn owns state + skills + auto-merge.
|
|
225
|
+
const resolved = resolveModel(config, root, def.model, capability);
|
|
205
226
|
const prompt = buildInitialPrompt(mail.checkInject(name), runtime.instructionPath);
|
|
206
|
-
|
|
207
|
-
|
|
227
|
+
const { finalState, exitCode } = await driveTurn({
|
|
228
|
+
root,
|
|
229
|
+
config,
|
|
208
230
|
runtime,
|
|
209
|
-
|
|
210
|
-
|
|
231
|
+
store,
|
|
232
|
+
events,
|
|
233
|
+
mail,
|
|
234
|
+
session,
|
|
235
|
+
model: resolved,
|
|
211
236
|
prompt,
|
|
212
|
-
env: resolved.env,
|
|
213
|
-
onEvent: (event) => {
|
|
214
|
-
if (event.error || event.type === "error") sawError = true;
|
|
215
|
-
// Prefer the error message (so a failed agent's reason is visible in
|
|
216
|
-
// the feed/logs), else the token/cost JSON the Costs page aggregates.
|
|
217
|
-
const detail = event.error
|
|
218
|
-
? event.error
|
|
219
|
-
: event.usage
|
|
220
|
-
? JSON.stringify({ tokens: event.usage.tokens, cost: event.usage.costUsd })
|
|
221
|
-
: null;
|
|
222
|
-
events.record({
|
|
223
|
-
agentName: name,
|
|
224
|
-
runId,
|
|
225
|
-
type: event.type,
|
|
226
|
-
tool: event.tool ?? null,
|
|
227
|
-
detail,
|
|
228
|
-
});
|
|
229
|
-
// Bump last_activity on every streamed event so a long but active
|
|
230
|
-
// turn keeps itself fresh and is never reaped as "idle".
|
|
231
|
-
store.touch(session.id);
|
|
232
|
-
},
|
|
233
|
-
});
|
|
234
|
-
if (turn.runtimeSessionId) store.setRuntimeSessionId(session.id, turn.runtimeSessionId);
|
|
235
|
-
|
|
236
|
-
// A non-zero exit with no error event means the runtime failed via stderr
|
|
237
|
-
// (e.g. Pi's "No API key found for anthropic"). Record that stderr so the
|
|
238
|
-
// failure reason is visible in the feed/logs instead of a blank "failed".
|
|
239
|
-
if (turn.exitCode !== 0 && !sawError) {
|
|
240
|
-
const reason = turn.stderr.trim();
|
|
241
|
-
if (reason) {
|
|
242
|
-
events.record({
|
|
243
|
-
agentName: name,
|
|
244
|
-
runId,
|
|
245
|
-
type: "error",
|
|
246
|
-
tool: null,
|
|
247
|
-
detail: reason.length > 1000 ? `${reason.slice(0, 1000)}…` : reason,
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// 6. Observe terminal mail to transition the session.
|
|
253
|
-
const finalState = resolveFinalState(root, name, capability, turn.exitCode);
|
|
254
|
-
store.updateSessionState(session.id, finalState);
|
|
255
|
-
store.touch(session.id);
|
|
256
|
-
updateIdentity(root, name, {
|
|
257
|
-
taskId,
|
|
258
|
-
summary: `${capability} ran a turn for ${taskId} → ${finalState}`,
|
|
259
237
|
});
|
|
260
238
|
|
|
261
|
-
// 7. Self-improving loop: score quality gates, append outcomes to
|
|
262
|
-
// applied skills, and distill a skill when the work passed. Only
|
|
263
|
-
// runs for a completed task; best-effort (never fails the spawn).
|
|
264
|
-
if (finalState === "completed" && config.skills.enabled) {
|
|
265
|
-
try {
|
|
266
|
-
const gateOutcome = await runQualityGates(
|
|
267
|
-
config.project.qualityGates ?? [],
|
|
268
|
-
worktree.path,
|
|
269
|
-
);
|
|
270
|
-
await runSkillFeedbackAndDistill({
|
|
271
|
-
root,
|
|
272
|
-
agentName: name,
|
|
273
|
-
capability,
|
|
274
|
-
taskId,
|
|
275
|
-
worktreePath: worktree.path,
|
|
276
|
-
baseRef: config.project.canonicalBranch,
|
|
277
|
-
runtime,
|
|
278
|
-
outcomeStatus: gateOutcome?.status ?? null,
|
|
279
|
-
skills: config.skills,
|
|
280
|
-
model: resolved.model,
|
|
281
|
-
});
|
|
282
|
-
} catch {
|
|
283
|
-
// Skill loop is advisory; a failure here must not fail the spawn.
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
239
|
if (useJson) {
|
|
288
240
|
jsonOutput({
|
|
289
241
|
agent: name,
|
|
@@ -293,7 +245,7 @@ export function createSlingCommand(): Command {
|
|
|
293
245
|
branchName,
|
|
294
246
|
worktreePath: worktree.path,
|
|
295
247
|
state: finalState,
|
|
296
|
-
exitCode:
|
|
248
|
+
exitCode: exitCode,
|
|
297
249
|
});
|
|
298
250
|
return;
|
|
299
251
|
}
|
|
@@ -301,8 +253,8 @@ export function createSlingCommand(): Command {
|
|
|
301
253
|
printInfo(` task: ${taskId}`);
|
|
302
254
|
printInfo(` branch: ${branchName}`);
|
|
303
255
|
printInfo(` worktree:${muted(` ${worktree.path}`)}`);
|
|
304
|
-
if (
|
|
305
|
-
printHint(` turn exited ${
|
|
256
|
+
if (exitCode !== 0) {
|
|
257
|
+
printHint(` turn exited ${exitCode}; see \`agentplate mail list --from ${name}\``);
|
|
306
258
|
}
|
|
307
259
|
} finally {
|
|
308
260
|
events.close();
|
|
@@ -378,26 +330,3 @@ function buildInitialPrompt(injected: string, instructionPath: string): string {
|
|
|
378
330
|
const header = `Read your instructions at ${instructionPath}, then begin your task.`;
|
|
379
331
|
return injected ? `${injected}\n\n${header}` : header;
|
|
380
332
|
}
|
|
381
|
-
|
|
382
|
-
/** Terminal mail types that mark a capability's work complete. */
|
|
383
|
-
function terminalTypesFor(capability: Capability): string[] {
|
|
384
|
-
return capability === "merger" ? ["merged", "merge_failed"] : ["worker_done"];
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function resolveFinalState(
|
|
388
|
-
root: string,
|
|
389
|
-
name: string,
|
|
390
|
-
capability: Capability,
|
|
391
|
-
exitCode: number,
|
|
392
|
-
): AgentSession["state"] {
|
|
393
|
-
const terminal = terminalTypesFor(capability);
|
|
394
|
-
const store = createMailStore(mailDbPath(root));
|
|
395
|
-
try {
|
|
396
|
-
const sent = store.list({ from: name });
|
|
397
|
-
if (sent.some((m) => terminal.includes(m.type))) return "completed";
|
|
398
|
-
} finally {
|
|
399
|
-
store.close();
|
|
400
|
-
}
|
|
401
|
-
if (exitCode === 0) return "idle";
|
|
402
|
-
return "failed";
|
|
403
|
-
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate turn` command tests. Real initialized temp project + a real (mock)
|
|
3
|
+
* runtime turn. Drives the exported command's action via parseAsync.
|
|
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 { NotFoundError, ValidationError } from "../errors.ts";
|
|
18
|
+
import { sessionsDbPath } from "../paths.ts";
|
|
19
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
20
|
+
import type { AgentSession, SessionState } from "../types.ts";
|
|
21
|
+
import { createTurnCommand } from "./turn.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 sessionState(agent: string): SessionState | undefined {
|
|
53
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
54
|
+
try {
|
|
55
|
+
return store.getSessionByAgent(agent)?.state;
|
|
56
|
+
} finally {
|
|
57
|
+
store.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runTurnCmd(agent: string): Promise<void> {
|
|
62
|
+
await createTurnCommand().parseAsync([agent], { from: "user" });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
root = mkdtempSync(join(tmpdir(), "agentplate-turn-cmd-"));
|
|
67
|
+
worktree = mkdtempSync(join(tmpdir(), "agentplate-turn-wt-"));
|
|
68
|
+
mkdirSync(join(root, AGENTPLATE_DIR), { recursive: true });
|
|
69
|
+
const config = structuredClone(DEFAULT_CONFIG);
|
|
70
|
+
config.runtime.default = "mock";
|
|
71
|
+
writeFileSync(join(root, AGENTPLATE_DIR, CONFIG_FILE), serializeConfig(config), "utf8");
|
|
72
|
+
setProjectRootOverride(root);
|
|
73
|
+
process.env.AGENTPLATE_MOCK_CMD = "true";
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
setProjectRootOverride(null);
|
|
78
|
+
rmSync(root, { recursive: true, force: true });
|
|
79
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
80
|
+
process.env.AGENTPLATE_MOCK_CMD = undefined;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("agentplate turn — refusals", () => {
|
|
84
|
+
test("throws NotFoundError for an unknown agent", async () => {
|
|
85
|
+
await expect(runTurnCmd("ghost")).rejects.toBeInstanceOf(NotFoundError);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("refuses a terminal (completed) agent", async () => {
|
|
89
|
+
seedSession({ state: "completed" });
|
|
90
|
+
await expect(runTurnCmd("builder-1")).rejects.toBeInstanceOf(ValidationError);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("agentplate turn — runs the next turn", () => {
|
|
95
|
+
test("an idle agent takes another turn and transitions", async () => {
|
|
96
|
+
seedSession({ state: "idle" });
|
|
97
|
+
await runTurnCmd("builder-1");
|
|
98
|
+
// No terminal mail from the mock no-op → stays idle (ran without throwing).
|
|
99
|
+
expect(sessionState("builder-1")).toBe("idle");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentplate turn <agent>` — run the NEXT headless turn for an existing agent.
|
|
3
|
+
*
|
|
4
|
+
* Where `sling` opens a fresh runtime session (turn 1), `turn` **resumes** it: it
|
|
5
|
+
* passes the session's captured `runtimeSessionId` to the runtime's `--resume`, so
|
|
6
|
+
* follow-up turns keep the warm context and skip the cold-start cost. The agent's
|
|
7
|
+
* unread mail is injected as the turn's prompt; the shared {@link driveTurn} core
|
|
8
|
+
* handles the state transition, skills loop, and auto-merge identically to turn 1.
|
|
9
|
+
*
|
|
10
|
+
* This is the multi-turn primitive: a coordinator/lead (or a future watcher) calls
|
|
11
|
+
* it when new mail arrives for an `idle` agent. Spawn-per-turn is preserved — each
|
|
12
|
+
* call is one fresh, resumed runtime subprocess.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Command } from "commander";
|
|
16
|
+
import { driveAgentTurn } from "../agents/drive.ts";
|
|
17
|
+
import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
|
|
18
|
+
import { NotFoundError, ValidationError } from "../errors.ts";
|
|
19
|
+
import { createEventStore } from "../events/store.ts";
|
|
20
|
+
import { jsonOutput } from "../json.ts";
|
|
21
|
+
import { brand, muted, printInfo, printSuccess } from "../logging/color.ts";
|
|
22
|
+
import { createMailClient } from "../mail/client.ts";
|
|
23
|
+
import { eventsDbPath, sessionsDbPath } from "../paths.ts";
|
|
24
|
+
import { createSessionStore } from "../sessions/store.ts";
|
|
25
|
+
import type { SessionState } from "../types.ts";
|
|
26
|
+
|
|
27
|
+
interface TurnOptions {
|
|
28
|
+
json?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** States a turn can be driven from: paused/awaiting mail (not terminal, not mid-turn). */
|
|
32
|
+
const DRIVABLE_STATES: ReadonlySet<SessionState> = new Set<SessionState>(["idle", "booting"]);
|
|
33
|
+
|
|
34
|
+
export function createTurnCommand(): Command {
|
|
35
|
+
return new Command("turn")
|
|
36
|
+
.description("Run the next (resumed) turn for an existing agent")
|
|
37
|
+
.argument("<agent>", "agent name")
|
|
38
|
+
.option("--json", "output JSON")
|
|
39
|
+
.action(async (agent: string, opts: TurnOptions, command: Command) => {
|
|
40
|
+
const useJson = command.optsWithGlobals().json === true;
|
|
41
|
+
const root = findProjectRoot();
|
|
42
|
+
if (!isInitialized(root)) {
|
|
43
|
+
throw new ValidationError("Not initialized. Run `agentplate setup` first.");
|
|
44
|
+
}
|
|
45
|
+
const config = loadConfig(root);
|
|
46
|
+
|
|
47
|
+
const store = createSessionStore(sessionsDbPath(root));
|
|
48
|
+
const mail = createMailClient(root);
|
|
49
|
+
const events = createEventStore(eventsDbPath(root));
|
|
50
|
+
try {
|
|
51
|
+
const session = store.getSessionByAgent(agent);
|
|
52
|
+
if (!session) throw new NotFoundError(`No agent named "${agent}".`);
|
|
53
|
+
if (!DRIVABLE_STATES.has(session.state)) {
|
|
54
|
+
throw new ValidationError(
|
|
55
|
+
`Agent "${agent}" is ${session.state}; only an idle agent can take another turn.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { finalState, exitCode } = await driveAgentTurn({
|
|
60
|
+
root,
|
|
61
|
+
config,
|
|
62
|
+
session,
|
|
63
|
+
store,
|
|
64
|
+
events,
|
|
65
|
+
mail,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (useJson) {
|
|
69
|
+
jsonOutput({
|
|
70
|
+
agent,
|
|
71
|
+
capability: session.capability,
|
|
72
|
+
taskId: session.taskId,
|
|
73
|
+
state: finalState,
|
|
74
|
+
resumed: Boolean(session.runtimeSessionId),
|
|
75
|
+
exitCode,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
printSuccess(`${brand(agent)} [${session.capability}] → ${finalState}`);
|
|
80
|
+
printInfo(` resumed: ${session.runtimeSessionId ? "yes (warm)" : "no (cold)"}`);
|
|
81
|
+
printInfo(` worktree:${muted(` ${session.worktreePath}`)}`);
|
|
82
|
+
} finally {
|
|
83
|
+
events.close();
|
|
84
|
+
mail.close();
|
|
85
|
+
store.close();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -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
|
+
});
|