@juicesharp/rpiv-workflow 1.14.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/LICENSE +21 -0
- package/README.md +449 -0
- package/api.ts +557 -0
- package/audit.ts +217 -0
- package/built-ins.ts +65 -0
- package/command.ts +137 -0
- package/docs/cover.png +0 -0
- package/docs/cover.svg +120 -0
- package/docs/workflow-authoring.md +629 -0
- package/docs/workflow-basics.md +122 -0
- package/docs-protocol.ts +106 -0
- package/fanout.ts +96 -0
- package/host.ts +97 -0
- package/index.ts +230 -0
- package/internal-utils.ts +69 -0
- package/internal.ts +27 -0
- package/layers.ts +33 -0
- package/lifecycle.ts +274 -0
- package/load/cache.test.ts +82 -0
- package/load/cache.ts +40 -0
- package/load/index.ts +159 -0
- package/load/merge.ts +136 -0
- package/load/normalize.ts +73 -0
- package/load/paths.ts +32 -0
- package/load/resolve-default.ts +43 -0
- package/load/shape-guards.test.ts +74 -0
- package/load/shape-guards.ts +42 -0
- package/messages.ts +185 -0
- package/outcomes/collectors/directory-path.test.ts +64 -0
- package/outcomes/collectors/directory-path.ts +40 -0
- package/outcomes/collectors/index.ts +21 -0
- package/outcomes/collectors/tool-call.test.ts +110 -0
- package/outcomes/collectors/tool-call.ts +63 -0
- package/outcomes/collectors/transcript-path.test.ts +70 -0
- package/outcomes/collectors/transcript-path.ts +53 -0
- package/outcomes/collectors/union.test.ts +59 -0
- package/outcomes/collectors/union.ts +55 -0
- package/outcomes/collectors/url.test.ts +67 -0
- package/outcomes/collectors/url.ts +45 -0
- package/outcomes/collectors/workspace-diff.test.ts +107 -0
- package/outcomes/collectors/workspace-diff.ts +123 -0
- package/outcomes/git-commit.test.ts +194 -0
- package/outcomes/git-commit.ts +192 -0
- package/outcomes/index.ts +22 -0
- package/outcomes/parsers/index.ts +11 -0
- package/outcomes/parsers/json-body.test.ts +80 -0
- package/outcomes/parsers/json-body.ts +50 -0
- package/outcomes/side-effect.ts +26 -0
- package/output-spec.ts +170 -0
- package/output.ts +98 -0
- package/package.json +83 -0
- package/preview.ts +120 -0
- package/routing.ts +79 -0
- package/runner/chain-advance.ts +185 -0
- package/runner/index.ts +7 -0
- package/runner/runner.ts +356 -0
- package/runner/script-stage.ts +240 -0
- package/runner/stage-lifecycle.ts +447 -0
- package/sessions/extraction.ts +297 -0
- package/sessions/index.ts +7 -0
- package/sessions/sessions.ts +269 -0
- package/sessions/spawn.ts +135 -0
- package/state/index.ts +27 -0
- package/state/paths.ts +46 -0
- package/state/reads.ts +190 -0
- package/state/state.ts +115 -0
- package/state/writes.ts +58 -0
- package/transcript.ts +156 -0
- package/triggers.ts +27 -0
- package/typebox-adapter.ts +48 -0
- package/types.ts +237 -0
- package/validate-output.ts +120 -0
- package/validate-workflow.ts +491 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output production + validation retry loop. Sits between the
|
|
3
|
+
* post-session classifier (which decides "stage finished cleanly?") and
|
|
4
|
+
* the persistence helpers ("record this stage").
|
|
5
|
+
*
|
|
6
|
+
* Public entry: `produceAndValidateOutput`. Returns a tagged outcome
|
|
7
|
+
* — `ok` with the output, `fatal` (halt with a wording the
|
|
8
|
+
* collector/parser supplied), or `validation-exhausted` (halt after the
|
|
9
|
+
* retry budget tripped without a passing schema).
|
|
10
|
+
*
|
|
11
|
+
* The two-step contract:
|
|
12
|
+
* 1. `outcome.collector.collect(ctx)` — enumerate artifacts.
|
|
13
|
+
* 2. `outcome.parser?.parse(ctx)` — shape the typed data channel
|
|
14
|
+
* (default: data = artifacts,
|
|
15
|
+
* kind = "artifacts").
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { StageDef, StageSchema } from "../api.js";
|
|
19
|
+
import { nowIso } from "../audit.js";
|
|
20
|
+
import type { Artifact } from "../handle.js";
|
|
21
|
+
import { assertNever, withTimeout } from "../internal-utils.js";
|
|
22
|
+
import { buildLifecycleContext, skillStageRef } from "../lifecycle.js";
|
|
23
|
+
import { ERR_SCHEMA_TIMEOUT, MSG_VALIDATION_RETRY, MSG_VALIDATION_RETRY_PROMPT } from "../messages.js";
|
|
24
|
+
import { sideEffectOutcome } from "../outcomes/index.js";
|
|
25
|
+
import { finalizeOutput, type Output } from "../output.js";
|
|
26
|
+
import type { CollectCtx, OutputSpec } from "../output-spec.js";
|
|
27
|
+
import { type BranchEntry, readBranch } from "../transcript.js";
|
|
28
|
+
import type { RunnerCtx, StageSession } from "../types.js";
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_VALIDATION_RETRIES,
|
|
31
|
+
DEFAULT_VALIDATION_RETRY_TIMEOUT_MS,
|
|
32
|
+
MAX_VALIDATION_RETRIES,
|
|
33
|
+
MAX_VALIDATION_RETRY_TIMEOUT_MS,
|
|
34
|
+
MIN_VALIDATION_RETRIES,
|
|
35
|
+
MIN_VALIDATION_RETRY_TIMEOUT_MS,
|
|
36
|
+
type SchemaValidationFailure,
|
|
37
|
+
type ValidationResult,
|
|
38
|
+
validateOutputData,
|
|
39
|
+
} from "../validate-output.js";
|
|
40
|
+
import { handlerFor } from "./spawn.js";
|
|
41
|
+
|
|
42
|
+
export type OutputProduction =
|
|
43
|
+
| { kind: "ok"; output: Output }
|
|
44
|
+
| { kind: "fatal"; message: string }
|
|
45
|
+
| { kind: "validation-exhausted"; failureSummary: string };
|
|
46
|
+
|
|
47
|
+
/** Retry loop re-produces against the latest branch after each fix request. */
|
|
48
|
+
export async function produceAndValidateOutput(
|
|
49
|
+
ctx: RunnerCtx,
|
|
50
|
+
s: StageSession,
|
|
51
|
+
branch: BranchEntry[],
|
|
52
|
+
branchOffset: number | undefined,
|
|
53
|
+
): Promise<OutputProduction> {
|
|
54
|
+
const outcome = resolveOutcome(s.stage, s.skill);
|
|
55
|
+
const collectCtx = buildCollectCtx(s, branch, branchOffset);
|
|
56
|
+
const finalize = (parts: { kind: string; artifacts: readonly Artifact[]; data: unknown }) => wrapOutput(s, parts);
|
|
57
|
+
|
|
58
|
+
const first = await runOutcome(outcome, collectCtx, finalize);
|
|
59
|
+
if (first.kind === "fatal") return first;
|
|
60
|
+
const initialOutput = enforceCompletionContract(s.stage, s.skill, first.output);
|
|
61
|
+
if (initialOutput.kind === "fatal") return initialOutput;
|
|
62
|
+
|
|
63
|
+
if (!shouldValidateOutput(s.stage, initialOutput.output)) return initialOutput;
|
|
64
|
+
|
|
65
|
+
return retryUntilValid(ctx, s, { outcome, collectCtx, finalize }, initialOutput.output);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Explicit `stage.outcome` wins. Defaults:
|
|
70
|
+
* - `side-effect` → `sideEffectOutcome` (universal — emits empty artifacts).
|
|
71
|
+
* - `produces` → throws. There is no framework-wide default; the
|
|
72
|
+
* `.rpiv/artifacts/<bucket>/<file>.md` layout is an rpiv-pi convention
|
|
73
|
+
* and lives in that package. `validate-workflow.ts` rejects this at
|
|
74
|
+
* load time; the runtime throw is defense-in-depth for programmatic
|
|
75
|
+
* embedders that bypassed validation.
|
|
76
|
+
*/
|
|
77
|
+
function resolveOutcome(stage: StageDef, skill: string): OutputSpec {
|
|
78
|
+
if (stage.outcome) return stage.outcome;
|
|
79
|
+
switch (stage.kind) {
|
|
80
|
+
case "side-effect":
|
|
81
|
+
return sideEffectOutcome;
|
|
82
|
+
case "produces":
|
|
83
|
+
throw new Error(
|
|
84
|
+
`runStage: stage "${skill}" has kind "produces" but no \`outcome\` — ` +
|
|
85
|
+
"there is no framework default for produces stages (the `.rpiv/artifacts/` layout is " +
|
|
86
|
+
"an rpiv-pi convention). Either wire `outcome: rpivArtifactMdOutcome` (from @juicesharp/rpiv-pi) " +
|
|
87
|
+
"or supply your own `{ collector, parser? }`.",
|
|
88
|
+
);
|
|
89
|
+
default:
|
|
90
|
+
return assertNever(stage.kind);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Contract: `branch` is always the FULL unsliced branch and
|
|
96
|
+
* `branchOffset` is always the policy-derived offset (continue → the
|
|
97
|
+
* stage's captured offset; fresh → undefined). Collectors slice on
|
|
98
|
+
* demand via the `branchOffset` field. Initial production and retry
|
|
99
|
+
* production use the same offset value.
|
|
100
|
+
*/
|
|
101
|
+
function buildCollectCtx(s: StageSession, branch: BranchEntry[], branchOffset: number | undefined): CollectCtx {
|
|
102
|
+
return {
|
|
103
|
+
cwd: s.cwd,
|
|
104
|
+
runId: s.runId,
|
|
105
|
+
stageIndex: s.stageIndex,
|
|
106
|
+
state: s.state,
|
|
107
|
+
branch,
|
|
108
|
+
branchOffset,
|
|
109
|
+
snapshot: s.snapshot,
|
|
110
|
+
skill: s.skill,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function wrapOutput(s: StageSession, parts: { kind: string; artifacts: readonly Artifact[]; data: unknown }): Output {
|
|
115
|
+
return finalizeOutput(parts, {
|
|
116
|
+
stage: s.stageName,
|
|
117
|
+
skill: s.skill,
|
|
118
|
+
stageNumber: s.state.lastAllocatedStageNumber + 1,
|
|
119
|
+
ts: nowIso(),
|
|
120
|
+
runId: s.runId,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type RunOutcomeResult = { kind: "ok"; output: Output } | { kind: "fatal"; message: string };
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The collector → parser pipeline. When `parser` is omitted, the
|
|
128
|
+
* output emits `kind: "artifacts"` with `data = artifacts` — a stage
|
|
129
|
+
* that only needs to enumerate doesn't have to write a parser.
|
|
130
|
+
*/
|
|
131
|
+
async function runOutcome(
|
|
132
|
+
outcome: OutputSpec,
|
|
133
|
+
ctx: CollectCtx,
|
|
134
|
+
finalize: (parts: { kind: string; artifacts: readonly Artifact[]; data: unknown }) => Output,
|
|
135
|
+
): Promise<RunOutcomeResult> {
|
|
136
|
+
const collected = await outcome.collector.collect(ctx);
|
|
137
|
+
if (collected.kind === "fatal") return collected;
|
|
138
|
+
|
|
139
|
+
if (!outcome.parser) {
|
|
140
|
+
return {
|
|
141
|
+
kind: "ok",
|
|
142
|
+
output: finalize({ kind: "artifacts", artifacts: collected.artifacts, data: collected.artifacts }),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const parsed = await outcome.parser.parse({ ...ctx, artifacts: collected.artifacts });
|
|
147
|
+
if (parsed.kind === "fatal") return parsed;
|
|
148
|
+
return {
|
|
149
|
+
kind: "ok",
|
|
150
|
+
output: finalize({
|
|
151
|
+
kind: parsed.payload.kind,
|
|
152
|
+
artifacts: collected.artifacts,
|
|
153
|
+
data: parsed.payload.data,
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Contract check: `produces` stages MUST emit at least one
|
|
160
|
+
* artifact. The collector/parser pair can succeed structurally
|
|
161
|
+
* (kind: "ok") with zero artifacts — that's a chain halt for
|
|
162
|
+
* `produces` (the stage promised an output and didn't deliver)
|
|
163
|
+
* but a normal pass-through for `side-effect`.
|
|
164
|
+
*/
|
|
165
|
+
function enforceCompletionContract(
|
|
166
|
+
stage: StageDef,
|
|
167
|
+
skill: string,
|
|
168
|
+
output: Output,
|
|
169
|
+
): { kind: "ok"; output: Output } | { kind: "fatal"; message: string } {
|
|
170
|
+
if (stage.kind === "produces" && output.artifacts.length === 0) {
|
|
171
|
+
return {
|
|
172
|
+
kind: "fatal",
|
|
173
|
+
message: `${skill} finished without producing any artifact (collector returned an empty list)`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { kind: "ok", output };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function shouldValidateOutput(stage: StageDef, output: Output): boolean {
|
|
180
|
+
return !!(stage.outputSchema && output.data !== undefined);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface RetryDeps {
|
|
184
|
+
outcome: OutputSpec;
|
|
185
|
+
collectCtx: CollectCtx;
|
|
186
|
+
finalize: (parts: { kind: string; artifacts: readonly Artifact[]; data: unknown }) => Output;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function retryUntilValid(
|
|
190
|
+
ctx: RunnerCtx,
|
|
191
|
+
s: StageSession,
|
|
192
|
+
deps: RetryDeps,
|
|
193
|
+
initial: Output,
|
|
194
|
+
): Promise<OutputProduction> {
|
|
195
|
+
const schema = s.stage.outputSchema!;
|
|
196
|
+
const maxRetries = Math.max(
|
|
197
|
+
MIN_VALIDATION_RETRIES,
|
|
198
|
+
Math.min(s.stage.maxRetries ?? DEFAULT_VALIDATION_RETRIES, MAX_VALIDATION_RETRIES),
|
|
199
|
+
);
|
|
200
|
+
const timeoutMs = Math.max(
|
|
201
|
+
MIN_VALIDATION_RETRY_TIMEOUT_MS,
|
|
202
|
+
Math.min(s.stage.validateTimeoutMs ?? DEFAULT_VALIDATION_RETRY_TIMEOUT_MS, MAX_VALIDATION_RETRY_TIMEOUT_MS),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
let output = initial;
|
|
206
|
+
const initialValidation = await validateOrFatal(schema, output.data, s.skill, timeoutMs);
|
|
207
|
+
if (initialValidation.kind === "fatal") return initialValidation;
|
|
208
|
+
let result = initialValidation.result;
|
|
209
|
+
let attempts = 0;
|
|
210
|
+
|
|
211
|
+
while (!result.valid && attempts < maxRetries && s.stage.onInvalid !== "halt") {
|
|
212
|
+
attempts++;
|
|
213
|
+
// onStageRetry fires before the agent is re-prompted; `attempt` is 1-based.
|
|
214
|
+
await s.lifecycle.fire(
|
|
215
|
+
ctx,
|
|
216
|
+
"onStageRetry",
|
|
217
|
+
skillStageRef(s.stageName, s.stageIndex + 1, s.skill),
|
|
218
|
+
attempts,
|
|
219
|
+
buildLifecycleContext({
|
|
220
|
+
cwd: s.cwd,
|
|
221
|
+
runId: s.runId,
|
|
222
|
+
workflow: s.runIdentity.workflow,
|
|
223
|
+
totalStages: s.runIdentity.totalStages,
|
|
224
|
+
trigger: s.runIdentity.trigger,
|
|
225
|
+
state: s.state,
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
try {
|
|
229
|
+
await askAgentToFix(ctx, s, attempts, result.failures, timeoutMs);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
232
|
+
return { kind: "fatal", message: msg };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const retryBranch = readBranch(ctx);
|
|
236
|
+
const retryCtx: CollectCtx = { ...deps.collectCtx, branch: retryBranch };
|
|
237
|
+
const reRun = await runOutcome(deps.outcome, retryCtx, deps.finalize);
|
|
238
|
+
if (reRun.kind === "fatal") return reRun;
|
|
239
|
+
const contract = enforceCompletionContract(s.stage, s.skill, reRun.output);
|
|
240
|
+
if (contract.kind === "fatal") return contract;
|
|
241
|
+
|
|
242
|
+
output = contract.output;
|
|
243
|
+
const reValidation = await validateOrFatal(schema, output.data, s.skill, timeoutMs);
|
|
244
|
+
if (reValidation.kind === "fatal") return reValidation;
|
|
245
|
+
result = reValidation.result;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!result.valid) return validationExhausted(result.failures);
|
|
249
|
+
return { kind: "ok", output };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Translate a thrown `validateOutputData` (user-authored schemas may throw
|
|
254
|
+
* synchronously or reject their Promise) into the canonical fatal-extraction
|
|
255
|
+
* outcome. Async schemas are guarded by `timeoutMs` — the same
|
|
256
|
+
* `validateTimeoutMs` budget that bounds the agent-settle step on a
|
|
257
|
+
* retry.
|
|
258
|
+
*/
|
|
259
|
+
async function validateOrFatal(
|
|
260
|
+
schema: StageSchema,
|
|
261
|
+
data: unknown,
|
|
262
|
+
skill: string,
|
|
263
|
+
timeoutMs: number,
|
|
264
|
+
): Promise<{ kind: "ok"; result: ValidationResult } | { kind: "fatal"; message: string }> {
|
|
265
|
+
try {
|
|
266
|
+
const result = await withTimeout(
|
|
267
|
+
Promise.resolve(validateOutputData(schema, data)),
|
|
268
|
+
timeoutMs,
|
|
269
|
+
ERR_SCHEMA_TIMEOUT("outputSchema", timeoutMs),
|
|
270
|
+
);
|
|
271
|
+
return { kind: "ok", result };
|
|
272
|
+
} catch (e) {
|
|
273
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
274
|
+
return { kind: "fatal", message: `${skill}: ${reason}` };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function askAgentToFix(
|
|
279
|
+
ctx: RunnerCtx,
|
|
280
|
+
s: StageSession,
|
|
281
|
+
attempt: number,
|
|
282
|
+
failures: SchemaValidationFailure[],
|
|
283
|
+
timeoutMs: number,
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
ctx.ui.notify(MSG_VALIDATION_RETRY(s.skill, attempt), "warning");
|
|
286
|
+
const errorLines = failures.map((f) => ` • ${f.path} — ${f.message}`).join("\n");
|
|
287
|
+
await withTimeout(
|
|
288
|
+
handlerFor(s.stage.sessionPolicy).send(ctx, MSG_VALIDATION_RETRY_PROMPT(s.skill, errorLines), s.continueHost),
|
|
289
|
+
timeoutMs,
|
|
290
|
+
`${s.skill}: validation retry attempt ${attempt} exceeded ${timeoutMs}ms — agent did not settle`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function validationExhausted(failures: SchemaValidationFailure[]): OutputProduction {
|
|
295
|
+
const failureSummary = failures.map((f) => `${f.path}: ${f.message}`).join("; ");
|
|
296
|
+
return { kind: "validation-exhausted", failureSummary };
|
|
297
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session execution public surface. The runner is internally split into
|
|
3
|
+
* three files (see `sessions.ts`'s header for the module map); this
|
|
4
|
+
* barrel re-exports only the symbols the rest of the package consumes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { runFanoutSession, runStageSession } from "./sessions.js";
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session execution — one Pi session per workflow stage / fanout unit.
|
|
3
|
+
* `runStageSession` and `runFanoutSession` are the two public entries.
|
|
4
|
+
*
|
|
5
|
+
* The fresh-vs-continue policy split is owned by `SessionPolicyHandler`
|
|
6
|
+
* (see `spawn.ts`): `FRESH_HANDLER` and `CONTINUE_HANDLER` implement
|
|
7
|
+
* the three policy-specific decisions. Everything in this file —
|
|
8
|
+
* post-processing, halt routing, success persistence, outcome reading
|
|
9
|
+
* — is policy-agnostic.
|
|
10
|
+
*
|
|
11
|
+
* Companion modules:
|
|
12
|
+
* - extraction.ts — produceAndValidateOutput + retry loop +
|
|
13
|
+
* outcome helpers (collector → parser pipeline).
|
|
14
|
+
* - spawn.ts — SessionPolicyHandler + FRESH/CONTINUE handlers +
|
|
15
|
+
* handlerFor.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
type AuditCtx,
|
|
20
|
+
fanoutRowStage,
|
|
21
|
+
nowIso,
|
|
22
|
+
recordCancellation,
|
|
23
|
+
recordStage,
|
|
24
|
+
recordStopFailure,
|
|
25
|
+
recordTerminalFailure,
|
|
26
|
+
} from "../audit.js";
|
|
27
|
+
import { resolvePublishName } from "../internal-utils.js";
|
|
28
|
+
import { buildLifecycleContext, skillStageRef } from "../lifecycle.js";
|
|
29
|
+
import {
|
|
30
|
+
ERR_AUDIT_WRITE_FAILED,
|
|
31
|
+
ERR_VALIDATION_FAILED,
|
|
32
|
+
MSG_AUDIT_WRITE_FAILED,
|
|
33
|
+
MSG_STAGE_COMPLETE,
|
|
34
|
+
MSG_STAGE_FAILED,
|
|
35
|
+
MSG_VALIDATION_EXHAUSTED,
|
|
36
|
+
} from "../messages.js";
|
|
37
|
+
import type { Output } from "../output.js";
|
|
38
|
+
import { type BranchEntry, classifyStop, readBranch, type StopSignal } from "../transcript.js";
|
|
39
|
+
import type { FanoutSession, RunnerCtx, SessionContext, StageSession } from "../types.js";
|
|
40
|
+
import { produceAndValidateOutput } from "./extraction.js";
|
|
41
|
+
import { FRESH_HANDLER, handlerFor } from "./spawn.js";
|
|
42
|
+
|
|
43
|
+
// ===========================================================================
|
|
44
|
+
// PUBLIC ENTRIES — what the orchestrator calls
|
|
45
|
+
// ===========================================================================
|
|
46
|
+
|
|
47
|
+
/** Execute one DAG stage in its own session. */
|
|
48
|
+
export async function runStageSession(ctx: RunnerCtx, s: StageSession): Promise<void> {
|
|
49
|
+
const handler = handlerFor(s.stage.sessionPolicy);
|
|
50
|
+
const { cancelled } = await handler.spawn(ctx, s.prompt, (sessionCtx) => postStage(sessionCtx, s), s.continueHost);
|
|
51
|
+
if (cancelled) await recordCancellation(ctx, auditFor(s));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Execute one fanout-unit iteration. Always fresh. */
|
|
55
|
+
export async function runFanoutSession(ctx: RunnerCtx, s: FanoutSession): Promise<void> {
|
|
56
|
+
const { cancelled } = await FRESH_HANDLER.spawn(ctx, s.prompt, (sessionCtx) => postFanout(sessionCtx, s));
|
|
57
|
+
if (cancelled) await recordCancellation(ctx, auditFor(s));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ===========================================================================
|
|
61
|
+
// POST-PROCESSING — runs after the agent loop settles
|
|
62
|
+
// ===========================================================================
|
|
63
|
+
|
|
64
|
+
/** Stage post-processing: classify outcome → produce & validate output → persist → chain. */
|
|
65
|
+
async function postStage(ctx: RunnerCtx, s: StageSession): Promise<void> {
|
|
66
|
+
const handler = handlerFor(s.stage.sessionPolicy);
|
|
67
|
+
const offset = handler.branchOffset(s.branchOffset);
|
|
68
|
+
const outcome = readSessionOutcome(ctx, offset);
|
|
69
|
+
if (outcome.stop !== "stop") return haltStage(ctx, s, outcome.stop);
|
|
70
|
+
|
|
71
|
+
const result = await produceAndValidateOutput(ctx, s, outcome.branch, offset);
|
|
72
|
+
if (result.kind === "fatal") return haltStageWithExtractionError(ctx, s, result.message);
|
|
73
|
+
if (result.kind === "validation-exhausted") return haltStageWithValidationFailure(ctx, s, result.failureSummary);
|
|
74
|
+
|
|
75
|
+
if (!(await recordStageSuccess(ctx, s, result.output))) return;
|
|
76
|
+
await s.onSuccess(ctx, result.output.artifacts[0]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Fanout-unit post-processing: classify outcome → persist bare row → chain. */
|
|
80
|
+
async function postFanout(ctx: RunnerCtx, s: FanoutSession): Promise<void> {
|
|
81
|
+
const outcome = readSessionOutcome(ctx, undefined);
|
|
82
|
+
if (outcome.stop !== "stop") return haltFanout(ctx, s, outcome.stop);
|
|
83
|
+
|
|
84
|
+
if (!(await recordFanoutSuccess(ctx, s))) return;
|
|
85
|
+
await s.onSuccess(ctx);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ===========================================================================
|
|
89
|
+
// HALT HELPERS — turn a halt reason into the right audit-layer call
|
|
90
|
+
// ===========================================================================
|
|
91
|
+
|
|
92
|
+
async function haltStage(ctx: RunnerCtx, s: StageSession, stop: Exclude<StopSignal, "stop">): Promise<void> {
|
|
93
|
+
await recordStopFailure(ctx, auditFor(s), stop, `${s.skill} failed`, s.onFailure);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function haltStageWithExtractionError(ctx: RunnerCtx, s: StageSession, message: string): Promise<void> {
|
|
97
|
+
await recordTerminalFailure(
|
|
98
|
+
ctx,
|
|
99
|
+
auditFor(s),
|
|
100
|
+
{ status: "failed", notifyMsg: MSG_STAGE_FAILED(s.skill), notifyLevel: "error", errMsg: message },
|
|
101
|
+
s.onFailure,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function haltStageWithValidationFailure(ctx: RunnerCtx, s: StageSession, failureSummary: string): Promise<void> {
|
|
106
|
+
await recordTerminalFailure(
|
|
107
|
+
ctx,
|
|
108
|
+
auditFor(s),
|
|
109
|
+
{
|
|
110
|
+
status: "failed",
|
|
111
|
+
notifyMsg: MSG_VALIDATION_EXHAUSTED(s.skill),
|
|
112
|
+
notifyLevel: "error",
|
|
113
|
+
errMsg: ERR_VALIDATION_FAILED(s.skill, failureSummary),
|
|
114
|
+
},
|
|
115
|
+
s.onFailure,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function haltFanout(ctx: RunnerCtx, s: FanoutSession, stop: Exclude<StopSignal, "stop">): Promise<void> {
|
|
120
|
+
await recordStopFailure(ctx, auditFor(s), stop, `${s.skill} unit ${s.unitIndex} (${s.label}) failed`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ===========================================================================
|
|
124
|
+
// SUCCESS-PERSISTENCE HELPERS
|
|
125
|
+
// ===========================================================================
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Write + counter-increment guard shared by `recordStageSuccess` and
|
|
129
|
+
* `recordFanoutSuccess`. Returns `true` iff the JSONL row landed.
|
|
130
|
+
* Output assignment lives here so callers get the same "output is
|
|
131
|
+
* set iff the row that carried it landed" invariant.
|
|
132
|
+
*/
|
|
133
|
+
function tryRecordStage(s: SessionContext, row: { stage: string; skill?: string; output?: Output }): boolean {
|
|
134
|
+
const assigned = recordStage(
|
|
135
|
+
s.cwd,
|
|
136
|
+
s.runId,
|
|
137
|
+
{
|
|
138
|
+
stage: row.stage,
|
|
139
|
+
skill: row.skill,
|
|
140
|
+
status: "completed",
|
|
141
|
+
ts: nowIso(),
|
|
142
|
+
output: row.output,
|
|
143
|
+
},
|
|
144
|
+
s.state,
|
|
145
|
+
);
|
|
146
|
+
if (assigned === undefined) return false;
|
|
147
|
+
if (row.output) s.state.output = row.output;
|
|
148
|
+
s.state.stagesCompleted++;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Update the rolling chain-input slot. Three cases:
|
|
154
|
+
* 1. `produces` stages whose collector returned at least one artifact
|
|
155
|
+
* advance the primary (first artifact wins; `role` is user-facing
|
|
156
|
+
* metadata, not a framework gate).
|
|
157
|
+
* 2. `side-effect` stages with `inheritsArtifacts: false` (authored via
|
|
158
|
+
* `terminal()`) CLEAR the slot — they explicitly break the chain
|
|
159
|
+
* so anything after also starts without an inherited artifact.
|
|
160
|
+
* 3. Other `side-effect` stages (commit, implement) leave it in place
|
|
161
|
+
* so a stage after them inherits the upstream chain input.
|
|
162
|
+
*/
|
|
163
|
+
function maybeAdvancePrimary(s: StageSession, output: Output): void {
|
|
164
|
+
if (s.stage.kind === "produces") {
|
|
165
|
+
const next = output.artifacts[0];
|
|
166
|
+
if (next) s.state.primaryArtifact = next;
|
|
167
|
+
const key = resolvePublishName(s.stage, s.stageName);
|
|
168
|
+
const slot = s.state.named[key];
|
|
169
|
+
if (slot) slot.push(output);
|
|
170
|
+
else s.state.named[key] = [output];
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (s.stage.inheritsArtifacts === false) {
|
|
174
|
+
s.state.primaryArtifact = undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Returns true on successful write — caller gates `onSuccess` on this so the
|
|
180
|
+
* chain advances only when the audit row landed. On failure, leaves
|
|
181
|
+
* `state.output` / `state.primaryArtifact` at their prior values and sets
|
|
182
|
+
* `state.termination.error` to halt the run.
|
|
183
|
+
*/
|
|
184
|
+
async function recordStageSuccess(ctx: RunnerCtx, s: StageSession, output: Output): Promise<boolean> {
|
|
185
|
+
if (tryRecordStage(s, { stage: s.stageName, skill: s.skill, output })) {
|
|
186
|
+
maybeAdvancePrimary(s, output);
|
|
187
|
+
ctx.ui.notify(MSG_STAGE_COMPLETE(s.skill), "info");
|
|
188
|
+
await s.lifecycle.fire(
|
|
189
|
+
ctx,
|
|
190
|
+
"onStageEnd",
|
|
191
|
+
skillStageRef(s.stageName, s.state.lastAllocatedStageNumber, s.skill),
|
|
192
|
+
output,
|
|
193
|
+
lifecycleCtxFromSession(s),
|
|
194
|
+
);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
ctx.ui.notify(MSG_AUDIT_WRITE_FAILED(s.skill), "error");
|
|
198
|
+
s.state.termination.error = ERR_AUDIT_WRITE_FAILED(s.skill);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function recordFanoutSuccess(ctx: RunnerCtx, s: FanoutSession): Promise<boolean> {
|
|
203
|
+
const stageLabel = fanoutRowStage(s);
|
|
204
|
+
if (tryRecordStage(s, { stage: stageLabel, skill: s.skill })) {
|
|
205
|
+
await s.lifecycle.fire(
|
|
206
|
+
ctx,
|
|
207
|
+
"onFanoutUnitEnd",
|
|
208
|
+
skillStageRef(s.stageName, s.stageIndex + 1, s.skill),
|
|
209
|
+
{ prompt: s.prompt, label: s.label, ...(s.id !== undefined && { id: s.id }) },
|
|
210
|
+
s.unitIndex,
|
|
211
|
+
lifecycleCtxFromSession(s),
|
|
212
|
+
);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
s.state.termination.error = ERR_AUDIT_WRITE_FAILED(stageLabel);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Build a `LifecycleContext` from any SessionContext-shaped object. */
|
|
220
|
+
function lifecycleCtxFromSession(s: SessionContext) {
|
|
221
|
+
return buildLifecycleContext({
|
|
222
|
+
cwd: s.cwd,
|
|
223
|
+
runId: s.runId,
|
|
224
|
+
workflow: s.runIdentity.workflow,
|
|
225
|
+
totalStages: s.runIdentity.totalStages,
|
|
226
|
+
trigger: s.runIdentity.trigger,
|
|
227
|
+
state: s.state,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ===========================================================================
|
|
232
|
+
// OUTCOME READER
|
|
233
|
+
// ===========================================================================
|
|
234
|
+
|
|
235
|
+
interface SessionOutcome {
|
|
236
|
+
branch: BranchEntry[];
|
|
237
|
+
stop: StopSignal;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Always reads the full unsliced branch + applies the policy-derived
|
|
242
|
+
* `branchOffset` to `classifyStop` so the prior-stage prefix is
|
|
243
|
+
* skipped in place. The same offset value flows through to
|
|
244
|
+
* `produceAndValidateOutput` (initial == retry).
|
|
245
|
+
*
|
|
246
|
+
* No longer scans the transcript for an artifact path — discovery is
|
|
247
|
+
* the collector's job, not the runner's.
|
|
248
|
+
*/
|
|
249
|
+
function readSessionOutcome(ctx: RunnerCtx, branchOffset: number | undefined): SessionOutcome {
|
|
250
|
+
const branch = readBranch(ctx);
|
|
251
|
+
return {
|
|
252
|
+
branch,
|
|
253
|
+
stop: classifyStop(branch, branchOffset),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ===========================================================================
|
|
258
|
+
// Helpers
|
|
259
|
+
// ===========================================================================
|
|
260
|
+
|
|
261
|
+
const auditFor = (s: StageSession | FanoutSession): AuditCtx => ({
|
|
262
|
+
cwd: s.cwd,
|
|
263
|
+
runId: s.runId,
|
|
264
|
+
state: s.state,
|
|
265
|
+
stageName: "unitIndex" in s ? fanoutRowStage(s) : s.stageName,
|
|
266
|
+
skill: s.skill,
|
|
267
|
+
lifecycle: s.lifecycle,
|
|
268
|
+
runIdentity: s.runIdentity,
|
|
269
|
+
});
|