@studio-foundation/engine 0.3.0-beta.1

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.
Files changed (90) hide show
  1. package/ARCHITECTURE.md +57 -0
  2. package/LICENSE +663 -0
  3. package/README.md +118 -0
  4. package/package.json +47 -0
  5. package/src/__tests__/__fixtures__/script-stage/contracts/book-context.contract.yaml +4 -0
  6. package/src/__tests__/engine.conditions-group.test.ts +244 -0
  7. package/src/__tests__/engine.conditions.test.ts +186 -0
  8. package/src/__tests__/engine.resume.test.ts +108 -0
  9. package/src/__tests__/engine.script-stage.test.ts +125 -0
  10. package/src/db/client.ts +5 -0
  11. package/src/engine.context-event.test.ts +175 -0
  12. package/src/engine.ts +491 -0
  13. package/src/events.ts +167 -0
  14. package/src/index.ts +64 -0
  15. package/src/pipeline/agent-loader.test.ts +151 -0
  16. package/src/pipeline/agent-loader.ts +39 -0
  17. package/src/pipeline/condition-evaluator.test.ts +129 -0
  18. package/src/pipeline/condition-evaluator.ts +121 -0
  19. package/src/pipeline/context-pack-loader.ts +63 -0
  20. package/src/pipeline/context-propagation.test.ts +237 -0
  21. package/src/pipeline/context-propagation.ts +235 -0
  22. package/src/pipeline/contract-loader.ts +41 -0
  23. package/src/pipeline/group-orchestrator.ts +483 -0
  24. package/src/pipeline/hook-executor.test.ts +121 -0
  25. package/src/pipeline/hook-executor.ts +87 -0
  26. package/src/pipeline/invariants-loader.test.ts +51 -0
  27. package/src/pipeline/invariants-loader.ts +14 -0
  28. package/src/pipeline/loader.test.ts +128 -0
  29. package/src/pipeline/loader.ts +149 -0
  30. package/src/pipeline/output-validator.test.ts +100 -0
  31. package/src/pipeline/output-validator.ts +40 -0
  32. package/src/pipeline/post-validator.ts +124 -0
  33. package/src/pipeline/skill-loader.test.ts +44 -0
  34. package/src/pipeline/skill-loader.ts +32 -0
  35. package/src/pipeline/stage-executor.ts +654 -0
  36. package/src/pipeline/stage-resolver.ts +8 -0
  37. package/src/pipeline/startup-executor.test.ts +37 -0
  38. package/src/pipeline/startup-executor.ts +32 -0
  39. package/src/pipeline/types.ts +42 -0
  40. package/src/repo-resolver.ts +61 -0
  41. package/src/spawners/direct-engine-spawner.ts +26 -0
  42. package/src/state/run-store.test.ts +157 -0
  43. package/src/state/run-store.ts +362 -0
  44. package/src/state/state-machine.ts +35 -0
  45. package/src/state/status-derivation.ts +36 -0
  46. package/tests/context-pack-loader.test.ts +113 -0
  47. package/tests/context-propagation.test.ts +267 -0
  48. package/tests/direct-engine-spawner.test.ts +102 -0
  49. package/tests/e2e/feature-v5.test.ts +57 -0
  50. package/tests/events.test.ts +56 -0
  51. package/tests/fixtures/agents/test-agent.agent.yaml +5 -0
  52. package/tests/fixtures/contracts/code-gen.contract.yaml +6 -0
  53. package/tests/fixtures/contracts/qa-gate.contract.yaml +15 -0
  54. package/tests/fixtures/contracts/test-contract.contract.yaml +6 -0
  55. package/tests/fixtures/pipelines/group-test.pipeline.yaml +40 -0
  56. package/tests/fixtures/pipelines/simple.pipeline.yaml +15 -0
  57. package/tests/fixtures/pipelines/two-stage.pipeline.yaml +24 -0
  58. package/tests/fixtures/software/pipelines/feature-builder.pipeline.yaml +75 -0
  59. package/tests/fixtures/test-project/agents/test-agent.agent.yaml +5 -0
  60. package/tests/fixtures/test-project/contracts/basic-result.contract.yaml +6 -0
  61. package/tests/fixtures/test-project/contracts/code-gen.contract.yaml +6 -0
  62. package/tests/fixtures/test-project/contracts/maximum-only.contract.yaml +8 -0
  63. package/tests/fixtures/test-project/contracts/qa-gate.contract.yaml +15 -0
  64. package/tests/fixtures/test-project/contracts/strict-result.contract.yaml +7 -0
  65. package/tests/fixtures/test-project/contracts/test-contract.contract.yaml +6 -0
  66. package/tests/fixtures/test-project/pipelines/group-simple.pipeline.yaml +26 -0
  67. package/tests/fixtures/test-project/pipelines/group-test.pipeline.yaml +40 -0
  68. package/tests/fixtures/test-project/pipelines/hook-output-template.pipeline.yaml +19 -0
  69. package/tests/fixtures/test-project/pipelines/hook-reject-on-failure.pipeline.yaml +19 -0
  70. package/tests/fixtures/test-project/pipelines/maximum-only.pipeline.yaml +15 -0
  71. package/tests/fixtures/test-project/pipelines/parallel-collect-all-test.pipeline.yaml +39 -0
  72. package/tests/fixtures/test-project/pipelines/parallel-context-isolation-test.pipeline.yaml +38 -0
  73. package/tests/fixtures/test-project/pipelines/parallel-fail-test.pipeline.yaml +39 -0
  74. package/tests/fixtures/test-project/pipelines/parallel-test.pipeline.yaml +39 -0
  75. package/tests/fixtures/test-project/pipelines/parallel-then-sequential-test.pipeline.yaml +38 -0
  76. package/tests/fixtures/test-project/pipelines/simple.pipeline.yaml +15 -0
  77. package/tests/fixtures/test-project/pipelines/two-stage.pipeline.yaml +24 -0
  78. package/tests/fixtures/test-project/pipelines/with-startup.pipeline.yaml +19 -0
  79. package/tests/loader.test.ts +385 -0
  80. package/tests/post-validator.test.ts +297 -0
  81. package/tests/repo-resolver.test.ts +102 -0
  82. package/tests/run-store.test.ts +143 -0
  83. package/tests/state-machine.test.ts +110 -0
  84. package/tests/unit/context-propagation.test.ts +45 -0
  85. package/tests/unit/engine.test.ts +770 -0
  86. package/tests/unit/group-loop.test.ts +389 -0
  87. package/tests/unit/group-parallel.test.ts +423 -0
  88. package/tests/unit/state/status-derivation.test.ts +88 -0
  89. package/tsconfig.json +24 -0
  90. package/vitest.config.ts +14 -0
