@getpipher/armory-fleet 0.11.0 → 0.12.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/concurrency-lock.ts +20 -15
- package/src/engine/spawnSubagent.ts +8 -2
- package/src/index.ts +112 -8
- package/src/panel/fleet-panel.ts +165 -11
- package/src/runtime/async-runner.ts +79 -25
- package/src/runtime/reconcile.ts +12 -9
- package/src/runtime/resume.ts +21 -15
- package/src/runtime/run-journal.ts +2 -2
- package/src/scheduling/scheduler.ts +4 -0
- package/src/tools/fleet.ts +179 -0
- package/src/tools/subagent.ts +8 -2
- package/src/workflows/builtin/adversarial-review.js +19 -0
- package/src/workflows/builtin/code-review.js +13 -0
- package/src/workflows/builtin/codebase-audit.js +16 -0
- package/src/workflows/builtin/deep-research.js +12 -0
- package/src/workflows/builtin/multi-perspective.js +17 -0
- package/src/workflows/helpers/checkpoint.ts +15 -0
- package/src/workflows/helpers/completeness-check.ts +18 -0
- package/src/workflows/helpers/gate.ts +22 -0
- package/src/workflows/helpers/index.ts +8 -0
- package/src/workflows/helpers/judge-panel.ts +33 -0
- package/src/workflows/helpers/loop-until-dry.ts +21 -0
- package/src/workflows/helpers/retry.ts +17 -0
- package/src/workflows/helpers/types.ts +19 -0
- package/src/workflows/helpers/verify.ts +27 -0
- package/src/workflows/journal.ts +76 -0
- package/src/workflows/keyword.ts +22 -0
- package/src/workflows/panel/workflows-items.ts +150 -0
- package/src/workflows/panel/workflows-rows.ts +3 -0
- package/src/workflows/panel-host.ts +179 -0
- package/src/workflows/registry.ts +68 -0
- package/src/workflows/runner.ts +507 -0
- package/src/workflows/runtime/adapters.ts +182 -0
- package/src/workflows/runtime/controller.ts +493 -0
- package/src/workflows/runtime/hydrate.ts +116 -0
- package/src/workflows/runtime/pause-gate.ts +41 -0
- package/src/workflows/runtime/run-store.ts +31 -0
- package/src/workflows/runtime/save.ts +111 -0
- package/src/workflows/runtime/types.ts +78 -0
- package/src/workflows/source.ts +156 -0
- package/src/workflows/vm-realm.ts +106 -0
- package/src/worktree/worktree-service.ts +10 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// src/workflows/runtime/adapters.ts — SPEC-6-3 production spawn/lifecycle adapters.
|
|
2
|
+
// Wires real spawnSubagent + runLifecycle into WorkflowRunDeps via a per-workflow concurrency pool.
|
|
3
|
+
// Each admitted child gets a fresh SingleSlotLock (NOT the extension's foreground singleton).
|
|
4
|
+
import { ConcurrencyPool } from "../../runtime/concurrency-pool.ts"
|
|
5
|
+
import { SingleSlotLock } from "../../engine/concurrency-lock.ts"
|
|
6
|
+
import type { SpawnOptions, SpawnResult } from "../../engine/spawnSubagent.ts"
|
|
7
|
+
import type {
|
|
8
|
+
LifecycleRunResult,
|
|
9
|
+
LifecycleRunOpts,
|
|
10
|
+
PhaseSpawnOpts,
|
|
11
|
+
CheckpointFn,
|
|
12
|
+
} from "../../lifecycle/run-lifecycle.ts"
|
|
13
|
+
|
|
14
|
+
/** The deps the adapter needs from index.ts (Task 13). Tests inject spawnSubagentFn + runLifecycleFn. */
|
|
15
|
+
export interface WorkflowAdapterBase {
|
|
16
|
+
registry: Map<string, unknown>
|
|
17
|
+
todoSync: unknown
|
|
18
|
+
runRegistry: unknown
|
|
19
|
+
backendRegistry: unknown
|
|
20
|
+
parentModel: { provider: string; id: string }
|
|
21
|
+
parentCwd: string
|
|
22
|
+
runLog?: unknown
|
|
23
|
+
tierRegistry?: unknown
|
|
24
|
+
modelRegistry?: unknown
|
|
25
|
+
lifecycleDeps: unknown
|
|
26
|
+
spawnSubagentFn: (opts: SpawnOptions) => Promise<SpawnResult>
|
|
27
|
+
runLifecycleFn: (task: string, name: string, opts: LifecycleRunOpts) => Promise<LifecycleRunResult>
|
|
28
|
+
/** Optional bridge for lifecycle checkpoint decisions (maps to the runner's deps.onCheckpoint). */
|
|
29
|
+
onCheckpointBridge?: (prompt: string, opts: Record<string, unknown>) => Promise<unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WorkflowAdapterOpts {
|
|
33
|
+
concurrency: number
|
|
34
|
+
signal: AbortSignal
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type WorkflowSpawnResult = {
|
|
38
|
+
finalText: string
|
|
39
|
+
runId: string
|
|
40
|
+
status: "completed" | "failed"
|
|
41
|
+
costTotal?: number
|
|
42
|
+
tokenTotal?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type WorkflowLifecycleResult = {
|
|
46
|
+
status: "completed" | "failed" | "aborted"
|
|
47
|
+
finalText: string
|
|
48
|
+
costTotal?: number
|
|
49
|
+
tokenTotal?: number
|
|
50
|
+
error?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createWorkflowAdapters(
|
|
54
|
+
base: WorkflowAdapterBase,
|
|
55
|
+
opts: WorkflowAdapterOpts,
|
|
56
|
+
): Pick<import("../runner.ts").WorkflowRunDeps, "spawn" | "runLifecycle"> {
|
|
57
|
+
const pool = new ConcurrencyPool(Math.min(Math.max(opts.concurrency, 1), 16))
|
|
58
|
+
|
|
59
|
+
/** Build the common SpawnOptions fields shared by direct spawns and lifecycle phase spawns. */
|
|
60
|
+
const commonSpawnFields = (lock: SingleSlotLock, signal: AbortSignal) => ({
|
|
61
|
+
lock,
|
|
62
|
+
signal,
|
|
63
|
+
registry: base.registry as SpawnOptions["registry"],
|
|
64
|
+
todoSync: base.todoSync as SpawnOptions["todoSync"],
|
|
65
|
+
runRegistry: base.runRegistry as SpawnOptions["runRegistry"],
|
|
66
|
+
backendRegistry: base.backendRegistry as SpawnOptions["backendRegistry"],
|
|
67
|
+
parentModel: base.parentModel,
|
|
68
|
+
parentCwd: base.parentCwd,
|
|
69
|
+
...(base.runLog ? { runLog: base.runLog as SpawnOptions["runLog"] } : {}),
|
|
70
|
+
...(base.tierRegistry ? { tierRegistry: base.tierRegistry as SpawnOptions["tierRegistry"] } : {}),
|
|
71
|
+
...(base.modelRegistry ? { modelRegistry: base.modelRegistry as SpawnOptions["modelRegistry"] } : {}),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const combineSignal = (timeoutMs?: number): AbortSignal => {
|
|
75
|
+
const signals: AbortSignal[] = [opts.signal]
|
|
76
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
77
|
+
signals.push(AbortSignal.timeout(timeoutMs))
|
|
78
|
+
}
|
|
79
|
+
return AbortSignal.any(signals)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const spawn: import("../runner.ts").WorkflowRunDeps["spawn"] = async (prompt, spawnOpts) => {
|
|
83
|
+
return pool.withSlot(async () => {
|
|
84
|
+
const lock = new SingleSlotLock()
|
|
85
|
+
const combined = combineSignal(spawnOpts.timeoutMs)
|
|
86
|
+
|
|
87
|
+
const result = await base.spawnSubagentFn({
|
|
88
|
+
agent: spawnOpts.agent,
|
|
89
|
+
task: prompt,
|
|
90
|
+
...(spawnOpts.model ? { model: spawnOpts.model } : {}),
|
|
91
|
+
...(spawnOpts.tier ? { tierOverride: spawnOpts.tier } : {}),
|
|
92
|
+
...(spawnOpts.skills ? { skillsOverride: spawnOpts.skills } : {}),
|
|
93
|
+
...(spawnOpts.backend ? { backendOverride: spawnOpts.backend } : {}),
|
|
94
|
+
...commonSpawnFields(lock, combined),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
finalText: result.finalText,
|
|
99
|
+
runId: result.runId,
|
|
100
|
+
status: result.status === "completed" ? "completed" : "failed",
|
|
101
|
+
...(result.costTotal != null ? { costTotal: result.costTotal } : {}),
|
|
102
|
+
...(result.tokenTotal != null ? { tokenTotal: result.tokenTotal } : {}),
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Lifecycle phase spawn: runs through the SAME workflow pool with a FRESH lock.
|
|
109
|
+
* Delegates to base.spawnSubagentFn (NOT the opaque lifecycleDeps.spawn) so phase
|
|
110
|
+
* spawns respect workflow concurrency and never reuse the foreground singleton.
|
|
111
|
+
*/
|
|
112
|
+
const lifecycleSpawn = async (o: PhaseSpawnOpts): Promise<SpawnResult> => {
|
|
113
|
+
return pool.withSlot(async () => {
|
|
114
|
+
const lock = new SingleSlotLock()
|
|
115
|
+
const combined = combineSignal()
|
|
116
|
+
|
|
117
|
+
return base.spawnSubagentFn({
|
|
118
|
+
agent: o.agent,
|
|
119
|
+
task: o.task,
|
|
120
|
+
...(o.lifecycleTodoId ? { lifecycleTodoId: o.lifecycleTodoId } : {}),
|
|
121
|
+
...(o.model ? { model: o.model } : {}),
|
|
122
|
+
...(o.skills ? { skillsOverride: o.skills } : {}),
|
|
123
|
+
...(o.backend ? { backendOverride: o.backend } : {}),
|
|
124
|
+
...commonSpawnFields(lock, combined),
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Bridge lifecycle checkpoint decisions to the workflow runner's onCheckpoint.
|
|
131
|
+
* When no bridge is provided, auto-continue (the v1 headless default).
|
|
132
|
+
*/
|
|
133
|
+
const lifecycleOnCheckpoint: CheckpointFn = async (phase, gateResults) => {
|
|
134
|
+
if (!base.onCheckpointBridge) {
|
|
135
|
+
return { action: "continue" as const }
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const bridgeResult = await base.onCheckpointBridge(
|
|
139
|
+
`Checkpoint: ${phase.name}`,
|
|
140
|
+
{ phase: phase.name, status: phase.status, summary: phase.summary, gateResults },
|
|
141
|
+
)
|
|
142
|
+
if (bridgeResult === false || bridgeResult === "abort") {
|
|
143
|
+
return { action: "abort" as const }
|
|
144
|
+
}
|
|
145
|
+
return { action: "continue" as const }
|
|
146
|
+
} catch {
|
|
147
|
+
return { action: "abort" as const }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const runLifecycle: import("../runner.ts").WorkflowRunDeps["runLifecycle"] = async (
|
|
152
|
+
task,
|
|
153
|
+
name,
|
|
154
|
+
lcOpts,
|
|
155
|
+
) => {
|
|
156
|
+
return pool.withSlot(async () => {
|
|
157
|
+
const result = await base.runLifecycleFn(task, name, {
|
|
158
|
+
deps: {
|
|
159
|
+
...(base.lifecycleDeps as LifecycleRunOpts["deps"]),
|
|
160
|
+
spawn: lifecycleSpawn,
|
|
161
|
+
},
|
|
162
|
+
mode: lcOpts.mode,
|
|
163
|
+
onCheckpoint: lifecycleOnCheckpoint,
|
|
164
|
+
...(lcOpts.worktreePath ? { worktreePath: lcOpts.worktreePath } : {}),
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const finalText = result.phases.length > 0
|
|
168
|
+
? (result.phases[result.phases.length - 1]?.summary ?? result.error ?? "")
|
|
169
|
+
: (result.error ?? "")
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
status: result.status,
|
|
173
|
+
finalText,
|
|
174
|
+
...(result.error ? { error: result.error } : {}),
|
|
175
|
+
...((result as unknown as Record<string, unknown>).costTotal != null ? { costTotal: (result as unknown as Record<string, unknown>).costTotal as number } : {}),
|
|
176
|
+
...((result as unknown as Record<string, unknown>).tokenTotal != null ? { tokenTotal: (result as unknown as Record<string, unknown>).tokenTotal as number } : {}),
|
|
177
|
+
} as WorkflowLifecycleResult
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { spawn, runLifecycle }
|
|
182
|
+
}
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// SPEC-6-3 §6/§7 — session-scoped workflow controller. Owns starts, background completion,
|
|
2
|
+
// ResultsInbox delivery, atomic Save-as, and the control state machine (pause/resume/stop/
|
|
3
|
+
// respondToCheckpoint). index.ts (Task 13) just constructs this.
|
|
4
|
+
import type { WorkflowRunStore } from "./run-store.ts"
|
|
5
|
+
import type { WorkflowJournal } from "../journal.ts"
|
|
6
|
+
import type { ResultsInbox, RunResult } from "../../runtime/results-inbox.ts"
|
|
7
|
+
import type {
|
|
8
|
+
WorkflowRunDeps,
|
|
9
|
+
WorkflowRunOpts,
|
|
10
|
+
WorkflowRunResult,
|
|
11
|
+
} from "../runner.ts"
|
|
12
|
+
import type {
|
|
13
|
+
WorkflowStartInput,
|
|
14
|
+
WorkflowStartReceipt,
|
|
15
|
+
WorkflowRunState,
|
|
16
|
+
WorkflowSaveInput,
|
|
17
|
+
WorkflowProgressEvent,
|
|
18
|
+
} from "./types.ts"
|
|
19
|
+
import type { WorkflowDef, WorkflowRegistry } from "../registry.ts"
|
|
20
|
+
import { parseWorkflowSource } from "../source.ts"
|
|
21
|
+
import { saveWorkflowAtomic } from "./save.ts"
|
|
22
|
+
import { discoverWorkflows } from "../registry.ts"
|
|
23
|
+
import { PauseGate } from "./pause-gate.ts"
|
|
24
|
+
import { hydrateWorkflowRuns } from "./hydrate.ts"
|
|
25
|
+
|
|
26
|
+
export interface WorkflowControllerDeps {
|
|
27
|
+
registry: WorkflowRegistry
|
|
28
|
+
projectDir: string
|
|
29
|
+
store: WorkflowRunStore
|
|
30
|
+
journal: WorkflowJournal
|
|
31
|
+
runWorkflow: (
|
|
32
|
+
script: string,
|
|
33
|
+
opts: WorkflowRunOpts,
|
|
34
|
+
deps: WorkflowRunDeps,
|
|
35
|
+
) => Promise<WorkflowRunResult>
|
|
36
|
+
runDepsFactory: (runId: string) => WorkflowRunDeps
|
|
37
|
+
inbox: ResultsInbox
|
|
38
|
+
genRunId: () => string
|
|
39
|
+
notify: (msg: string, level?: "info" | "warning" | "error") => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SUMMARY_BOUND = 500
|
|
43
|
+
|
|
44
|
+
function safeSummary(value: unknown): string {
|
|
45
|
+
let text: string
|
|
46
|
+
try {
|
|
47
|
+
text = JSON.stringify(value) ?? String(value)
|
|
48
|
+
} catch {
|
|
49
|
+
text = String(value)
|
|
50
|
+
}
|
|
51
|
+
return text.slice(0, SUMMARY_BOUND)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface RunControls {
|
|
55
|
+
abort: AbortController
|
|
56
|
+
gate: PauseGate
|
|
57
|
+
checkpointResolver: ((response: unknown) => void) | undefined
|
|
58
|
+
sourceText: string | undefined
|
|
59
|
+
executable: string
|
|
60
|
+
input: WorkflowStartInput
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class WorkflowController {
|
|
64
|
+
private readonly active = new Map<string, Promise<WorkflowRunResult>>()
|
|
65
|
+
private readonly controls = new Map<string, RunControls>()
|
|
66
|
+
|
|
67
|
+
constructor(private readonly deps: WorkflowControllerDeps) {}
|
|
68
|
+
|
|
69
|
+
definitions(): WorkflowDef[] {
|
|
70
|
+
return this.deps.registry.list()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
runs(): WorkflowRunState[] {
|
|
74
|
+
return this.deps.store.values()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
getRun(runId: string): WorkflowRunState | undefined {
|
|
78
|
+
return this.deps.store.get(runId)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
hydrate(): void {
|
|
82
|
+
hydrateWorkflowRuns(this.deps.journal, this.deps.store)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
start(
|
|
86
|
+
input: WorkflowStartInput,
|
|
87
|
+
_ctx?: { signal?: AbortSignal },
|
|
88
|
+
): Promise<WorkflowStartReceipt | WorkflowRunResult> {
|
|
89
|
+
return this.startInternal(input)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async startInternal(
|
|
93
|
+
input: WorkflowStartInput,
|
|
94
|
+
): Promise<WorkflowStartReceipt | WorkflowRunResult> {
|
|
95
|
+
const hasScript = input.script !== undefined
|
|
96
|
+
const hasName = input.workflowName !== undefined
|
|
97
|
+
|
|
98
|
+
if (hasScript && hasName) {
|
|
99
|
+
throw new Error("provide exactly one of script or workflowName")
|
|
100
|
+
}
|
|
101
|
+
if (!hasScript && !hasName) {
|
|
102
|
+
throw new Error("exactly one of script or workflowName is required")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const runId = this.deps.genRunId()
|
|
106
|
+
const background = input.background !== false
|
|
107
|
+
|
|
108
|
+
let executable: string
|
|
109
|
+
let sourceText: string | undefined
|
|
110
|
+
let displayName: string
|
|
111
|
+
|
|
112
|
+
if (input.script !== undefined) {
|
|
113
|
+
if (input.name) {
|
|
114
|
+
this.save({ name: input.name, source: input.script })
|
|
115
|
+
}
|
|
116
|
+
const parsed = parseWorkflowSource(input.script, {
|
|
117
|
+
filePath: `${input.name ?? runId}.js`,
|
|
118
|
+
requireMeta: false,
|
|
119
|
+
})
|
|
120
|
+
executable = parsed.executable
|
|
121
|
+
sourceText = parsed.source
|
|
122
|
+
displayName = input.name ?? runId
|
|
123
|
+
} else if (input.workflowName !== undefined) {
|
|
124
|
+
const def = this.deps.registry.get(input.workflowName)
|
|
125
|
+
if (!def) {
|
|
126
|
+
const available = this.deps.registry.list().map((w) => w.name).join(", ")
|
|
127
|
+
throw new Error(
|
|
128
|
+
`workflow '${input.workflowName}' not found; available: ${available}`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
executable = def.executable
|
|
132
|
+
sourceText = def.sourceText
|
|
133
|
+
displayName = input.workflowName
|
|
134
|
+
} else {
|
|
135
|
+
throw new Error("exactly one of script or workflowName is required")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const now = Date.now()
|
|
139
|
+
const state: WorkflowRunState = {
|
|
140
|
+
runId,
|
|
141
|
+
name: displayName,
|
|
142
|
+
script: sourceText ?? executable,
|
|
143
|
+
...(input.args !== undefined ? { args: input.args } : {}),
|
|
144
|
+
mode: input.mode,
|
|
145
|
+
status: "running",
|
|
146
|
+
startedAt: now,
|
|
147
|
+
currentPhase: "default",
|
|
148
|
+
phases: [],
|
|
149
|
+
childRunIds: [],
|
|
150
|
+
logs: [],
|
|
151
|
+
tokenTotal: 0,
|
|
152
|
+
costTotal: 0,
|
|
153
|
+
...(input.resumeFromRunId ? { resumeFromRunId: input.resumeFromRunId } : {}),
|
|
154
|
+
}
|
|
155
|
+
this.deps.store.set(runId, state)
|
|
156
|
+
|
|
157
|
+
// Per-run controls: AbortController + PauseGate + optional checkpoint resolver.
|
|
158
|
+
const abort = new AbortController()
|
|
159
|
+
const gate = new PauseGate()
|
|
160
|
+
|
|
161
|
+
const onProgress = (event: WorkflowProgressEvent): void => {
|
|
162
|
+
this.onProgress(runId, event)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The controls object is stored in the map and mutated by onCheckpoint/respondToCheckpoint.
|
|
166
|
+
const controls: RunControls = {
|
|
167
|
+
abort,
|
|
168
|
+
gate,
|
|
169
|
+
checkpointResolver: undefined,
|
|
170
|
+
sourceText,
|
|
171
|
+
executable,
|
|
172
|
+
input,
|
|
173
|
+
}
|
|
174
|
+
this.controls.set(runId, controls)
|
|
175
|
+
|
|
176
|
+
// Build runDeps: base from factory + runtime hooks merged on top.
|
|
177
|
+
const baseRunDeps = this.deps.runDepsFactory(runId)
|
|
178
|
+
const runDeps: WorkflowRunDeps = {
|
|
179
|
+
...baseRunDeps,
|
|
180
|
+
runtime: {
|
|
181
|
+
signal: abort.signal,
|
|
182
|
+
waitIfPaused: () => gate.wait(abort.signal),
|
|
183
|
+
onProgress,
|
|
184
|
+
},
|
|
185
|
+
...(input.mode === "checkpointed"
|
|
186
|
+
? {
|
|
187
|
+
onCheckpoint: (prompt: string, opts: Record<string, unknown>) => {
|
|
188
|
+
return new Promise<unknown>((resolve) => {
|
|
189
|
+
controls.checkpointResolver = resolve
|
|
190
|
+
const existing = this.deps.store.get(runId)
|
|
191
|
+
if (existing) {
|
|
192
|
+
this.deps.store.set(runId, {
|
|
193
|
+
...existing,
|
|
194
|
+
status: "checkpoint",
|
|
195
|
+
checkpoint: { prompt, opts },
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
: {}),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const runOpts: WorkflowRunOpts = {
|
|
205
|
+
script: executable,
|
|
206
|
+
...(sourceText ? { sourceText } : {}),
|
|
207
|
+
...(input.args !== undefined ? { args: input.args } : {}),
|
|
208
|
+
runId,
|
|
209
|
+
...(input.resumeFromRunId ? { resumeFromRunId: input.resumeFromRunId } : {}),
|
|
210
|
+
mode: input.mode,
|
|
211
|
+
...(input.tokenBudget !== undefined ? { budget: { total: input.tokenBudget } } : {}),
|
|
212
|
+
...(input.maxAgents !== undefined ? { maxAgents: input.maxAgents } : {}),
|
|
213
|
+
...(input.concurrency !== undefined ? { concurrency: input.concurrency } : {}),
|
|
214
|
+
...(input.agentRetries !== undefined ? { agentRetries: input.agentRetries } : {}),
|
|
215
|
+
...(input.agentTimeoutMs !== undefined ? { agentTimeoutMs: input.agentTimeoutMs } : {}),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (background) {
|
|
219
|
+
const promise = this.execute(state, runOpts, runDeps)
|
|
220
|
+
this.active.set(runId, promise)
|
|
221
|
+
void promise.finally(() => {
|
|
222
|
+
this.active.delete(runId)
|
|
223
|
+
// Keep controls until after terminal so stop() can reach them;
|
|
224
|
+
// but if the run settled naturally, clean up.
|
|
225
|
+
const ctrl = this.controls.get(runId)
|
|
226
|
+
if (ctrl && !ctrl.abort.signal.aborted) {
|
|
227
|
+
this.controls.delete(runId)
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
return { runId, status: "background" }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return this.execute(state, runOpts, runDeps)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async execute(
|
|
237
|
+
state: WorkflowRunState,
|
|
238
|
+
runOpts: WorkflowRunOpts,
|
|
239
|
+
runDeps: WorkflowRunDeps,
|
|
240
|
+
): Promise<WorkflowRunResult> {
|
|
241
|
+
try {
|
|
242
|
+
const result = await this.deps.runWorkflow(runOpts.script, runOpts, runDeps)
|
|
243
|
+
this.onTerminal(result, state)
|
|
244
|
+
return result
|
|
245
|
+
} catch (e) {
|
|
246
|
+
const result: WorkflowRunResult = {
|
|
247
|
+
runId: state.runId,
|
|
248
|
+
status: "aborted",
|
|
249
|
+
error: (e as Error).message,
|
|
250
|
+
phases: [],
|
|
251
|
+
childRunIds: [],
|
|
252
|
+
logs: [],
|
|
253
|
+
}
|
|
254
|
+
this.onTerminal(result, state)
|
|
255
|
+
return result
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private onProgress(runId: string, event: WorkflowProgressEvent): void {
|
|
260
|
+
const existing = this.deps.store.get(runId)
|
|
261
|
+
if (!existing) return
|
|
262
|
+
|
|
263
|
+
// Don't overwrite terminal statuses set by onTerminal or stop().
|
|
264
|
+
if (
|
|
265
|
+
existing.status === "completed" ||
|
|
266
|
+
existing.status === "aborted" ||
|
|
267
|
+
existing.status === "failed"
|
|
268
|
+
) {
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Don't overwrite control-set statuses (paused, checkpoint) with runner progress.
|
|
273
|
+
// Patch non-status fields from the snapshot but preserve the control status.
|
|
274
|
+
if (existing.status === "paused" || existing.status === "checkpoint") {
|
|
275
|
+
this.deps.store.set(runId, {
|
|
276
|
+
...existing,
|
|
277
|
+
...event.snapshot,
|
|
278
|
+
status: existing.status,
|
|
279
|
+
...(existing.checkpoint ? { checkpoint: existing.checkpoint } : {}),
|
|
280
|
+
})
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// For "running" status, patch with the runner's snapshot.
|
|
285
|
+
this.deps.store.set(runId, {
|
|
286
|
+
...existing,
|
|
287
|
+
...event.snapshot,
|
|
288
|
+
status: event.snapshot.status,
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private onTerminal(result: WorkflowRunResult, state: WorkflowRunState): void {
|
|
293
|
+
const existing = this.deps.store.get(result.runId)
|
|
294
|
+
if (!existing) return
|
|
295
|
+
|
|
296
|
+
// Don't overwrite an aborted status set by stop() with a late completion.
|
|
297
|
+
if (existing.status === "aborted" && result.status === "completed") {
|
|
298
|
+
this.controls.delete(result.runId)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Don't overwrite a checkpoint status — the run is awaiting a human response.
|
|
303
|
+
// The runner shouldn't reach terminal while blocked on a checkpoint, but defend anyway.
|
|
304
|
+
if (existing.status === "checkpoint") {
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const status: WorkflowRunState["status"] =
|
|
309
|
+
result.status === "completed"
|
|
310
|
+
? "completed"
|
|
311
|
+
: result.status === "aborted"
|
|
312
|
+
? "aborted"
|
|
313
|
+
: "failed"
|
|
314
|
+
|
|
315
|
+
this.deps.store.set(result.runId, {
|
|
316
|
+
...existing,
|
|
317
|
+
status,
|
|
318
|
+
endedAt: Date.now(),
|
|
319
|
+
...(result.result !== undefined ? { result: result.result } : {}),
|
|
320
|
+
...(result.error ? { error: result.error } : {}),
|
|
321
|
+
...(result.costTotal !== undefined ? { costTotal: result.costTotal } : {}),
|
|
322
|
+
...(result.tokenTotal !== undefined ? { tokenTotal: result.tokenTotal } : {}),
|
|
323
|
+
phases: result.phases,
|
|
324
|
+
childRunIds: result.childRunIds,
|
|
325
|
+
logs: result.logs,
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
const inboxResult: RunResult = {
|
|
329
|
+
runId: result.runId,
|
|
330
|
+
task: state.name,
|
|
331
|
+
status: result.status === "completed" ? "completed" : "failed",
|
|
332
|
+
summary: safeSummary(result.result ?? result.error ?? result.status),
|
|
333
|
+
paths: [],
|
|
334
|
+
completedAt: Date.now(),
|
|
335
|
+
}
|
|
336
|
+
this.deps.inbox.push(inboxResult)
|
|
337
|
+
this.controls.delete(result.runId)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Control state machine ──
|
|
341
|
+
|
|
342
|
+
pause(runId: string): void {
|
|
343
|
+
const run = this.deps.store.get(runId)
|
|
344
|
+
if (!run) throw new Error(`cannot pause: run '${runId}' not found`)
|
|
345
|
+
const status = run.status
|
|
346
|
+
if (status === "paused") return // idempotent
|
|
347
|
+
if (status !== "running" && status !== "queued") {
|
|
348
|
+
throw new Error(`cannot pause run '${runId}' in status '${status}'`)
|
|
349
|
+
}
|
|
350
|
+
const ctrl = this.controls.get(runId)
|
|
351
|
+
if (ctrl) ctrl.gate.pause()
|
|
352
|
+
this.deps.store.set(runId, { ...run, status: "paused" })
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
resume(runId: string): Promise<WorkflowStartReceipt | WorkflowRunResult> {
|
|
356
|
+
const run = this.deps.store.get(runId)
|
|
357
|
+
if (!run) throw new Error(`cannot resume: run '${runId}' not found`)
|
|
358
|
+
const status = run.status
|
|
359
|
+
|
|
360
|
+
if (status === "running") return Promise.resolve({ runId, status: "background" as const })
|
|
361
|
+
if (status === "interrupted") {
|
|
362
|
+
// Start a NEW background run with original source + resumeFromRunId.
|
|
363
|
+
const ctrl = this.controls.get(runId)
|
|
364
|
+
const source = ctrl?.sourceText ?? run.script
|
|
365
|
+
return this.startInternal({
|
|
366
|
+
script: source,
|
|
367
|
+
mode: run.mode,
|
|
368
|
+
resumeFromRunId: runId,
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
if (status !== "paused") {
|
|
372
|
+
throw new Error(`cannot resume run '${runId}' in status '${status}'`)
|
|
373
|
+
}
|
|
374
|
+
const ctrl = this.controls.get(runId)
|
|
375
|
+
if (ctrl) ctrl.gate.resume()
|
|
376
|
+
this.deps.store.set(runId, { ...run, status: "running" })
|
|
377
|
+
return Promise.resolve({ runId, status: "background" as const })
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async stop(runId: string): Promise<void> {
|
|
381
|
+
const run = this.deps.store.get(runId)
|
|
382
|
+
if (!run) throw new Error(`cannot stop: run '${runId}' not found`)
|
|
383
|
+
const status = run.status
|
|
384
|
+
|
|
385
|
+
if (status === "aborted") return // idempotent
|
|
386
|
+
|
|
387
|
+
if (
|
|
388
|
+
status !== "running" &&
|
|
389
|
+
status !== "queued" &&
|
|
390
|
+
status !== "paused" &&
|
|
391
|
+
status !== "checkpoint" &&
|
|
392
|
+
status !== "interrupted"
|
|
393
|
+
) {
|
|
394
|
+
throw new Error(`cannot stop run '${runId}' in status '${status}'`)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const ctrl = this.controls.get(runId)
|
|
398
|
+
|
|
399
|
+
// Reject checkpoint waiters.
|
|
400
|
+
if (ctrl?.checkpointResolver) {
|
|
401
|
+
ctrl.checkpointResolver(undefined)
|
|
402
|
+
ctrl.checkpointResolver = undefined
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Abort the run signal (fires for live children).
|
|
406
|
+
if (ctrl) ctrl.abort.abort()
|
|
407
|
+
|
|
408
|
+
// Resume paused waiters so they observe the abort.
|
|
409
|
+
if (ctrl) ctrl.gate.resume()
|
|
410
|
+
|
|
411
|
+
// Set store status to aborted immediately.
|
|
412
|
+
this.deps.store.set(runId, { ...run, status: "aborted", endedAt: Date.now() })
|
|
413
|
+
|
|
414
|
+
// For interrupted runs (no live controller), journal the abort directly —
|
|
415
|
+
// the runner isn't executing and won't write wf:aborted itself.
|
|
416
|
+
if (!ctrl) {
|
|
417
|
+
this.deps.journal.append(runId, {
|
|
418
|
+
type: "wf:aborted",
|
|
419
|
+
runId,
|
|
420
|
+
reason: "stopped",
|
|
421
|
+
ts: Date.now(),
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Await settlement if there's an active promise.
|
|
426
|
+
await this.settled(runId)
|
|
427
|
+
|
|
428
|
+
// Clean up controls.
|
|
429
|
+
this.controls.delete(runId)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
respondToCheckpoint(runId: string, response: unknown): void {
|
|
433
|
+
const run = this.deps.store.get(runId)
|
|
434
|
+
if (!run) throw new Error(`cannot respond to checkpoint: run '${runId}' not found`)
|
|
435
|
+
if (run.status !== "checkpoint") {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`cannot respond to checkpoint for run '${runId}' in status '${run.status}'`,
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const ctrl = this.controls.get(runId)
|
|
442
|
+
if (!ctrl || !ctrl.checkpointResolver) {
|
|
443
|
+
throw new Error(`no pending checkpoint for run '${runId}'`)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Resolve the pending checkpoint Promise + clear pending state.
|
|
447
|
+
ctrl.checkpointResolver(response)
|
|
448
|
+
ctrl.checkpointResolver = undefined
|
|
449
|
+
|
|
450
|
+
// Restore running status.
|
|
451
|
+
this.deps.store.set(runId, { ...run, status: "running", checkpoint: undefined })
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async settled(runId: string): Promise<WorkflowRunResult | undefined> {
|
|
455
|
+
const promise = this.active.get(runId)
|
|
456
|
+
if (promise) return promise
|
|
457
|
+
return undefined
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
save(input: WorkflowSaveInput): WorkflowDef {
|
|
461
|
+
const saved = saveWorkflowAtomic({ ...input, dir: this.deps.projectDir })
|
|
462
|
+
this.refreshRegistry()
|
|
463
|
+
return saved
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
editAndResume(
|
|
467
|
+
runId: string,
|
|
468
|
+
source: string,
|
|
469
|
+
modeOverride?: "auto" | "checkpointed",
|
|
470
|
+
): Promise<WorkflowStartReceipt | WorkflowRunResult> {
|
|
471
|
+
const existing = this.deps.store.get(runId)
|
|
472
|
+
if (!existing) throw new Error(`run '${runId}' not found`)
|
|
473
|
+
return this.startInternal({
|
|
474
|
+
script: source,
|
|
475
|
+
name: existing.name !== existing.runId ? existing.name : undefined,
|
|
476
|
+
mode: modeOverride ?? existing.mode,
|
|
477
|
+
resumeFromRunId: runId,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private refreshRegistry(): void {
|
|
482
|
+
const result = discoverWorkflows({
|
|
483
|
+
projectDir: this.deps.projectDir,
|
|
484
|
+
globalDir: "",
|
|
485
|
+
builtinDir: "",
|
|
486
|
+
})
|
|
487
|
+
const existing = this.deps.registry.list()
|
|
488
|
+
const merged = new Map<string, WorkflowDef>()
|
|
489
|
+
for (const w of existing) merged.set(w.name, w)
|
|
490
|
+
for (const w of result.workflows.values()) merged.set(w.name, w)
|
|
491
|
+
this.deps.registry.replace([...merged.values()])
|
|
492
|
+
}
|
|
493
|
+
}
|