Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fd68207
docs(spec-5a): operational runtime spec (async/bg + scheduling + work…
rz1989s Jul 24, 2026
b0227b4
docs(spec-5a): implementation plan (15 TDD tasks — worktree + async/b…
rz1989s Jul 24, 2026
a478876
feat(spec-5a): WorktreeService — git worktree create/remove/exists
rz1989s Jul 24, 2026
3ceb051
feat(spec-5a): DiffService — worktree-diff artifact discovery (tracke…
rz1989s Jul 24, 2026
dbb3093
feat(spec-5a): RunJournal — JSONL append + replay + partial-line skip…
rz1989s Jul 24, 2026
a53dd88
feat(spec-5a): ConcurrencyPool (N-slot semaphore) + ResultsInbox (del…
rz1989s Jul 24, 2026
c610ad4
feat(spec-5a): vendor cron-parser v1.1.1 (MIT, dep-free) + schedule e…
rz1989s Jul 24, 2026
e89556b
feat(spec-5a): PidLock — session-scoped schedule firing ownership + s…
rz1989s Jul 24, 2026
9ca41e2
feat(spec-5a): Scheduler — in-process cron/interval/one-shot firing +…
rz1989s Jul 24, 2026
c0dcf3c
feat(spec-5a): AsyncRunner — bg path (worktree + journal + runLifecyc…
rz1989s Jul 24, 2026
3d929b7
feat(spec-5a): resume — scan non-terminal journals + worktree-existen…
rz1989s Jul 24, 2026
11db286
feat(spec-5a): subagent tool — background + schedule params (async/bg…
rz1989s Jul 24, 2026
dc9323b
feat(spec-5a): fleet.results tool — pull completed bg-run results fro…
rz1989s Jul 24, 2026
17b34a5
feat(spec-5a): /fleet scheduled tab + bg row status icons (▶ ⏸ ✓ ✗ ⏳ …
rz1989s Jul 24, 2026
9ae42a5
feat(spec-5a): index wiring — async runner + scheduler + resume-on-in…
rz1989s Jul 24, 2026
2188a74
feat(spec-5a): end-to-end smoke script + term-driven TUI smoke checklist
rz1989s Jul 24, 2026
441c49d
fix(spec-5a): wire onProgress so bgRuns populates for live /fleet fle…
rz1989s Jul 24, 2026
64653fd
fix(spec-5a): code-review — realize Q3=A (worktree-diff artifact disc…
rz1989s Jul 24, 2026
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
46 changes: 46 additions & 0 deletions docs/SPEC-5a-smoke-checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# SPEC-5a — term-driven TUI smoke checklist

> Run after installing `@getpipher/armory-fleet@0.5.0` into pi (`~/.pi/agent/settings.json` packages), `/reload` pi.

## Setup
- [ ] `~/.pi/agent/settings.json` `packages` includes `npm:@getpipher/armory-fleet@0.5.0` (+ `armory-todo@0.5.4`).
- [ ] `/reload` pi — `[Extensions]` shows `@getpipher/armory-fleet@0.5.0:src` with no load error.
- [ ] Ollama key present in `~/.pi/agent/auth.json` (pi loads automatically — no env var).
- [ ] (Optional) RECTOR re-auths `claude` first to exercise a per-phase `backend: claude` scheduled lifecycle.

## `/fleet` panel — scheduled tab
- [ ] `/fleet` opens → tabs render: `fleet · lifecycle · agents · backends · scheduled`.
- [ ] `tab` to `scheduled` → empty list renders + footer `a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit`.
- [ ] `a:Add` → inline Input: `task>` → type a trivial task → enter → `schedule (cron | interval | one-shot ISO)>` → type `5s` → enter → `lifecycle (blank=default)>` → enter → row appears with `▶ 5s default "…" next: <iso> sch-…`.
- [ ] `i:Info` on the row → schedule detail pane (id, expression, lifecycle, task, paused, nextFire) + `esc:Back`.
- [ ] `p:Pause/resume` on the row → row toggles to `⏸` + `paused`; `p` again → back to `▶`.
- [ ] `d:Delete` on the row → row removed.

## `/fleet` panel — fleet tab bg row status
- [ ] Wait for the `5s` schedule to fire (if not deleted) → a bg run row appears in the `fleet` tab with `▶` + `●<phase> n/5` + `checkpointed` + `pi`.
- [ ] `i:Info` on the bg row → phase timeline (reads the journal).
- [ ] On completion → row becomes `✓` + branch `fleet/fl-…`; `fleet_results()` returns it; a pi notify fires "fleet run … completed".

## `/fleet-schedule` slash
- [ ] `/fleet-schedule <task> 30m --lifecycle default` → prints `scheduled: sch-… · next fire: <iso>`.

## `subagent` tool (agent path)
- [ ] The agent calls `subagent({ agent, task, background: true, lifecycle: "default" })` → returns `{ runId, status: "background" }` immediately (no await).
- [ ] The agent calls `subagent({ agent, task, schedule: "1h" })` → returns `{ scheduleId, nextFire }`.
- [ ] `background + schedule` together → actionable error.
- [ ] `fleet_results({})` → returns ready completed-run summaries; pulling marks delivered.

## Resume
- [ ] Kill pi mid-lifecycle (Ctrl+C while a bg run is at `▶ implement 3/5`).
- [ ] Reopen pi in the same project → notify "1 interrupted fleet run — open /fleet to resume".
- [ ] `/fleet` → `lifecycle` tab shows the interrupted run; the journal under `.pi/fleet/runs/` has no terminal event.

## PID-lock
- [ ] Open a second pi session in the same project → schedules don't double-fire (the second session defers; `.pi/fleet/schedules.lock` holds the first session's PID).

## Manual end-to-end (optional, real Ollama)
- [ ] `node --import tsx scripts/spec-5a-smoke.mts` → `SMOKE PASSED ✅` (worktree created, lifecycle ran, journal recorded, inbox received, one-shot schedule fired once + auto-deleted). Safe to run from the repo cwd — the worktree is the isolation.

## Guards
- [ ] Invalid cron expression at `a:Add` → actionable error (resolve-time, not fire-time).
- [ ] `background` runs cap at `fleet.maxConcurrentBg` (default 3); a 4th bg run queues (`⏳` in fleet tab).
2,416 changes: 2,416 additions & 0 deletions plans/SPEC-5a-operational-runtime.md

Large diffs are not rendered by default.

119 changes: 119 additions & 0 deletions scripts/spec-5a-smoke.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// scripts/spec-5a-smoke.mts — SPEC-5a end-to-end operational-runtime smoke
// Run: node --import tsx scripts/spec-5a-smoke.mts
//
// Verifies the full SPEC-5a path on REAL Ollama Cloud pi phases in an isolated temp git repo:
// 1. runBackground fires a trivial isolated lifecycle → worktree created → phases run →
// worktree-diff discovers artifacts → journal records events → inbox receives result → notify.
// 2. A one-shot schedule fires + auto-deletes.
// The worktree IS the isolation (no repo pollution, unlike the SPEC-4 smoke's temp-cwd workaround).
// Requires a configured Ollama Cloud model + ~/.pi/agent/auth.json (pi loads it automatically).
// NOT part of the CI gate; run manually before tagging v0.5.0.
import { runLifecycle } from "../src/lifecycle/run-lifecycle.ts";
import { DEFAULT_LIFECYCLE } from "../src/lifecycle/default.ts";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { ArmoryTodoAdapter } from "../src/todo-sync/adapter.ts";
import { ArmoryMemoryAdapter } from "../src/memory-hydrate/adapter.ts";
import { RunRegistry } from "../src/engine/run-registry.ts";
import { createSingleSlotLock } from "../src/engine/concurrency-lock.ts";
import { discoverAgents } from "../src/registry/discovery.ts";
import { createChildSessionFactory } from "../src/index.ts";
import { BackendRegistry, PI_HOOK_PARITY } from "../src/backend/port.ts";
import { ResumeStore } from "../src/backend/resume-store.ts";
import { spawnSubagent } from "../src/engine/spawnSubagent.ts";
import { WorktreeService } from "../src/worktree/worktree-service.ts";
import { DiffService } from "../src/worktree/diff-service.ts";
import { RunJournal } from "../src/runtime/run-journal.ts";
import { ConcurrencyPool } from "../src/runtime/concurrency-pool.ts";
import { ResultsInbox } from "../src/runtime/results-inbox.ts";
import { runBackground } from "../src/runtime/async-runner.ts";
import { Scheduler } from "../src/scheduling/scheduler.ts";
import { join } from "node:path";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";

async function main(): Promise<void> {
// 1. isolated temp git repo (the worktree IS the isolation — no repo pollution)
const repo = mkdtempSync(join(tmpdir(), "fleet-spec5a-smoke-"));
execSync("git init -b main", { cwd: repo });
execSync('git config user.email "t@t.test" && git config user.name "test"', { cwd: repo });
writeFileSync(join(repo, "base.txt"), "base\n");
execSync("git add base.txt && git commit -m base", { cwd: repo });
console.log("smoke repo:", repo);

// 2. build the same lifecycleDeps as the SPEC-4 smoke
const modelRuntime = await ModelRuntime.create();
const todoSync = new ArmoryTodoAdapter();
const resumeStore = new ResumeStore();
const backendRegistry = new BackendRegistry();
backendRegistry.register({ id: "pi", factory: createChildSessionFactory(modelRuntime, new ArmoryMemoryAdapter(), resumeStore), available: () => true, versionInfo: () => null, hookParity: PI_HOOK_PARITY });
const agentDiscovery = discoverAgents({ projectDir: join(repo, ".pi", "agents"), globalDir: join(process.env.HOME ?? "", ".pi", "agent", "agents"), builtinDir: join(new URL(".", import.meta.url).pathname, "..", "agents") });
const agentRegistry = agentDiscovery.agents;

const lifecycleDeps = {
registry: new Map([["default", DEFAULT_LIFECYCLE]]),
agentRegistry,
spawn: async (o: any) => spawnSubagent({ agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model, skillsOverride: o.skills, backendOverride: o.backend, registry: agentRegistry, todoSync, runRegistry: new RunRegistry(), lock: createSingleSlotLock(), backendRegistry, parentModel: { provider: "Ollama", id: "glm-5.2:cloud" }, parentCwd: o.parentCwd }),
todoPort: todoSync,
resolveBackend: (phaseBackend: any, lifecycleBackend: any) => phaseBackend ?? lifecycleBackend,
genRunId: () => "fl-smoke-" + Date.now().toString(36),
};

// 3. async runner deps — the runLifecycle adapter maps runBackground opts → runLifecycle
const journal = new RunJournal(join(repo, ".pi", "fleet", "runs"));
const inbox = new ResultsInbox();
const asyncDeps = {
worktree: new WorktreeService({ rootDir: repo }),
diff: new DiffService(),
journal,
pool: new ConcurrencyPool(2),
inbox,
runLifecycle: async (task: string, lifecycleName: string, opts: any) => {
const res = await runLifecycle(task, lifecycleName, {
deps: { ...lifecycleDeps, spawn: async (o: any) => lifecycleDeps.spawn({ ...o, parentCwd: opts.worktreePath }) } as any,
mode: "auto",
onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" },
});
return res as any;
},
notify: (m: string) => console.log("notify:", m),
genRunId: () => "fl-smoke-" + Date.now().toString(36),
};

// 4. fire a background run
const handle = runBackground("Add a hello() function to scratch.ts returning 'hello from fleet'", { deps: asyncDeps, lifecycle: "default", mode: "auto" });
console.log("fired:", handle);

// 5. wait for completion (poll the inbox)
const deadline = Date.now() + 180_000;
while (Date.now() < deadline && inbox.readyCount() === 0) {
await new Promise((r) => setTimeout(r, 1000));
}
const results = inbox.pull();
if (results.length === 0) { console.error("SMOKE FAILED: no result within 180s"); rmSync(repo, { recursive: true, force: true }); process.exit(1); }
console.log("result:", JSON.stringify(results[0], null, 2));

// 6. assert the journal
const events = journal.replay(handle.runId);
if (!events.some((e) => e.type === "run:completed")) { console.error("SMOKE FAILED: no run:completed in journal"); rmSync(repo, { recursive: true, force: true }); process.exit(1); }
console.log("journal events:", events.map((e) => e.type).join(", "));

// 7. scheduling: register a one-shot 2s out + assert it fires once + auto-deletes
let schedFired = 0;
const scheduler = new Scheduler({ storePath: join(repo, ".pi", "fleet", "schedules.json"), lockPath: join(repo, ".pi", "fleet", "schedules.lock"), onFire: () => { schedFired++; } });
const fireAt = new Date(Date.now() + 2000);
const pad = (n: number) => String(n).padStart(2, "0");
const iso = `${fireAt.getFullYear()}-${pad(fireAt.getMonth() + 1)}-${pad(fireAt.getDate())}T${pad(fireAt.getHours())}:${pad(fireAt.getMinutes())}:${pad(fireAt.getSeconds())}`;
const schedId = scheduler.register({ task: "scheduled smoke", expression: iso, lifecycle: "default" });
scheduler.start();
await new Promise((r) => setTimeout(r, 3000));
scheduler.stop();
if (schedFired !== 1) { console.error(`SMOKE FAILED: schedule fired ${schedFired} times (expected 1)`); rmSync(repo, { recursive: true, force: true }); process.exit(1); }
if (scheduler.list().find((s) => s.id === schedId)) { console.error("SMOKE FAILED: one-shot not auto-deleted"); rmSync(repo, { recursive: true, force: true }); process.exit(1); }
console.log("schedule fired once + auto-deleted ✓");

rmSync(repo, { recursive: true, force: true });
console.log("SMOKE PASSED ✅");
}

void main().catch((e) => { console.error("SMOKE ERROR:", e); process.exit(1); });
Loading
Loading