@@ -0,0 +1,654 @@
1
+ // StageExecutor — extracted from PipelineEngine.executeStage()
2
+ // Handles the execution of a single pipeline stage: ralph loop, validation, hooks, observability.
3
+
4
+ import { randomUUID } from 'node:crypto';
5
+ import { join } from 'node:path';
6
+ import type {
7
+ StageDefinition,
8
+ StageRun,
9
+ TaskRun,
10
+ AgentRun,
11
+ OutputContract,
12
+ ResolvedAgentConfig,
13
+ } from '@studio-foundation/contracts';
14
+ import {
15
+ ralph,
16
+ validateSchema,
17
+ validateToolCalls,
18
+ validateRequiredTools,
19
+ validateCountedTools,
20
+ validateToolGroups,
21
+ compose,
22
+ exponentialBackoff,
23
+ fixedDelay,
24
+ noDelay,
25
+ type ExecutionContext as RalphExecutionContext,
26
+ type Validator,
27
+ type ToolCallRequirements,
28
+ } from '@studio-foundation/ralph';
29
+ import {
30
+ runAgent,
31
+ runScript,
32
+ type AgentRunResult,
33
+ type ToolRegistry,
34
+ type ProviderRegistry,
35
+ type TaskInput,
36
+ AnonymizationMiddleware,
37
+ } from '@studio-foundation/runner';
38
+ import { loadAgentProfile } from './agent-loader.js';
39
+ import { loadContract } from './contract-loader.js';
40
+ import { loadSkillFiles } from './skill-loader.js';
41
+ import { runStageHook, runToolHook } from './hook-executor.js';
42
+ import {
43
+ getContextForStage,
44
+ buildContextKeys,
45
+ buildContextContent,
46
+ type PipelineContext,
47
+ } from './context-propagation.js';
48
+ import { evaluateCondition } from './condition-evaluator.js';
49
+ import { deriveStageStatus } from '../state/status-derivation.js';
50
+ import { transition } from '../state/state-machine.js';
51
+ import { postValidate, type PostValidationResult } from './post-validator.js';
52
+ import { loadContextPacks } from './context-pack-loader.js';
53
+ import type { EngineEvents, StageContextEvent } from '../events.js';
54
+ import { PipelineEventEmitter } from '../events.js';
55
+ import type { ProjectPaths, StageResult } from './types.js';
56
+
57
+ // Module-level helpers — verbatim from engine.ts lines 73-95
58
+
59
+ function summarizeOutput(output: unknown): string {
60
+ if (!output || typeof output !== 'object') return 'no structured output';
61
+ const o = output as Record<string, unknown>;
62
+ const keys = Object.keys(o);
63
+ return `${keys.length} fields: ${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}`;
64
+ }
65
+
66
+
67
+ export interface StageExecutorConfig {
68
+ events?: EngineEvents;
69
+ emitter: PipelineEventEmitter;
70
+ providerRegistry: ProviderRegistry;
71
+ repoPath?: string;
72
+ configsDir: string;
73
+ pluginSkills?: Record<string, string[]>;
74
+ providerOverride?: string;
75
+ defaultProvider?: string;
76
+ defaultModel?: string;
77
+ }
78
+
79
+ export class StageExecutor {
80
+ constructor(private config: StageExecutorConfig) {}
81
+
82
+ async execute(
83
+ stageDef: StageDefinition,
84
+ pipelineContext: PipelineContext,
85
+ previousStageName: string | undefined,
86
+ userInput: string | Record<string, unknown>,
87
+ stageIndex: number,
88
+ totalStages: number,
89
+ paths: ProjectPaths,
90
+ toolRegistry: ToolRegistry | undefined,
91
+ runMiddleware?: AnonymizationMiddleware | null,
92
+ runId?: string,
93
+ signal?: AbortSignal,
94
+ ): Promise<StageResult> {
95
+ const stageRunId = randomUUID();
96
+ const stageStartedAt = new Date().toISOString();
97
+
98
+ // Create stage run shell
99
+ const stageRun: StageRun = {
100
+ id: stageRunId,
101
+ stage_name: stageDef.name,
102
+ status: 'running',
103
+ started_at: stageStartedAt,
104
+ tasks: [],
105
+ };
106
+
107
+ this.config.events?.onStageStart?.({
108
+ stage_name: stageDef.name,
109
+ stage_index: stageIndex,
110
+ total_stages: totalStages,
111
+ max_attempts: stageDef.ralph?.max_attempts ?? 3,
112
+ });
113
+ this.config.emitter.emit({ type: 'stage_start', stageId: stageRunId, stageName: stageDef.name });
114
+
115
+ // Evaluate condition — skip stage if false
116
+ if (stageDef.condition !== undefined) {
117
+ const shouldRun = evaluateCondition(stageDef.condition, {
118
+ input: pipelineContext.input,
119
+ stageOutputs: pipelineContext.stageOutputs,
120
+ });
121
+ if (!shouldRun) {
122
+ stageRun.status = 'skipped';
123
+ stageRun.completed_at = new Date().toISOString();
124
+ stageRun.tasks = [];
125
+ this.config.events?.onStageComplete?.({
126
+ stage_name: stageDef.name,
127
+ stage_index: stageIndex,
128
+ total_stages: totalStages,
129
+ status: 'skipped',
130
+ attempts: 0,
131
+ duration_ms: 0,
132
+ });
133
+ this.config.emitter.emit({ type: 'stage_complete', stageId: stageRunId, stageName: stageDef.name });
134
+ return { stageRun, status: 'skipped' };
135
+ }
136
+ }
137
+
138
+ // Load agent profile — only for LLM stages (script stages have no agent)
139
+ let agentConfig: Awaited<ReturnType<typeof loadAgentProfile>> | null = null;
140
+ if (stageDef.agent) {
141
+ agentConfig = await loadAgentProfile(stageDef.agent, paths.agentsDir);
142
+ // Apply defaults from config.yaml when agent YAML omits provider/model
143
+ if (!agentConfig.provider) agentConfig.provider = this.config.defaultProvider;
144
+ if (!agentConfig.model) agentConfig.model = this.config.defaultModel;
145
+ if (this.config.providerOverride) {
146
+ agentConfig.provider = this.config.providerOverride;
147
+ }
148
+ if (!agentConfig.provider) {
149
+ throw new Error(`Agent '${stageDef.agent}' has no provider and no default is configured. Run: studio config set provider <name>`);
150
+ }
151
+ if (!agentConfig.model) {
152
+ throw new Error(`Agent '${stageDef.agent}' has no model and no default is configured. Run: studio config set defaults.model <model>`);
153
+ }
154
+ // Inject plugin skills into system_prompt for agents that declare plugins
155
+ if (agentConfig.plugins?.length && this.config.pluginSkills) {
156
+ const skillChunks = agentConfig.plugins
157
+ .flatMap((p) => this.config.pluginSkills![p] ?? []);
158
+ if (skillChunks.length > 0) {
159
+ agentConfig.system_prompt = `${agentConfig.system_prompt ?? ''}\n\n${skillChunks.join('\n\n---\n\n')}`;
160
+ }
161
+ }
162
+
163
+ // Inject project skills (.studio/skills/*.skill.md) for agents that declare skills
164
+ if (agentConfig.skills?.length) {
165
+ const skillsDir = join(paths.projectDir, 'skills');
166
+ const loaded = await loadSkillFiles(agentConfig.skills, skillsDir);
167
+ if (loaded.length > 0) {
168
+ const skillChunks = loaded.map((s) => `## Skill: ${s.name}\n\n${s.content}`);
169
+ agentConfig.system_prompt = `${agentConfig.system_prompt ?? ''}\n\n${skillChunks.join('\n\n---\n\n')}`;
170
+ }
171
+ }
172
+
173
+ // Inject project domain invariants (.studio/invariants.md) into system_prompt
174
+ if (pipelineContext.invariantsContent) {
175
+ agentConfig.system_prompt = `${agentConfig.system_prompt ?? ''}\n\n---\n\n## Project Invariants\n\n${pipelineContext.invariantsContent}`;
176
+ }
177
+ }
178
+
179
+ // Validate: stage must have either agent (LLM) or script (script executor)
180
+ if (!stageDef.agent && !stageDef.script) {
181
+ throw new Error(`Stage '${stageDef.name}' must have either 'agent' (for LLM) or 'script' (for script executor)`);
182
+ }
183
+
184
+ const stageHooks = stageDef.hooks;
185
+ const hookCwd = this.config.repoPath ?? this.config.configsDir;
186
+
187
+ // Load output contract if specified
188
+ let contract: OutputContract | null = null;
189
+ if (stageDef.contract) {
190
+ const contractName = stageDef.contract.replace('.contract.yaml', '');
191
+ contract = await loadContract(contractName, paths.contractsDir);
192
+ }
193
+
194
+ // Build context for this stage
195
+ const agentContext = getContextForStage(pipelineContext, stageDef, previousStageName);
196
+
197
+ // Load context packs if stage defines any
198
+ if (stageDef.context?.packs?.length) {
199
+ agentContext.context_packs = await loadContextPacks(
200
+ stageDef.context.packs,
201
+ paths.projectDir,
202
+ pipelineContext.repoPath,
203
+ );
204
+ }
205
+
206
+ // Emit context observability event (zero work if no handler registered)
207
+ if (this.config.events?.onStageContext) {
208
+ const debugFlag = process.env.DEBUG ?? '';
209
+ const includeContent = debugFlag.includes('studio:context');
210
+ const includePrompt = debugFlag.includes('studio:context:verbose');
211
+
212
+ const contextEvent: StageContextEvent = {
213
+ stage: stageDef.name,
214
+ run_id: runId ?? '',
215
+ context_keys: buildContextKeys(agentContext, pipelineContext.stageOutputSizes),
216
+ ...(includeContent ? { context_content: buildContextContent(agentContext) } : {}),
217
+ ...(includePrompt && agentConfig ? { system_prompt: agentConfig.system_prompt } : {}),
218
+ };
219
+
220
+ this.config.events.onStageContext(contextEvent);
221
+ }
222
+
223
+ // Create a single task run (v7: 1 stage = 1 task = 1 ralph call)
224
+ const taskRun: TaskRun = {
225
+ id: randomUUID(),
226
+ task_name: stageDef.name,
227
+ status: 'running',
228
+ started_at: stageStartedAt,
229
+ agent_runs: [],
230
+ };
231
+
232
+ // Build the validator for ralph using ralph's own validators
233
+ const ralphValidator = this.buildValidator(contract, stageDef);
234
+
235
+ // Resolve retry strategy
236
+ const retryStrategy = this.resolveRetryStrategy(stageDef.ralph?.retry_strategy);
237
+
238
+ // Create per-stage middleware if agent requests it (and no run-level middleware)
239
+ const stageMiddleware = (!runMiddleware && agentConfig?.anonymize)
240
+ ? new AnonymizationMiddleware()
241
+ : null;
242
+
243
+ // Run on_stage_start hooks before the ralph loop
244
+ if (stageHooks?.on_stage_start?.length) {
245
+ for (const hook of stageHooks.on_stage_start) {
246
+ const hookResult = await runStageHook(hook, hookCwd);
247
+ if (!hookResult.success) {
248
+ const onFailure = hook.on_failure ?? 'warn';
249
+ if (onFailure === 'fail') {
250
+ stageRun.status = 'failed';
251
+ stageRun.completed_at = new Date().toISOString();
252
+ stageRun.tasks = [];
253
+ this.config.events?.onStageComplete?.({
254
+ stage_name: stageDef.name,
255
+ stage_index: stageIndex,
256
+ total_stages: totalStages,
257
+ status: 'failed',
258
+ attempts: 0,
259
+ duration_ms: Date.now() - new Date(stageStartedAt).getTime(),
260
+ });
261
+ this.config.emitter.emit({ type: 'stage_complete', stageId: stageRunId, stageName: stageDef.name });
262
+ return { stageRun, status: 'failed' };
263
+ } else if (onFailure === 'reject') {
264
+ stageRun.status = 'rejected';
265
+ stageRun.completed_at = new Date().toISOString();
266
+ stageRun.tasks = [];
267
+ this.config.events?.onStageComplete?.({
268
+ stage_name: stageDef.name,
269
+ stage_index: stageIndex,
270
+ total_stages: totalStages,
271
+ status: 'rejected',
272
+ attempts: 0,
273
+ duration_ms: Date.now() - new Date(stageStartedAt).getTime(),
274
+ });
275
+ this.config.emitter.emit({ type: 'stage_complete', stageId: stageRunId, stageName: stageDef.name });
276
+ return {
277
+ stageRun,
278
+ status: 'rejected',
279
+ postValidation: {
280
+ accepted: false,
281
+ rejection_reason: `on_stage_start hook failed: ${hook.command}`,
282
+ rejection_details: hookResult.stderr ? [hookResult.stderr] : [],
283
+ },
284
+ };
285
+ } else {
286
+ // warn (default)
287
+ console.warn(`[on_stage_start] hook failed for stage "${stageDef.name}": ${hookResult.stderr}`);
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ // Build tool hook callbacks for runAgent
294
+ const onPreToolUse = stageHooks?.pre_tool_use?.length
295
+ ? async (event: { tool: string; params: Record<string, unknown>; timestamp: number }) => {
296
+ const matchingHooks = stageHooks!.pre_tool_use!.filter(h => h.matcher === event.tool);
297
+ // Fail-fast: first matching hook that fails blocks the tool call; remaining hooks are skipped
298
+ for (const hook of matchingHooks) {
299
+ const hookResult = await runToolHook(hook, event.params, hookCwd);
300
+ if (!hookResult.success) {
301
+ return { blocked: true, error: `Pre-hook failed: ${hookResult.stderr || hookResult.stdout}` };
302
+ }
303
+ }
304
+ return { blocked: false };
305
+ }
306
+ : undefined;
307
+
308
+ const onPostToolUse = stageHooks?.post_tool_use?.length
309
+ ? async (event: { tool: string; params: Record<string, unknown>; result: unknown; error?: string; timestamp: number }) => {
310
+ const matchingHooks = stageHooks!.post_tool_use!.filter(h => h.matcher === event.tool);
311
+ for (const hook of matchingHooks) {
312
+ const hookResult = await runToolHook(hook, event.params, hookCwd);
313
+ if (!hookResult.success) {
314
+ const onFailure = hook.on_failure ?? 'warn';
315
+ if (onFailure === 'reject') {
316
+ return { append_message: `Post-hook failed: ${hookResult.stderr || hookResult.stdout}` };
317
+ } else {
318
+ console.warn(`[post_tool_use] hook failed for "${event.tool}" in stage "${stageDef.name}": ${hookResult.stderr}`);
319
+ }
320
+ }
321
+ }
322
+ return {};
323
+ }
324
+ : undefined;
325
+
326
+ // Execute ralph loop — catch unexpected executor throws (network errors, etc.)
327
+ // and convert them to a failed stage rather than crashing the pipeline.
328
+ let ralphResult: Awaited<ReturnType<typeof ralph<AgentRunResult>>>;
329
+ try {
330
+ ralphResult = await ralph<AgentRunResult>({
331
+ executor: async (execContext: RalphExecutionContext) => {
332
+ const agentRunId = randomUUID();
333
+ const agentRunStartedAt = new Date().toISOString();
334
+
335
+ const taskInput: TaskInput = {
336
+ description: typeof userInput === 'string' ? userInput : JSON.stringify(userInput),
337
+ contract_name: contract?.name,
338
+ };
339
+
340
+ // Map ralph context to runner context
341
+ const runnerExecContext = {
342
+ attempt: execContext.attempt,
343
+ previous_failures: execContext.previousFailures.map(f => ({
344
+ error: f,
345
+ tool_calls_count: 0,
346
+ })),
347
+ };
348
+
349
+ const result = stageDef.agent
350
+ ? await runAgent({
351
+ agent: agentConfig as ResolvedAgentConfig,
352
+ task: taskInput,
353
+ context: agentContext,
354
+ executionContext: runnerExecContext,
355
+ toolRegistry: toolRegistry!,
356
+ providerRegistry: this.config.providerRegistry,
357
+ outputContract: contract ?? undefined,
358
+ maxToolCalls: stageDef.ralph?.max_tool_calls,
359
+ anonymizationMiddleware: runMiddleware ?? stageMiddleware ?? undefined,
360
+ signal,
361
+ callbacks: {
362
+ ...(this.config.events ? {
363
+ onToolCallStart: this.config.events.onToolCallStart
364
+ ? (e) => this.config.events!.onToolCallStart!({ stage: stageDef.name, ...e })
365
+ : undefined,
366
+ onToolCallComplete: this.config.events.onToolCallComplete
367
+ ? (e) => this.config.events!.onToolCallComplete!({ stage: stageDef.name, ...e })
368
+ : undefined,
369
+ onAgentThinking: this.config.events.onAgentThinking
370
+ ? (e) => this.config.events!.onAgentThinking!({ stage: stageDef.name, ...e })
371
+ : undefined,
372
+ onAgentProgress: this.config.events.onAgentProgress
373
+ ? (e) => this.config.events!.onAgentProgress!({ stage: stageDef.name, ...e })
374
+ : undefined,
375
+ onAgentToken: this.config.events.onAgentToken
376
+ ? (e) => this.config.events!.onAgentToken!({ stage: stageDef.name, ...e })
377
+ : undefined,
378
+ } : {}),
379
+ ...(onPreToolUse ? { onPreToolUse } : {}),
380
+ ...(onPostToolUse ? { onPostToolUse } : {}),
381
+ },
382
+ })
383
+ : await runScript({
384
+ scriptPath: stageDef.script!,
385
+ runtime: stageDef.runtime ?? 'shell',
386
+ context: agentContext,
387
+ cwd: this.config.repoPath ?? this.config.configsDir,
388
+ timeoutMs: stageDef.timeout_ms,
389
+ });
390
+
391
+ // Record agent run
392
+ const agentRun: AgentRun = {
393
+ id: agentRunId,
394
+ agent_name: agentConfig?.name ?? `script:${stageDef.script ?? 'unknown'}`,
395
+ attempt: execContext.attempt,
396
+ status: 'success',
397
+ tool_calls: result.tool_calls_count,
398
+ started_at: agentRunStartedAt,
399
+ completed_at: new Date().toISOString(),
400
+ output: result.output,
401
+ };
402
+ taskRun.agent_runs.push(agentRun);
403
+
404
+ return result;
405
+ },
406
+ validator: ralphValidator,
407
+ maxAttempts: stageDef.ralph?.max_attempts ?? 3,
408
+ retryStrategy,
409
+ signal,
410
+ onRetry: async (event) => {
411
+ // Extract raw output for diagnostic logging
412
+ const rawOutput = typeof event.result.output === 'string'
413
+ ? event.result.output
414
+ : JSON.stringify(event.result.output, null, 2);
415
+
416
+ this.config.events?.onTaskRetry?.({
417
+ stage: stageDef.name,
418
+ attempt: event.attempt,
419
+ max_attempts: stageDef.ralph?.max_attempts ?? 3,
420
+ failures: event.allFailures,
421
+ agent_output_raw: rawOutput,
422
+ tool_calls_count: event.result.tool_calls_count,
423
+ });
424
+ this.config.emitter.emit({
425
+ type: 'task_retry',
426
+ stageName: stageDef.name,
427
+ attempt: event.attempt,
428
+ failures: event.allFailures,
429
+ rawOutput,
430
+ });
431
+ },
432
+ });
433
+ } catch (err) {
434
+ // AbortError from signal propagation is handled inside ralph (returns 'cancelled').
435
+ // Any other throw is a technical failure (network, timeout, etc.) — mark stage failed.
436
+ stageRun.status = transition('running', 'fail');
437
+ stageRun.completed_at = new Date().toISOString();
438
+ stageRun.tasks = [taskRun];
439
+ this.config.events?.onStageComplete?.({
440
+ stage_name: stageDef.name,
441
+ stage_index: stageIndex,
442
+ total_stages: totalStages,
443
+ status: 'failed',
444
+ attempts: 1,
445
+ duration_ms: Date.now() - new Date(stageStartedAt).getTime(),
446
+ });
447
+ this.config.emitter.emit({ type: 'stage_complete', stageId: stageRunId, stageName: stageDef.name });
448
+ return { stageRun, status: 'failed' };
449
+ }
450
+
451
+ // Persist stage-level keymap if we created a stage middleware
452
+ if (stageMiddleware) {
453
+ await this.persistKeymap(runId ?? stageRunId, stageMiddleware.getKeymap());
454
+ }
455
+
456
+ // Derive stage status from ralph result
457
+ let stageStatus = deriveStageStatus(ralphResult);
458
+
459
+ // Cancelled — skip post-validation and hooks
460
+ if (stageStatus === 'cancelled') {
461
+ stageRun.status = transition('running', 'cancel');
462
+ stageRun.completed_at = new Date().toISOString();
463
+ taskRun.status = 'failed'; // closest existing TaskRun status
464
+ taskRun.completed_at = new Date().toISOString();
465
+ stageRun.tasks = [taskRun];
466
+ this.config.events?.onStageComplete?.({
467
+ stage_name: stageDef.name,
468
+ stage_index: stageIndex,
469
+ total_stages: totalStages,
470
+ status: 'cancelled',
471
+ attempts: ralphResult.attempts,
472
+ duration_ms: Date.now() - new Date(stageStartedAt).getTime(),
473
+ });
474
+ this.config.emitter.emit({ type: 'stage_complete', stageId: stageRunId, stageName: stageDef.name });
475
+ return { stageRun, status: 'cancelled' };
476
+ }
477
+
478
+ // Post-validation: check if a successful output is semantically rejected
479
+ // (e.g. QA stage returned valid JSON but status says "implementation_incomplete")
480
+ let postResult: PostValidationResult | undefined;
481
+ if (stageStatus === 'success' && contract?.post_validation?.rejection_detection) {
482
+ const agentOutput = ralphResult.status === 'success' ? ralphResult.result?.output : undefined;
483
+ postResult = postValidate(agentOutput, contract);
484
+
485
+ if (!postResult.accepted) {
486
+ stageStatus = 'rejected';
487
+ }
488
+ }
489
+
490
+ // Run on_stage_complete hooks — only when stage succeeded (including post-validation)
491
+ if (stageStatus === 'success' && stageHooks?.on_stage_complete?.length) {
492
+ const stageOutput = ralphResult.status === 'success'
493
+ ? (ralphResult.result?.output as Record<string, unknown> ?? {})
494
+ : {};
495
+ for (const hook of stageHooks.on_stage_complete) {
496
+ const hookResult = await runStageHook(hook, hookCwd, stageOutput);
497
+ if (!hookResult.success) {
498
+ const onFailure = hook.on_failure ?? 'warn';
499
+ if (onFailure === 'reject') {
500
+ stageStatus = 'rejected';
501
+ postResult = {
502
+ accepted: false,
503
+ rejection_reason: `on_stage_complete hook failed: ${hook.command}`,
504
+ rejection_details: hookResult.stderr ? [hookResult.stderr] : [],
505
+ };
506
+ break;
507
+ } else if (onFailure === 'fail') {
508
+ stageStatus = 'failed';
509
+ postResult = {
510
+ accepted: false,
511
+ rejection_reason: `on_stage_complete hook failed: ${hook.command}`,
512
+ rejection_details: hookResult.stderr ? [hookResult.stderr] : [],
513
+ };
514
+ break;
515
+ } else {
516
+ // warn (default)
517
+ console.warn(`[on_stage_complete] hook failed for stage "${stageDef.name}": ${hookResult.stderr}`);
518
+ }
519
+ }
520
+ }
521
+ }
522
+
523
+ // Finalize task run
524
+ taskRun.status = stageStatus === 'success' ? 'success' : 'failed';
525
+ taskRun.completed_at = new Date().toISOString();
526
+
527
+ stageRun.tasks = [taskRun];
528
+ stageRun.status = stageStatus;
529
+ stageRun.completed_at = new Date().toISOString();
530
+
531
+ // Extract result data for observability
532
+ const lastResult = ralphResult.status === 'success' ? ralphResult.result : undefined;
533
+
534
+ // Populate stage output for observability and context propagation
535
+ if (lastResult?.output !== undefined) {
536
+ stageRun.output = lastResult.output;
537
+ }
538
+ const stageDurationMs = stageRun.completed_at && stageRun.started_at
539
+ ? new Date(stageRun.completed_at).getTime() - new Date(stageRun.started_at).getTime()
540
+ : 0;
541
+
542
+ this.config.events?.onStageComplete?.({
543
+ stage_name: stageDef.name,
544
+ stage_index: stageIndex,
545
+ total_stages: totalStages,
546
+ status: stageStatus,
547
+ attempts: ralphResult.attempts,
548
+ duration_ms: stageDurationMs,
549
+ output_summary: stageStatus === 'rejected'
550
+ ? `REJECTED: ${postResult!.rejection_reason}`
551
+ : lastResult ? summarizeOutput(lastResult.output) : undefined,
552
+ output: lastResult?.output,
553
+ tool_calls: lastResult ? lastResult.tool_calls : undefined,
554
+ token_usage: lastResult?.token_usage,
555
+ rejection_reason: postResult?.rejection_reason,
556
+ rejection_details: postResult?.rejection_details,
557
+ });
558
+ this.config.emitter.emit({ type: 'stage_complete', stageId: stageRunId, stageName: stageDef.name });
559
+
560
+ return {
561
+ stageRun: stageRun,
562
+ status: stageStatus,
563
+ postValidation: postResult,
564
+ lastAgentOutput: lastResult?.output,
565
+ toolCalls: lastResult?.tool_calls,
566
+ tokensDelta: lastResult?.token_usage?.total_tokens ?? 0,
567
+ toolCallsDelta: lastResult?.tool_calls_count ?? 0,
568
+ };
569
+ }
570
+
571
+ private buildValidator(
572
+ contract: OutputContract | null,
573
+ stageDef: StageDefinition
574
+ ): Validator<AgentRunResult> {
575
+ // Always fail if runner returned a terminal error (e.g. max tool iterations)
576
+ const errorCheck: Validator<AgentRunResult> = (result) =>
577
+ result.error
578
+ ? { valid: false, errors: [result.error], warnings: [] }
579
+ : { valid: true, errors: [], warnings: [] };
580
+
581
+ // No contract → only the error check applies
582
+ if (!contract) {
583
+ return errorCheck;
584
+ }
585
+
586
+ // Compose ralph's built-in validators
587
+ const validators: Validator<AgentRunResult>[] = [errorCheck];
588
+
589
+ // Schema validation (required_fields)
590
+ if (contract.schema?.required_fields) {
591
+ validators.push((result) => validateSchema(result.output, contract));
592
+ }
593
+
594
+ // Normalize tool call requirements from contract or stage definition
595
+ const toolCallReqs: ToolCallRequirements | undefined = contract.tool_calls
596
+ ?? (stageDef.tools?.required ? { required_tools: stageDef.tools.required } : undefined);
597
+
598
+ // Tool calls count validation
599
+ if (toolCallReqs?.minimum !== undefined || toolCallReqs?.maximum !== undefined || toolCallReqs?.required_tools) {
600
+ validators.push((result) => validateToolCalls(result.tool_calls, toolCallReqs));
601
+ }
602
+
603
+ // Required tools validation
604
+ if (toolCallReqs?.required_tools?.length) {
605
+ validators.push((result) => validateRequiredTools(result.tool_calls, toolCallReqs));
606
+ }
607
+
608
+ // Counted tools validation (OR semantics — any of these count toward minimum)
609
+ if (toolCallReqs?.counted_tools?.length) {
610
+ validators.push((result) => validateCountedTools(result.tool_calls, toolCallReqs));
611
+ }
612
+
613
+ // Tool groups validation (OR per group — at least one tool from each group must be called)
614
+ if (toolCallReqs?.required_tool_groups?.length) {
615
+ validators.push((result) => validateToolGroups(result.tool_calls, toolCallReqs));
616
+ }
617
+
618
+ if (validators.length === 0) {
619
+ return () => ({ valid: true, errors: [], warnings: [] });
620
+ }
621
+
622
+ return compose(...validators);
623
+ }
624
+
625
+ private resolveRetryStrategy(strategyName?: string) {
626
+ switch (strategyName) {
627
+ case 'exponential':
628
+ return exponentialBackoff(1000, 30000);
629
+ case 'fixed':
630
+ return fixedDelay(2000);
631
+ case 'none':
632
+ case 'prompt_escalation':
633
+ // Prompt escalation happens in runner via executionContext
634
+ // No delay needed — ralph handles the retry, runner handles the escalation
635
+ return noDelay();
636
+ default:
637
+ return exponentialBackoff(1000, 30000);
638
+ }
639
+ }
640
+
641
+ private async persistKeymap(runId: string, keymap: Record<string, string>): Promise<void> {
642
+ if (Object.keys(keymap).length === 0) return;
643
+ try {
644
+ const { mkdir, writeFile } = await import('node:fs/promises');
645
+ // configsDir is .studio/ directly — keymap goes in .studio/runs/anonymization/
646
+ const anonDir = join(this.config.configsDir, 'runs', 'anonymization');
647
+ await mkdir(anonDir, { recursive: true });
648
+ const keymapPath = join(anonDir, `${runId}.keymap.json`);
649
+ await writeFile(keymapPath, JSON.stringify(keymap, null, 2), 'utf-8');
650
+ } catch {
651
+ // Non-fatal — keymap persistence is best-effort
652
+ }
653
+ }
654
+ }
@@ -0,0 +1,8 @@
1
+ // Resolve stages (sequential for v7)
2
+ import type { StageDefinition } from '@studio-foundation/contracts';
3
+
4
+ export function resolveStages(stages: StageDefinition[]): StageDefinition[] {
5
+ // For v7: just return stages in order (sequential execution)
6
+ // Future: could support DAG, parallel stages
7
+ return stages;
8
+ }