@getpipher/armory-fleet 0.3.0 → 0.5.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/package.json +1 -1
- package/src/engine/run-registry.ts +13 -1
- package/src/engine/spawnSubagent.ts +33 -16
- package/src/index.ts +155 -2
- package/src/lifecycle/artifacts-parser.ts +57 -0
- package/src/lifecycle/default.ts +74 -0
- package/src/lifecycle/lifecycle-todo.ts +84 -0
- package/src/lifecycle/lifecycle-types.ts +66 -0
- package/src/lifecycle/port.ts +7 -0
- package/src/lifecycle/prompt-template.ts +40 -0
- package/src/lifecycle/registry.ts +169 -0
- package/src/lifecycle/run-lifecycle.ts +251 -0
- package/src/panel/bg-runs-store.ts +36 -0
- package/src/panel/fleet-items.ts +36 -0
- package/src/panel/fleet-panel.ts +334 -18
- package/src/panel/rows.ts +90 -0
- package/src/runtime/async-runner.ts +123 -0
- package/src/runtime/concurrency-pool.ts +27 -0
- package/src/runtime/results-inbox.ts +45 -0
- package/src/runtime/resume.ts +48 -0
- package/src/runtime/run-journal.ts +61 -0
- package/src/scheduling/expressions.ts +60 -0
- package/src/scheduling/pid-lock.ts +43 -0
- package/src/scheduling/scheduler.ts +147 -0
- package/src/todo-sync/adapter.ts +6 -0
- package/src/todo-sync/port.ts +2 -0
- package/src/tools/fleet-results.ts +35 -0
- package/src/tools/subagent.ts +58 -0
- package/src/vendor/cron-parser/NOTICE.md +23 -0
- package/src/vendor/cron-parser/lib/date.js +79 -0
- package/src/vendor/cron-parser/lib/expression.js +614 -0
- package/src/vendor/cron-parser/lib/number.js +8 -0
- package/src/vendor/cron-parser/lib/parser.js +103 -0
- package/src/vendor/cron-parser/types.d.ts +12 -0
- package/src/worktree/diff-service.ts +40 -0
- package/src/worktree/worktree-service.ts +92 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// src/lifecycle/registry.ts
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
import { basename, extname } from "node:path";
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { AgentSource } from "../registry/frontmatter.ts";
|
|
7
|
+
import type { BackendId, LifecycleDef, PhaseDef } from "./lifecycle-types.ts";
|
|
8
|
+
|
|
9
|
+
export class LifecycleParseError extends Error {
|
|
10
|
+
override name = "LifecycleParseError" as const;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
14
|
+
const VALID_BACKENDS: BackendId[] = ["pi", "claude"];
|
|
15
|
+
|
|
16
|
+
/** Parse a lifecycle markdown file into a LifecycleDef. Throws LifecycleParseError on any malformed input. */
|
|
17
|
+
export function parseLifecycleFile(content: string, filePath: string, source: AgentSource): LifecycleDef {
|
|
18
|
+
const m = FM_RE.exec(content);
|
|
19
|
+
if (!m || m[1] === undefined || m[2] === undefined) {
|
|
20
|
+
throw new LifecycleParseError(`${filePath}: missing --- frontmatter delimiters`);
|
|
21
|
+
}
|
|
22
|
+
let raw: Record<string, unknown>;
|
|
23
|
+
try {
|
|
24
|
+
raw = (parseYaml(m[1]) ?? {}) as Record<string, unknown>;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
throw new LifecycleParseError(`${filePath}: invalid YAML (${(e as Error).message})`);
|
|
27
|
+
}
|
|
28
|
+
const body = m[2];
|
|
29
|
+
|
|
30
|
+
const name = typeof raw.name === "string" && raw.name.trim()
|
|
31
|
+
? raw.name.trim()
|
|
32
|
+
: basename(filePath, extname(filePath));
|
|
33
|
+
const description = typeof raw.description === "string" ? raw.description.trim() : "";
|
|
34
|
+
if (!description) throw new LifecycleParseError(`${filePath}: description is required`);
|
|
35
|
+
|
|
36
|
+
const rawBackend = typeof raw.backend === "string" ? raw.backend.trim() : "pi";
|
|
37
|
+
if (!VALID_BACKENDS.includes(rawBackend as BackendId)) {
|
|
38
|
+
throw new LifecycleParseError(`${filePath}: invalid backend '${rawBackend}' (must be 'pi' | 'claude')`);
|
|
39
|
+
}
|
|
40
|
+
const backend = rawBackend as BackendId;
|
|
41
|
+
|
|
42
|
+
if (!Array.isArray(raw.phases) || raw.phases.length === 0) {
|
|
43
|
+
throw new LifecycleParseError(`${filePath}: phases must be a non-empty array`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Parse phase frontmatter entries (name + skills + agent + backend + checkpoint); templates resolved after.
|
|
47
|
+
const phaseNames = new Set<string>();
|
|
48
|
+
const partialPhases = raw.phases.map((p: unknown, i: number) => {
|
|
49
|
+
if (!p || typeof p !== "object") {
|
|
50
|
+
throw new LifecycleParseError(`${filePath}: phases[${i}] must be an object`);
|
|
51
|
+
}
|
|
52
|
+
const po = p as Record<string, unknown>;
|
|
53
|
+
const pname = typeof po.name === "string" && po.name.trim() ? po.name.trim() : "";
|
|
54
|
+
if (!pname) throw new LifecycleParseError(`${filePath}: phases[${i}].name is required`);
|
|
55
|
+
if (phaseNames.has(pname)) {
|
|
56
|
+
throw new LifecycleParseError(`${filePath}: duplicate phase '${pname}'`);
|
|
57
|
+
}
|
|
58
|
+
phaseNames.add(pname);
|
|
59
|
+
if (!Array.isArray(po.skills)) {
|
|
60
|
+
throw new LifecycleParseError(`${filePath}: phase '${pname}' skills must be an array`);
|
|
61
|
+
}
|
|
62
|
+
const skills = po.skills.map((s) => String(s));
|
|
63
|
+
const agent = typeof po.agent === "string" && po.agent.trim() ? po.agent.trim() : undefined;
|
|
64
|
+
let pbackend: BackendId | undefined;
|
|
65
|
+
if (po.backend !== undefined) {
|
|
66
|
+
const b = String(po.backend).trim();
|
|
67
|
+
if (!VALID_BACKENDS.includes(b as BackendId)) {
|
|
68
|
+
throw new LifecycleParseError(`${filePath}: phase '${pname}' invalid backend '${b}'`);
|
|
69
|
+
}
|
|
70
|
+
pbackend = b as BackendId;
|
|
71
|
+
}
|
|
72
|
+
const checkpoint = po.checkpoint === undefined ? true : Boolean(po.checkpoint);
|
|
73
|
+
return { name: pname, skills, agent, backend: pbackend, checkpoint };
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Split body into `## <phase>` sections. A phase with no matching section = error.
|
|
77
|
+
const templates = splitPhaseTemplates(body, filePath);
|
|
78
|
+
const phases: PhaseDef[] = partialPhases.map((p) => {
|
|
79
|
+
const promptTemplate = templates.get(p.name);
|
|
80
|
+
if (promptTemplate === undefined) {
|
|
81
|
+
throw new LifecycleParseError(`${filePath}: phase '${p.name}' missing template (no '## ${p.name}' body section)`);
|
|
82
|
+
}
|
|
83
|
+
return { ...p, promptTemplate };
|
|
84
|
+
});
|
|
85
|
+
// The terminal phase never checkpoints after it (the lifecycle is done) — §5.4.
|
|
86
|
+
if (phases.length > 0) phases[phases.length - 1]!.checkpoint = false;
|
|
87
|
+
|
|
88
|
+
return { name, description, backend, phases, source, filePath };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Split the markdown body into a map of phase-name → prompt-template, by `## <name>` H2 headings. */
|
|
92
|
+
function splitPhaseTemplates(body: string, filePath: string): Map<string, string> {
|
|
93
|
+
const out = new Map<string, string>();
|
|
94
|
+
const lines = body.split(/\r?\n/);
|
|
95
|
+
let current: string | null = null;
|
|
96
|
+
const H2 = /^##\s+(\S[^\r\n]*)$/;
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
const h = H2.exec(line);
|
|
99
|
+
if (h && h[1] !== undefined) {
|
|
100
|
+
current = h[1].trim();
|
|
101
|
+
if (current !== null && out.has(current)) {
|
|
102
|
+
throw new LifecycleParseError(`${filePath}: duplicate '## ${current}' body section`);
|
|
103
|
+
}
|
|
104
|
+
if (current !== null) out.set(current, "");
|
|
105
|
+
} else if (current !== null) {
|
|
106
|
+
out.set(current, (out.get(current) ?? "") + line + "\n");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
export interface LifecycleDiscoverOpts {
|
|
112
|
+
projectDir: string | null;
|
|
113
|
+
globalDir: string | null;
|
|
114
|
+
builtinDir: string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface LifecycleDiscoverResult {
|
|
118
|
+
lifecycles: Map<string, LifecycleDef>;
|
|
119
|
+
warnings: string[];
|
|
120
|
+
errors: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Recursively collect *.md file paths (mirror registry/discovery.ts). */
|
|
124
|
+
function collectMarkdown(dir: string): string[] {
|
|
125
|
+
const out: string[] = [];
|
|
126
|
+
const visited = new Set<string>();
|
|
127
|
+
const walk = (d: string): void => {
|
|
128
|
+
if (!existsSync(d)) return;
|
|
129
|
+
let real: string;
|
|
130
|
+
try { real = realpathSync(d); } catch { return; }
|
|
131
|
+
if (visited.has(real)) return;
|
|
132
|
+
visited.add(real);
|
|
133
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
134
|
+
const full = join(d, entry.name);
|
|
135
|
+
if (entry.isDirectory()) walk(full);
|
|
136
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
walk(dir);
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function discoverLifecycles(opts: LifecycleDiscoverOpts): LifecycleDiscoverResult {
|
|
144
|
+
const lifecycles = new Map<string, LifecycleDef>();
|
|
145
|
+
const warnings: string[] = [];
|
|
146
|
+
const errors: string[] = [];
|
|
147
|
+
const loadScope = (dir: string | null, source: AgentSource): void => {
|
|
148
|
+
if (!dir) return;
|
|
149
|
+
for (const f of collectMarkdown(dir).sort()) {
|
|
150
|
+
let content: string;
|
|
151
|
+
try { content = readFileSync(f, "utf8"); } catch { warnings.push(`${f}: unreadable file, skipped`); continue; }
|
|
152
|
+
try {
|
|
153
|
+
const def = parseLifecycleFile(content, f, source);
|
|
154
|
+
const existing = lifecycles.get(def.name);
|
|
155
|
+
if (existing && existing.source === source) {
|
|
156
|
+
errors.push(`duplicate lifecycle '${def.name}' in ${source} scope (${f}); first kept`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
lifecycles.set(def.name, def); // project over global/builtin (later wins)
|
|
160
|
+
} catch (e) {
|
|
161
|
+
warnings.push(e instanceof LifecycleParseError ? e.message : `${f}: ${String(e)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
loadScope(opts.builtinDir, "builtin");
|
|
166
|
+
loadScope(opts.globalDir, "global");
|
|
167
|
+
loadScope(opts.projectDir, "project");
|
|
168
|
+
return { lifecycles, warnings, errors };
|
|
169
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// src/lifecycle/run-lifecycle.ts
|
|
2
|
+
import type { AgentDef } from "../registry/frontmatter.ts";
|
|
3
|
+
import type { FleetRunStatus } from "../todo-sync/port.ts";
|
|
4
|
+
import type { SpawnResult } from "../engine/spawnSubagent.ts";
|
|
5
|
+
import type {
|
|
6
|
+
BackendId, LifecycleDef, LifecycleMode, LifecycleStatus, PhaseRecord, CheckpointDecision,
|
|
7
|
+
} from "./lifecycle-types.ts";
|
|
8
|
+
import { renderPhasePrompt } from "./prompt-template.ts";
|
|
9
|
+
import { parseArtifacts, MAX_REVISE } from "./artifacts-parser.ts";
|
|
10
|
+
import {
|
|
11
|
+
createLifecycleTodo, updateProgress, completeLifecycleTodo, revertLifecycleTodo,
|
|
12
|
+
type LifecycleTodoPort, type ProgressPhase,
|
|
13
|
+
} from "./lifecycle-todo.ts";
|
|
14
|
+
|
|
15
|
+
/** A phase spawn is delegated to a `spawn` function (tests inject a fake; production wires spawnSubagent). */
|
|
16
|
+
export interface PhaseSpawnOpts {
|
|
17
|
+
agent: string;
|
|
18
|
+
task: string;
|
|
19
|
+
lifecycleTodoId: string;
|
|
20
|
+
/** The merged skill bundle for this phase (lifecycle phase skills ∪ agent's own). */
|
|
21
|
+
skills: string[];
|
|
22
|
+
/** The resolved backend for this phase (phase.backend → lifecycle.backend → "pi"). */
|
|
23
|
+
backend: BackendId;
|
|
24
|
+
model?: string;
|
|
25
|
+
}
|
|
26
|
+
export type SpawnFn = (opts: PhaseSpawnOpts) => Promise<SpawnResult>;
|
|
27
|
+
|
|
28
|
+
export interface LifecycleRunDeps {
|
|
29
|
+
registry: Map<string, LifecycleDef>;
|
|
30
|
+
agentRegistry: Map<string, AgentDef>;
|
|
31
|
+
spawn: SpawnFn;
|
|
32
|
+
todoPort: LifecycleTodoPort;
|
|
33
|
+
/** Resolve the backend for a phase: phase.backend → lifecycle.backend → "pi" (+ availability check). */
|
|
34
|
+
resolveBackend: (phaseBackend: BackendId | undefined, lifecycleBackend: BackendId) => BackendId;
|
|
35
|
+
genRunId: () => string;
|
|
36
|
+
/** SPEC-5a (Q3=A): when present, isolated runs use worktree-diff artifact discovery
|
|
37
|
+
* instead of the prompt-baked `Artifacts:` block parser. Foreground runs leave this undefined. */
|
|
38
|
+
artifactDiscovery?: (o: { finalText: string; cwd: string; baseRef: string; terminal: boolean }) => { summary: string; paths: string[] } | { error: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LifecycleRunOpts {
|
|
42
|
+
deps: LifecycleRunDeps;
|
|
43
|
+
mode: LifecycleMode;
|
|
44
|
+
onCheckpoint: CheckpointFn;
|
|
45
|
+
/** SPEC-5a (Q3=A): the worktree path for isolated runs. When set + deps.artifactDiscovery is present,
|
|
46
|
+
* artifact discovery uses worktree-diff instead of parseArtifacts. Foreground runs leave this undefined. */
|
|
47
|
+
worktreePath?: string;
|
|
48
|
+
/** SPEC-5a: the base ref to diff against (default "HEAD"). */
|
|
49
|
+
baseRef?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LifecycleRunResult {
|
|
53
|
+
runId: string;
|
|
54
|
+
lifecycleName: string;
|
|
55
|
+
task: string;
|
|
56
|
+
backend: BackendId;
|
|
57
|
+
mode: LifecycleMode;
|
|
58
|
+
status: LifecycleStatus;
|
|
59
|
+
phases: PhaseRecord[];
|
|
60
|
+
startedAt: number;
|
|
61
|
+
endedAt?: number;
|
|
62
|
+
todoId: string | null;
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Human (or auto) decision at a checkpoint. */
|
|
67
|
+
export type CheckpointFn = (phase: PhaseRecord) => Promise<CheckpointDecision>;
|
|
68
|
+
|
|
69
|
+
export async function runLifecycle(task: string, lifecycleName: string, opts: LifecycleRunOpts): Promise<LifecycleRunResult> {
|
|
70
|
+
const { deps } = opts;
|
|
71
|
+
const startedAt = Date.now();
|
|
72
|
+
|
|
73
|
+
// 1. Resolve lifecycle (resolve-time errors → failed result, no todo touched).
|
|
74
|
+
const lifecycle = deps.registry.get(lifecycleName);
|
|
75
|
+
if (!lifecycle) {
|
|
76
|
+
const available = [...deps.registry.keys()].sort().join(", ");
|
|
77
|
+
return failResult("", startedAt, `lifecycle '${lifecycleName}' not found; available: ${available}`, lifecycleName, task, opts.mode, [], null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const runId = deps.genRunId();
|
|
81
|
+
const lifecycleBackend = lifecycle.backend;
|
|
82
|
+
|
|
83
|
+
// 2. Create the lifecycle TODO (one per lifecycle — Q7=C).
|
|
84
|
+
let todoId: string;
|
|
85
|
+
try {
|
|
86
|
+
todoId = await createLifecycleTodo(deps.todoPort, {
|
|
87
|
+
runId, task, lifecycle: lifecycleName, backend: lifecycleBackend, mode: opts.mode,
|
|
88
|
+
phases: lifecycle.phases.map((p) => p.name),
|
|
89
|
+
});
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return failResult(runId, startedAt, `lifecycle TODO create failed: ${(e as Error).message}`, lifecycleName, task, opts.mode, [], null, lifecycleBackend);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Phase-progress state for the todo notes (single source of truth).
|
|
95
|
+
const progressPhases: ProgressPhase[] = lifecycle.phases.map((p) => ({ name: p.name, done: false }));
|
|
96
|
+
const phaseRecords: PhaseRecord[] = [];
|
|
97
|
+
|
|
98
|
+
// 3. Phase loop.
|
|
99
|
+
for (let idx = 0; idx < lifecycle.phases.length; idx++) {
|
|
100
|
+
const phaseDef = lifecycle.phases[idx]!;
|
|
101
|
+
const isTerminal = idx === lifecycle.phases.length - 1;
|
|
102
|
+
|
|
103
|
+
// a/b: resolve agent + backend
|
|
104
|
+
const agentName = phaseDef.agent ?? "general-purpose";
|
|
105
|
+
if (!deps.agentRegistry.has(agentName)) {
|
|
106
|
+
await revertLifecycleTodo(deps.todoPort, todoId, `agent '${agentName}' not in registry`);
|
|
107
|
+
return failResult(runId, startedAt, `agent '${agentName}' (phase '${phaseDef.name}') not in registry`, lifecycleName, task, opts.mode, phaseRecords, todoId, lifecycleBackend);
|
|
108
|
+
}
|
|
109
|
+
// resolveBackend may throw (e.g. phase requests claude when claude is unavailable) — §12.
|
|
110
|
+
let backend: BackendId;
|
|
111
|
+
try {
|
|
112
|
+
backend = deps.resolveBackend(phaseDef.backend, lifecycleBackend);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
await revertLifecycleTodo(deps.todoPort, todoId, `backend resolve failed: ${(e as Error).message}`);
|
|
115
|
+
return failResult(runId, startedAt, (e as Error).message, lifecycleName, task, opts.mode, phaseRecords, todoId, lifecycleBackend);
|
|
116
|
+
}
|
|
117
|
+
const agentDef = deps.agentRegistry.get(agentName)!;
|
|
118
|
+
const skills = mergeSkills(phaseDef.skills, agentDef.skills ?? []);
|
|
119
|
+
|
|
120
|
+
// Revise loop (runs the phase, then checkpoints; on Revise, re-runs with feedback)
|
|
121
|
+
let reviseCount = 0;
|
|
122
|
+
// Per-phase scratch for the revise feedback: the current phase's own prior attempt summary +
|
|
123
|
+
// the human's feedback. Reset each phase (a phase's revise context is its own, not a prior phase's).
|
|
124
|
+
let priorAttemptSummary = "";
|
|
125
|
+
let lastFeedback: string | undefined;
|
|
126
|
+
// eslint-disable-next-line no-constant-condition
|
|
127
|
+
while (true) {
|
|
128
|
+
const prev = phaseRecords.length > 0 ? phaseRecords[phaseRecords.length - 1] : undefined;
|
|
129
|
+
const feedback = reviseCount > 0
|
|
130
|
+
? `Prior attempt summary: ${priorAttemptSummary.slice(0, 500)}\n\nHuman feedback: ${lastFeedback ?? ""}`
|
|
131
|
+
: undefined;
|
|
132
|
+
const prompt = renderPhasePrompt(phaseDef.promptTemplate, {
|
|
133
|
+
task, lifecycle: lifecycleName, phase: phaseDef.name,
|
|
134
|
+
prev: prev ? { name: prev.name, summary: prev.summary, paths: prev.paths } : undefined,
|
|
135
|
+
feedback,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// e/f: spawn the phase child (links to the lifecycle todo; skips mark-done/revert — Task 8).
|
|
139
|
+
// The merged skill bundle + resolved backend are threaded so the real spawn injects the
|
|
140
|
+
// phase's skills (Q1=B) and routes to the phase's backend (Q4=C) — not the agent's defaults.
|
|
141
|
+
let spawnRes: import("../engine/spawnSubagent.ts").SpawnResult;
|
|
142
|
+
try {
|
|
143
|
+
spawnRes = await deps.spawn({ agent: agentName, task: prompt, lifecycleTodoId: todoId, skills, backend });
|
|
144
|
+
} catch (e) {
|
|
145
|
+
// spawn should return a failed result, not throw — but guard anyway so a throwing spawn
|
|
146
|
+
// can't orphan the lifecycle (treat as a phase failure).
|
|
147
|
+
spawnRes = { status: "failed", finalText: "", runId: "fl-err", todoId, agent: agentName, model: "", durationMs: 0, tokenTotal: 0, error: (e as Error).message };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// g: parse artifacts (terminal phase exempts a missing block).
|
|
151
|
+
let phaseRec: PhaseRecord;
|
|
152
|
+
if (spawnRes.status === "failed") {
|
|
153
|
+
phaseRec = { name: phaseDef.name, summary: spawnRes.error ?? spawnRes.finalText.slice(0, 120), paths: [], status: "failed", reviseCount };
|
|
154
|
+
} else if (deps.artifactDiscovery && opts.worktreePath) {
|
|
155
|
+
// SPEC-5a (Q3=A): isolated run — structural worktree-diff (robust to models that omit the Artifacts block).
|
|
156
|
+
const art = deps.artifactDiscovery({ finalText: spawnRes.finalText, cwd: opts.worktreePath, baseRef: opts.baseRef ?? "HEAD", terminal: isTerminal });
|
|
157
|
+
if ("error" in art) {
|
|
158
|
+
phaseRec = { name: phaseDef.name, summary: art.error, paths: [], status: "failed", reviseCount };
|
|
159
|
+
} else {
|
|
160
|
+
phaseRec = { name: phaseDef.name, summary: art.summary, paths: art.paths, status: "completed", reviseCount };
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
const art = parseArtifacts(spawnRes.finalText, { terminal: isTerminal });
|
|
164
|
+
if ("error" in art) {
|
|
165
|
+
phaseRec = { name: phaseDef.name, summary: art.error, paths: [], status: "failed", reviseCount };
|
|
166
|
+
} else {
|
|
167
|
+
phaseRec = { name: phaseDef.name, summary: art.summary, paths: art.paths, status: "completed", reviseCount };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Capture this attempt's summary for the next revise iteration's feedback digest.
|
|
172
|
+
priorAttemptSummary = phaseRec.summary;
|
|
173
|
+
|
|
174
|
+
// h: update the lifecycle todo progress block.
|
|
175
|
+
await updateProgress(deps.todoPort, todoId, {
|
|
176
|
+
phase: phaseDef.name, done: phaseRec.status === "completed",
|
|
177
|
+
last: `${phaseDef.name} ${phaseRec.status}${phaseRec.paths.length ? " — " + phaseRec.paths.join(", ") : ""}`,
|
|
178
|
+
revising: false, attempt: reviseCount,
|
|
179
|
+
}, { lifecycle: lifecycleName, task, backend: lifecycleBackend, mode: opts.mode, phases: progressPhases });
|
|
180
|
+
|
|
181
|
+
// i: checkpoint decision.
|
|
182
|
+
const forceCheckpoint = phaseRec.status === "failed"; // failure forces a checkpoint regardless of auto/checkpoint
|
|
183
|
+
const shouldCheckpoint = forceCheckpoint || (phaseDef.checkpoint !== false && opts.mode === "checkpointed" && !isTerminal);
|
|
184
|
+
if (!shouldCheckpoint) {
|
|
185
|
+
phaseRecords.push(phaseRec);
|
|
186
|
+
break; // advance to next phase
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const decision = await opts.onCheckpoint(phaseRec);
|
|
190
|
+
if (decision.action === "continue") {
|
|
191
|
+
if (forceCheckpoint) {
|
|
192
|
+
// cannot continue past a failure — treat as abort (guard against a misbehaving checkpoint fn)
|
|
193
|
+
await revertLifecycleTodo(deps.todoPort, todoId, `cannot continue past failed phase '${phaseDef.name}'`);
|
|
194
|
+
phaseRecords.push(phaseRec);
|
|
195
|
+
return doneResult(runId, startedAt, "aborted", lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId);
|
|
196
|
+
}
|
|
197
|
+
phaseRecords.push(phaseRec);
|
|
198
|
+
break; // advance
|
|
199
|
+
}
|
|
200
|
+
if (decision.action === "abort") {
|
|
201
|
+
await revertLifecycleTodo(deps.todoPort, todoId, `aborted at phase '${phaseDef.name}'`);
|
|
202
|
+
phaseRecords.push(phaseRec);
|
|
203
|
+
// A failed phase that's aborted = lifecycle failed (the work failed); a healthy phase
|
|
204
|
+
// aborted at a checkpoint = user-aborted (§12).
|
|
205
|
+
const status: LifecycleStatus = phaseRec.status === "failed" ? "failed" : "aborted";
|
|
206
|
+
return doneResult(runId, startedAt, status, lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId);
|
|
207
|
+
}
|
|
208
|
+
// decision.action === "revise"
|
|
209
|
+
reviseCount++;
|
|
210
|
+
lastFeedback = decision.feedback;
|
|
211
|
+
if (reviseCount > MAX_REVISE) {
|
|
212
|
+
await updateProgress(deps.todoPort, todoId, {
|
|
213
|
+
phase: phaseDef.name, done: false, last: `revise budget exhausted (${MAX_REVISE})`, revising: false, attempt: reviseCount,
|
|
214
|
+
}, { lifecycle: lifecycleName, task, backend: lifecycleBackend, mode: opts.mode, phases: progressPhases });
|
|
215
|
+
phaseRecords.push(phaseRec);
|
|
216
|
+
return doneResult(runId, startedAt, "failed", lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId,
|
|
217
|
+
`phase '${phaseDef.name}' revise budget exhausted (${MAX_REVISE})`);
|
|
218
|
+
}
|
|
219
|
+
// mark revising in the progress block, then loop to re-run this phase
|
|
220
|
+
await updateProgress(deps.todoPort, todoId, {
|
|
221
|
+
phase: phaseDef.name, done: false, last: `revising (attempt ${reviseCount}/${MAX_REVISE})`, revising: true, attempt: reviseCount,
|
|
222
|
+
}, { lifecycle: lifecycleName, task, backend: lifecycleBackend, mode: opts.mode, phases: progressPhases });
|
|
223
|
+
// loop continues — re-run the phase with feedback
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// j: terminal phase completed → lifecycle done.
|
|
228
|
+
await completeLifecycleTodo(deps.todoPort, todoId, `lifecycle '${lifecycleName}' completed`);
|
|
229
|
+
return doneResult(runId, startedAt, "completed", lifecycleName, task, lifecycleBackend, opts.mode, phaseRecords, todoId);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Merge lifecycle phase skills + agent's own skills (lifecycle first; agent can only add — Q3=B). */
|
|
233
|
+
function mergeSkills(phaseSkills: string[], agentSkills: string[]): string[] {
|
|
234
|
+
const out = [...phaseSkills];
|
|
235
|
+
for (const s of agentSkills) if (!out.includes(s)) out.push(s);
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function failResult(
|
|
240
|
+
runId: string, startedAt: number, error: string, lifecycleName: string, task: string,
|
|
241
|
+
mode: LifecycleMode, phases: PhaseRecord[], todoId: string | null, backend: BackendId = "pi",
|
|
242
|
+
): LifecycleRunResult {
|
|
243
|
+
return { runId, lifecycleName, task, backend, mode, status: "failed", phases, startedAt, endedAt: Date.now(), todoId, error };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function doneResult(
|
|
247
|
+
runId: string, startedAt: number, status: LifecycleStatus, lifecycleName: string, task: string,
|
|
248
|
+
backend: BackendId, mode: LifecycleMode, phases: PhaseRecord[], todoId: string | null, error?: string,
|
|
249
|
+
): LifecycleRunResult {
|
|
250
|
+
return { runId, lifecycleName, task, backend, mode, status, phases, startedAt, endedAt: Date.now(), todoId, error };
|
|
251
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/panel/bg-runs-store.ts
|
|
2
|
+
// SPEC-5a proper-fix: a change-emitting store for live bg-run status rows.
|
|
3
|
+
//
|
|
4
|
+
// Replaces the bare `Map<string, BgRunStatus>` that `onProgress` used to mutate.
|
|
5
|
+
// The async runner's `onProgress` callback writes here on every phase transition
|
|
6
|
+
// and on completion; the /fleet panel subscribes so it re-renders the moment a
|
|
7
|
+
// bg run's status changes — even while the parent is idle (no `turn_end` fires
|
|
8
|
+
// for an async/fire-and-forget run). This is the SPEC-5b live-widget seam,
|
|
9
|
+
// delivered early as the proper fix for the stale "running" row bug.
|
|
10
|
+
import type { BgRunStatus } from "./rows.ts";
|
|
11
|
+
|
|
12
|
+
export type BgRunsChangeListener = (runId: string) => void;
|
|
13
|
+
|
|
14
|
+
export class BgRunsStore {
|
|
15
|
+
private readonly runs = new Map<string, BgRunStatus>();
|
|
16
|
+
private readonly listeners = new Set<BgRunsChangeListener>();
|
|
17
|
+
|
|
18
|
+
set(runId: string, status: BgRunStatus): void {
|
|
19
|
+
this.runs.set(runId, status);
|
|
20
|
+
for (const fn of this.listeners) fn(runId);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get(runId: string): BgRunStatus | undefined {
|
|
24
|
+
return this.runs.get(runId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
values(): IterableIterator<BgRunStatus> {
|
|
28
|
+
return this.runs.values();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Subscribe to status changes. Returns an unsubscribe function. */
|
|
32
|
+
subscribe(fn: BgRunsChangeListener): () => void {
|
|
33
|
+
this.listeners.add(fn);
|
|
34
|
+
return () => { this.listeners.delete(fn); };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/panel/fleet-items.ts
|
|
2
|
+
// SPEC-5a proper-fix: pure builder for the /fleet fleet-tab list items.
|
|
3
|
+
//
|
|
4
|
+
// Extracted from FleetPanel.buildList so the merge + dedup of foreground
|
|
5
|
+
// (RunRegistry) rows and live bg (BgRunsStore) rows is unit-testable without
|
|
6
|
+
// a TUI harness. Foreground rows render via `fleetRow`; bg rows via
|
|
7
|
+
// `renderBgRow` (the Q8=A live status icons + phase progress). The two stores
|
|
8
|
+
// are disjoint by runId in practice (bg runs never enter RunRegistry under
|
|
9
|
+
// their own runId; the lifecycle's child spawns use their own runIds), but we
|
|
10
|
+
// dedup defensively in case a future change overlaps them.
|
|
11
|
+
import type { RunRecord } from "../engine/run-registry.ts";
|
|
12
|
+
import { fleetRow, renderBgRow, type BgRunStatus } from "./rows.ts";
|
|
13
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
14
|
+
|
|
15
|
+
export interface FleetItemSources {
|
|
16
|
+
runRegistry: { list(): RunRecord[] };
|
|
17
|
+
bgRuns?: { values(): IterableIterator<BgRunStatus> };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildFleetItems(src: FleetItemSources): SelectItem[] {
|
|
21
|
+
const items: SelectItem[] = [];
|
|
22
|
+
const seen = new Set<string>();
|
|
23
|
+
for (const r of src.runRegistry.list()) {
|
|
24
|
+
if (seen.has(r.runId)) continue;
|
|
25
|
+
seen.add(r.runId);
|
|
26
|
+
items.push({ value: r.runId, label: fleetRow(r) });
|
|
27
|
+
}
|
|
28
|
+
if (src.bgRuns) {
|
|
29
|
+
for (const b of src.bgRuns.values()) {
|
|
30
|
+
if (seen.has(b.runId)) continue;
|
|
31
|
+
seen.add(b.runId);
|
|
32
|
+
items.push({ value: b.runId, label: renderBgRow(b) });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return items;
|
|
36
|
+
}
|