Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,9 +364,20 @@ monotonic, one space per source store):

Emit `{ id, verb, params }`, get exactly one reply `{ id, ok, data }` or
`{ id, ok: false, error: { code, message } }`. Verbs: `spawn` (returns `{ runId }`
immediately; result arrives via `fleet:run:ended`), `status`, `observe` (replay dump —
subscribe to the broadcast channels + dedupe by `(channel, runId, seq)` for the live tail),
`steer`, `abort`.
immediately; result arrives via `fleet:run:ended`), `schedule` (#83 — returns
`{ scheduleId, nextFire }`; schedules run lifecycles, not single delegates), `status`,
`observe` (replay dump — subscribe to the broadcast channels + dedupe by
`(channel, runId, seq)` for the live tail), `steer`, `abort`.

`spawn` params: `agent`, `task`, `background?`, `lifecycle?` (named lifecycle — with
`background: true` routes through the bg runner; without it runs as a detached
foreground-semantics lifecycle), `modelFallback?` (per-request retry-once on a retryable
provider failure — the retry mints a fresh runId and relinks the primary's todo), `cwd?`,
`isolation?`, `maxTurns?`, `model?`, `skills?`, `readOnly?`, `track?`, `todoId?`.

`schedule` params: `task`, `expression` (cron or interval), `lifecycle?`, `auto?`,
`isolation?`, `cwd?` — no `agent`: the scheduler runs lifecycles only. Registration emits
no run events; on fire, the normal `fleet:*` stream flows with `mode: "scheduled"`.

Error codes: `E-CONTROL-DISABLED`, `E-RUN-NOT-FOUND`, `E-RUN-FINISHED`, `E-BAD-VERB`,
`E-BAD-PARAMS`, `E-STEER-UNSUPPORTED`, `E-INTERNAL`.
Expand Down Expand Up @@ -395,7 +406,7 @@ export function fleetRpc(pi: { events: { emit(c: string, d: unknown): void; on(c

### Control gate

`spawn`/`steer`/`abort` are on by default. Set `ARMORY_FLEET_RPC_CONTROL=0` (or `false`) to
`spawn`/`steer`/`abort`/`schedule` are on by default. Set `ARMORY_FLEET_RPC_CONTROL=0` (or `false`) to
reject them with `E-CONTROL-DISABLED`; read-only `observe`/`status` stay available. Honest
threat model: in-process extensions already have full system access through pi itself — the
switch guards accidents, not adversaries.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# SPEC — RPC spawn `lifecycle`/`modelFallback` params + `schedule` verb (#83)

**Date:** 2026-08-30 · **Status:** approved (Option B, RECTOR) · **Parent:** SPEC-6-4 §7 deferred list
**Frozen-surface rule:** additive only — no renames, no reshapes, no new error codes.

## Problem

SPEC-6-4 shipped RPC `spawn` as single-delegate + background routing only. `lifecycle`, `schedule`, and `modelFallback` reject with `E-BAD-PARAMS`. The `subagent` tool supports all three; the RPC surface should reach parity for external consumers.

## Decisions

**D1 — `schedule` is its own verb (RECTOR, Option B).** Not a spawn param. Reasons: the scheduler is lifecycle-based (`ScheduleSpec` = `{ task, expression, lifecycle?, auto?, isolation?, cwd? }` — no `agent` field), so a spawn param would advertise an affordance that doesn't exist; and spawn's reply shape must stay uniform (`{ runId }`) rather than branching on params.

**D2 — spawn `lifecycle` param mirrors the tool's routing:**
- `background: true` (+ optional `lifecycle`): `runBackground({ lifecycle: params.lifecycle ?? "default" })` — replaces today's hardcoded `"default"`.
- `lifecycle` WITHOUT `background`: detached foreground-semantics lifecycle run — `runLifecycle(task, lifecycle, { deps: lifecycleFullDeps, mode: "auto", entryCwd })` with `genRunId: () => runId` (the asyncRunLifecycle pre-minted-id pattern), phase spawn on the SESSION `deps.lock` (foreground pool, like the tool's fg lifecycle), phase cwd resolution unchanged (`lifecycle.cwd ?? entryCwd`).
- `mode` is always `"auto"` over RPC (the tool hardcodes it too; checkpointed lifecycles are interactive-only).

**D3 — `modelFallback` (per-request wins over the global default, exactly like the tool's `params.modelFallback ?? deps.defaultModelFallback`):**
- bg leg: `RunBackgroundOpts.modelFallback` → `RunLifecycleOpts.modelFallback` → `asyncRunLifecycle`'s `withModelFallbackRetry(fn, opts.modelFallback ?? deps.defaultModelFallback)`.
- fg single-delegate leg: retry ONCE inline, mirroring the tool's direct path — fresh runId for the retry (the primary already journaled under the pre-minted id; reusing it would double-emit `run:started`), `todoId` relinked from the primary. Extracted as `retryForegroundOnce(primary, fallback, spawn)` (testable).
- fg lifecycle leg: `withModelFallbackRetry` on the phase spawn (same as the tool + asyncRunLifecycle).

**D4 — schedule verb contract:**
- Request: `{ id, verb: "schedule", params: { task, expression, lifecycle?, auto?, isolation?, cwd? } }`.
- Reply: `{ ok: true, data: { scheduleId, nextFire: <ISO string | null> } }`.
- Behind the same `ARMORY_FLEET_RPC_CONTROL` gate (it is a control operation). Scheduler not configured → `E-BAD-PARAMS` (actionable "scheduler missing" message, mirroring the bg message style). `scheduler.register` throws (invalid expression) → `E-BAD-PARAMS` with the parser's message.
- No new verbs for pause/list/abort of schedules — out of scope (#83 tracks registration only).
- No run events at registration (nothing is running); on fire, the existing scheduler → `runBackground` path emits the normal `fleet:*` stream with `mode: "scheduled"` (pre-existing SPEC-5a behavior).

**D5 — validation:** `lifecycle`/`modelFallback`/`expression`/`task` must be non-empty strings when set; `auto` boolean; `isolation` enum (`'worktree' | 'none' | 'auto'`); `cwd` resolves through the same `resolveDispatchCwd` preflight as spawn. All failures → `E-BAD-PARAMS`, validation BEFORE runId minting (no ghost runIds).

## Non-goals

Per-dispatch `thinkingLevel` tool param (follow-up to #78); schedule pause/list/abort verbs; `agent` on `ScheduleSpec`; external transports (SPEC-6-4 §7).
14 changes: 14 additions & 0 deletions src/engine/retry-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,17 @@ export function withModelFallbackRetry(spawn: SpawnFn, fallback: string | undefi
return first;
};
}

/** #83 D3: the RPC foreground-semantics detached spawn's retry — mirrors the tool's direct-path
* contract (retry ONCE, retryable failures only, DISTINCT fallback model) but the retry runs
* WITHOUT the pre-minted runId: the primary already journaled run:started/ended under it, so
* the retry must mint a fresh id (a reused id would double-emit run:started). `todoId` links
* the retry to the primary's armory-todo task (same relink contract as the tool). */
export async function retryForegroundOnce(
primary: SpawnResult,
fallback: string | undefined,
spawn: (o: { model: string; todoId?: string }) => Promise<SpawnResult>,
): Promise<SpawnResult> {
if (!fallback || primary.status !== "failed" || !primary.retryable || fallback === primary.model) return primary;
return spawn({ model: fallback, todoId: primary.todoId ?? undefined });
}
84 changes: 80 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
getAgentDir,
} from "@earendil-works/pi-coding-agent";
import type { Model } from "@earendil-works/pi-ai";
import { createSubagentTool, resolveDispatchCwd, type SubagentToolDeps } from "./tools/subagent.ts";
import { createSubagentTool, mergeLifecycleSkills, resolveDispatchCwd, type SubagentToolDeps } from "./tools/subagent.ts";
// SPEC-6-3: /fleet uses openWorkflowPanelLoop (Task 12) instead of the raw openFleetPanel factory.
import { discoverAgents } from "./registry/discovery.ts";
import { RunRegistry } from "./engine/run-registry.ts";
Expand Down Expand Up @@ -306,7 +306,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
cwd: isolated ? opts.worktreePath : o.cwd,
runLog: deps.runLog, tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
defaultThinkingLevel: deps.defaultSubagentThinking,
}), deps.defaultModelFallback),
}), opts.modelFallback ?? deps.defaultModelFallback),
};
const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", entryCwd: opts.entryCwd, onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
return res as unknown as import("./runtime/async-runner.ts").FakeLifecycleResult;
Expand Down Expand Up @@ -431,33 +431,84 @@ export default async function (pi: ExtensionAPI): Promise<void> {
journal: fleetJournal,
parentCwd: ctx.cwd,
hasAsyncRunner: true,
// #83 D4: schedule registration — scheduler.register + nextFire lookup. Throws (invalid
// expression) propagate to the verb, which maps them to E-BAD-PARAMS.
schedule: (spec) => {
if (!deps.scheduler) throw new Error("scheduling not configured in this session (scheduler missing)");
const id = deps.scheduler.register({
task: String(spec.task),
expression: String(spec.expression),
...(typeof spec.lifecycle === "string" ? { lifecycle: spec.lifecycle } : {}),
...(typeof spec.auto === "boolean" ? { auto: spec.auto } : {}),
...(spec.isolation === "worktree" || spec.isolation === "none" || spec.isolation === "auto" ? { isolation: spec.isolation } : {}),
...(typeof spec.cwd === "string" ? { cwd: spec.cwd } : {}),
});
const entry = deps.scheduler.list().find((s) => s.id === id);
return { scheduleId: id, nextFire: entry?.nextFire?.toISOString() ?? null };
},
spawn: (params: Record<string, unknown>, runId: string) => {
// Detached fire-and-forget: NEVER throws — spawnSubagent journals its own fail path
// (run:ended + todo revert), so the caller's pre-minted runId always resolves to events.
void (async () => {
const requestedCwd = typeof params.cwd === "string" ? params.cwd : undefined;
const { cwd: resolvedCwd } = resolveDispatchCwd(requestedCwd, ctx.cwd);
// #83: lifecycle + per-request modelFallback params (validated by the verb; re-narrowed here).
const rpcLifecycle = typeof params.lifecycle === "string" && params.lifecycle ? params.lifecycle : "default";
const rpcFallback = typeof params.modelFallback === "string" && params.modelFallback ? params.modelFallback : undefined;
if (params.background === true) {
// RPC background: routed through the async runner (pool slot + isolation + origin),
// identical to the tool's runBackground path (spec §3.2). Ghost-runId prevention:
// a synchronous pre-flight failure journals run:ended failed under the caller's id.
const handle = runBackground(String(params.task), {
deps: deps.asyncRunner!,
lifecycle: "default",
lifecycle: rpcLifecycle,
mode: "auto",
isolation: params.isolation as "worktree" | "none" | "auto" | undefined,
cwd: resolvedCwd ?? ctx.cwd,
origin: "background",
runId,
...(rpcFallback ? { modelFallback: rpcFallback } : {}),
});
if (handle.status === "failed") {
deps.runLog?.append(runId, { type: "run:ended", runId, status: "failed", endedAt: Date.now(), tokenTotal: 0, error: handle.error });
}
return;
}
if (params.lifecycle !== undefined) {
// #83 D2: lifecycle WITHOUT background = detached foreground-semantics lifecycle run
// (tool parity: mode "auto", failed-phase checkpoint aborts, session lock on the phase
// spawn, pre-minted runId via the genRunId override — same pattern as asyncRunLifecycle).
const { runLifecycle } = await import("./lifecycle/run-lifecycle.ts");
const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
const { withModelFallbackRetry } = await import("./engine/retry-fallback.ts");
const lifecycleFullDeps = {
...deps.lifecycleDeps,
genRunId: () => runId,
spawn: withModelFallbackRetry(async (o) => spawnSubagent({
agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
skillsOverride: mergeLifecycleSkills(o.skills, Array.isArray(params.skills) ? params.skills as string[] : undefined),
backendOverride: o.backend,
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: ctx.cwd,
runLog: deps.runLog, signal: undefined,
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
defaultThinkingLevel: deps.defaultSubagentThinking,
maxTurns: typeof params.maxTurns === "number" ? params.maxTurns : undefined,
readOnly: params.readOnly === true,
cwd: o.cwd ?? resolvedCwd,
}), rpcFallback ?? deps.defaultModelFallback),
};
const res = await runLifecycle(String(params.task), rpcLifecycle, {
deps: lifecycleFullDeps, mode: "auto", entryCwd: resolvedCwd,
onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" },
});
deps.lifecycleRuns.set(res.runId, res); // panel Lifecycle-view visibility (workflow-lambda precedent)
return;
}
// Foreground-semantics detached spawn (no mode — "foreground" is the truth).
const { spawnSubagent } = await import("./engine/spawnSubagent.ts");
await spawnSubagent({
const { retryForegroundOnce } = await import("./engine/retry-fallback.ts");
const primary = await spawnSubagent({
agent: params.agent as string,
task: params.task as string,
todoId: typeof params.todoId === "string" ? params.todoId : undefined,
Expand All @@ -480,6 +531,31 @@ export default async function (pi: ExtensionAPI): Promise<void> {
runId,
defaultThinkingLevel: deps.defaultSubagentThinking,
});
// #83 D3: per-request fallback retry — the retry mints a FRESH runId (the primary's
// pre-minted id stays retired; a reuse would double-emit run:started) and relinks the
// primary's todo, exactly like the tool's direct path.
await retryForegroundOnce(primary, rpcFallback, (o) => spawnSubagent({
agent: params.agent as string,
task: params.task as string,
todoId: o.todoId,
track: params.track === true,
model: o.model,
readOnly: params.readOnly === true,
skillsOverride: Array.isArray(params.skills) ? params.skills as string[] : undefined,
registry: deps.registry,
todoSync: deps.todoSync,
runRegistry: deps.runRegistry,
lock: deps.lock,
backendRegistry: deps.backendRegistry,
parentModel: deps.parentModel,
parentCwd: ctx.cwd,
runLog: deps.runLog,
maxTurns: typeof params.maxTurns === "number" ? params.maxTurns : undefined,
tierRegistry: deps.tierRegistry,
modelRegistry: deps.modelRegistry,
cwd: resolvedCwd ?? ctx.cwd,
defaultThinkingLevel: deps.defaultSubagentThinking,
}));
})().catch(() => {});
},
});
Expand Down
Loading
Loading