@bridge_gpt/mcp-server 0.1.14 → 0.1.17

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.
@@ -0,0 +1,1150 @@
1
+ /**
2
+ * Chain orchestrator — server-side sequential execution for the new
3
+ * ``run_full_automation`` / ``resume_full_automation`` MCP tools (BAPI-326).
4
+ *
5
+ * Design rules (mirrors pipeline-orchestrator.ts):
6
+ * - Never imports ``mcp_server/src/index.ts``. All runtime dependencies are
7
+ * injected through ``ChainOrchestratorDeps`` (which extends
8
+ * ``PipelineOrchestratorDeps`` with the chain-recipe registry).
9
+ * - Chain runs are persisted server-side via the Bridge API routes at
10
+ * ``/jira/chain-runs/runs``. Every state transition extends the idle TTL.
11
+ * - The chain drives existing child pipelines via ``runPipeline`` /
12
+ * ``resumePipeline``. Only compact per-stage metadata + heuristic
13
+ * (no-LLM) summaries are persisted in ``chain_runs.stages`` — never the
14
+ * full child pipeline ``results`` arrays.
15
+ * - Stage 3 (start-tickets) is special-cased: the orchestrator does NOT call
16
+ * ``runPipeline``; it emits an ``agent_task`` envelope naming the exact
17
+ * ``/start-tickets ...`` command for the host agent to invoke in-session.
18
+ * - Cross-stage variables (created_ticket_keys -> review fan-out ->
19
+ * reviewed_ticket_keys -> start-tickets) are resolved server-side. Missing
20
+ * cross-stage variables produce a typed ``VALIDATION`` failure — never a
21
+ * placeholder value.
22
+ */
23
+ import { runPipeline, resumePipeline, peekPipelineRun } from "./pipeline-orchestrator.js";
24
+ import { toStringVariable, substituteChainValue } from "./chain-utils.js";
25
+ const CHAIN_NAME = "full-automation";
26
+ const START_TICKETS_PIPELINE = "start-tickets";
27
+ // Display titles for preambles, keyed by child pipeline name.
28
+ const STAGE_TITLES = {
29
+ "idea-to-ticket": "Idea to ticket",
30
+ "review-ticket": "Review ticket",
31
+ "start-tickets": "Start tickets",
32
+ };
33
+ export class ChainPersistenceError extends Error {
34
+ code;
35
+ constructor(code, message) {
36
+ super(message);
37
+ this.code = code;
38
+ }
39
+ }
40
+ export function createChainPersistenceClient(deps) {
41
+ const headers = {
42
+ "X-API-Key": deps.apiKey,
43
+ "Content-Type": "application/json",
44
+ };
45
+ const base = deps.baseUrl.replace(/\/+$/, "");
46
+ function urlFor(suffix, query) {
47
+ const u = new URL(`${base}/jira/chain-runs${suffix}`);
48
+ if (query) {
49
+ for (const [k, v] of Object.entries(query)) {
50
+ u.searchParams.set(k, v);
51
+ }
52
+ }
53
+ return u.toString();
54
+ }
55
+ async function readError(resp) {
56
+ const raw = await resp.text();
57
+ let codeFromBody = null;
58
+ let messageFromBody = null;
59
+ try {
60
+ const parsed = JSON.parse(raw);
61
+ const detail = parsed?.detail;
62
+ if (detail && typeof detail === "object") {
63
+ if (typeof detail.error_code === "string") {
64
+ codeFromBody = detail.error_code;
65
+ }
66
+ if (typeof detail.error === "string") {
67
+ messageFromBody = detail.error;
68
+ }
69
+ }
70
+ }
71
+ catch {
72
+ // not JSON
73
+ }
74
+ const codeFromStatus = resp.status === 404
75
+ ? "NOT_FOUND"
76
+ : resp.status === 410
77
+ ? "EXPIRED"
78
+ : resp.status === 403
79
+ ? "REPO_MISMATCH"
80
+ : resp.status === 400 || resp.status === 422
81
+ ? "VALIDATION"
82
+ : "TOOL_ERROR";
83
+ return {
84
+ code: codeFromBody ?? codeFromStatus,
85
+ message: messageFromBody ?? (raw || `HTTP ${resp.status}`),
86
+ };
87
+ }
88
+ async function createRun(input) {
89
+ const resp = await fetch(urlFor("/runs"), {
90
+ method: "POST",
91
+ headers,
92
+ body: JSON.stringify({
93
+ repo_name: deps.repoName,
94
+ chain_name: input.chain_name,
95
+ args: input.args,
96
+ current_stage_index: input.current_stage_index,
97
+ stages: input.stages,
98
+ status: input.status,
99
+ ttl_seconds: input.ttl_seconds,
100
+ }),
101
+ });
102
+ if (!resp.ok) {
103
+ const err = await readError(resp);
104
+ throw new ChainPersistenceError(err.code, err.message);
105
+ }
106
+ return (await resp.json());
107
+ }
108
+ async function getRun(chainRunId) {
109
+ const resp = await fetch(urlFor(`/runs/${encodeURIComponent(chainRunId)}`, {
110
+ repo_name: deps.repoName,
111
+ }), { headers: { "X-API-Key": deps.apiKey } });
112
+ if (!resp.ok) {
113
+ const err = await readError(resp);
114
+ throw new ChainPersistenceError(err.code, err.message);
115
+ }
116
+ return (await resp.json());
117
+ }
118
+ async function patchRun(chainRunId, body) {
119
+ const resp = await fetch(urlFor(`/runs/${encodeURIComponent(chainRunId)}`), {
120
+ method: "PATCH",
121
+ headers,
122
+ body: JSON.stringify({ repo_name: deps.repoName, ...body }),
123
+ });
124
+ if (!resp.ok) {
125
+ const err = await readError(resp);
126
+ throw new ChainPersistenceError(err.code, err.message);
127
+ }
128
+ return (await resp.json());
129
+ }
130
+ async function listRuns(status) {
131
+ const query = { repo_name: deps.repoName };
132
+ if (status)
133
+ query.status = status;
134
+ const resp = await fetch(urlFor("/runs", query), {
135
+ headers: { "X-API-Key": deps.apiKey },
136
+ });
137
+ if (!resp.ok) {
138
+ const err = await readError(resp);
139
+ throw new ChainPersistenceError(err.code, err.message);
140
+ }
141
+ const body = (await resp.json());
142
+ return body.runs ?? [];
143
+ }
144
+ async function deleteRun(chainRunId) {
145
+ const resp = await fetch(urlFor(`/runs/${encodeURIComponent(chainRunId)}`, {
146
+ repo_name: deps.repoName,
147
+ }), {
148
+ method: "DELETE",
149
+ headers: { "X-API-Key": deps.apiKey },
150
+ });
151
+ if (!resp.ok) {
152
+ const err = await readError(resp);
153
+ throw new ChainPersistenceError(err.code, err.message);
154
+ }
155
+ }
156
+ return { createRun, getRun, patchRun, listRuns, deleteRun };
157
+ }
158
+ // ---------------------------------------------------------------------------
159
+ // Pure helpers
160
+ // ---------------------------------------------------------------------------
161
+ function normalizeAutoApprove(value) {
162
+ if (value === true || value === "true")
163
+ return true;
164
+ return false;
165
+ }
166
+ const JIRA_KEY_EXACT = /^[A-Z][A-Z0-9]+-\d+$/;
167
+ /**
168
+ * Extract created Jira ticket keys from a STRUCTURED payload (an object/array
169
+ * holding `created_ticket_keys` / `ticket_key` / etc.). Collects values from the
170
+ * preferred key fields in a fixed preference order, validates each against
171
+ * `JIRA_KEY_EXACT`, and de-dupes in encounter order. There is deliberately NO
172
+ * free-text regex fallback: pass the parsed upload-step payload (via
173
+ * `extractCreatedKeysFromCompletedChild`), never a whole pipeline transcript, so
174
+ * keys merely *mentioned* by an earlier step (e.g. duplicate-detection search
175
+ * hits) can never be mistaken for this run's creations. A string / no-field
176
+ * input returns `[]`, which the caller treats as fail-closed.
177
+ */
178
+ export function extractCreatedTicketKeys(source) {
179
+ const preferredKeys = [
180
+ "created_ticket_keys",
181
+ "ticket_keys",
182
+ "child_ticket_keys",
183
+ "epic_key",
184
+ "ticket_key",
185
+ ];
186
+ const buckets = {};
187
+ for (const k of preferredKeys)
188
+ buckets[k] = [];
189
+ const visit = (value) => {
190
+ if (Array.isArray(value)) {
191
+ for (const item of value)
192
+ visit(item);
193
+ return;
194
+ }
195
+ if (value && typeof value === "object") {
196
+ const obj = value;
197
+ for (const key of preferredKeys) {
198
+ if (key in obj) {
199
+ const v = obj[key];
200
+ if (typeof v === "string") {
201
+ buckets[key].push(v);
202
+ }
203
+ else if (Array.isArray(v)) {
204
+ for (const elem of v) {
205
+ if (typeof elem === "string")
206
+ buckets[key].push(elem);
207
+ }
208
+ }
209
+ }
210
+ }
211
+ for (const v of Object.values(obj))
212
+ visit(v);
213
+ }
214
+ };
215
+ visit(source);
216
+ const ordered = [];
217
+ for (const key of preferredKeys) {
218
+ for (const candidate of buckets[key]) {
219
+ if (JIRA_KEY_EXACT.test(candidate))
220
+ ordered.push(candidate);
221
+ }
222
+ }
223
+ const seen = new Set();
224
+ const result = [];
225
+ for (const key of ordered) {
226
+ if (!seen.has(key)) {
227
+ seen.add(key);
228
+ result.push(key);
229
+ }
230
+ }
231
+ return result;
232
+ }
233
+ /**
234
+ * Pull a JSON object out of an agent's free-text result. Prefers the LAST fenced
235
+ * ```json … ``` block (the structured payload the upload step is told to emit as
236
+ * its final content); otherwise tries the last balanced `{ … }` slice that
237
+ * `JSON.parse`s. Returns the parsed value or `null`.
238
+ */
239
+ export function parseStructuredAgentResult(text) {
240
+ if (typeof text !== "string" || text.trim() === "")
241
+ return null;
242
+ // Prefer fenced ```json blocks; take the last one.
243
+ const fences = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)];
244
+ for (let i = fences.length - 1; i >= 0; i--) {
245
+ const parsed = tryParseJson(fences[i][1]);
246
+ if (parsed !== undefined)
247
+ return parsed;
248
+ }
249
+ // Fallback: try each `{ … }` candidate. For each opening brace (last first),
250
+ // walk closing braces from the last back toward it, so a balanced object is
251
+ // found even when a stray `}` trails the valid JSON (using the loop-local
252
+ // `end`, not a single text-wide `lastIndexOf("}")`).
253
+ for (let start = text.lastIndexOf("{"); start >= 0; start = text.lastIndexOf("{", start - 1)) {
254
+ for (let end = text.lastIndexOf("}"); end > start; end = text.lastIndexOf("}", end - 1)) {
255
+ const parsed = tryParseJson(text.slice(start, end + 1));
256
+ if (parsed !== undefined)
257
+ return parsed;
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+ function tryParseJson(raw) {
263
+ try {
264
+ return JSON.parse(raw.trim());
265
+ }
266
+ catch {
267
+ return undefined;
268
+ }
269
+ }
270
+ /**
271
+ * Derive the keys created by a completed idea-to-ticket child run AUTHORITATIVELY
272
+ * from the upload step's structured output — never by scanning the whole
273
+ * transcript. The upload step (`upload-and-track.md`) is the LAST step of the
274
+ * idea-to-ticket pipeline and the chain never passes `skip_steps`, so it is the
275
+ * final entry of `results`. Its `result` is parsed for a `{ created_ticket_keys }`
276
+ * payload. Returns `[]` (→ caller fails closed) if the payload is absent or
277
+ * malformed, so dedup-search hits from earlier steps can never become targets.
278
+ */
279
+ export function extractCreatedKeysFromCompletedChild(childEnv) {
280
+ const results = childEnv.results;
281
+ if (!Array.isArray(results) || results.length === 0)
282
+ return [];
283
+ const uploadResult = results[results.length - 1]?.result;
284
+ const payload = typeof uploadResult === "string"
285
+ ? parseStructuredAgentResult(uploadResult)
286
+ : uploadResult;
287
+ return extractCreatedTicketKeys(payload);
288
+ }
289
+ /**
290
+ * True when ``token`` appears in a failed child envelope's failure-text fields:
291
+ * the top-level ``error`` message and each step result's ``error`` / string
292
+ * ``result``. Deliberately does NOT scan echoed input, args, or other free-form
293
+ * fields, so a coincidental token elsewhere cannot misclassify the failure.
294
+ */
295
+ function failedEnvelopeMentions(env, token) {
296
+ if (env.status !== "failed")
297
+ return false;
298
+ if (typeof env.error === "string" && env.error.includes(token))
299
+ return true;
300
+ for (const r of env.results ?? []) {
301
+ if (typeof r.error === "string" && r.error.includes(token))
302
+ return true;
303
+ if (typeof r.result === "string" && r.result.includes(token))
304
+ return true;
305
+ }
306
+ return false;
307
+ }
308
+ /** Heuristic, no-LLM one-line stage summary. */
309
+ export function summarizeStageCompletion(pipelineName, keys) {
310
+ const joined = keys.join(", ");
311
+ if (pipelineName === "idea-to-ticket") {
312
+ return `Created tickets: ${joined}`;
313
+ }
314
+ if (pipelineName === "review-ticket") {
315
+ return `Reviewed tickets: ${joined}`;
316
+ }
317
+ if (pipelineName === START_TICKETS_PIPELINE) {
318
+ return `Spawned worktrees for ${keys.length} ticket(s): ${joined}`;
319
+ }
320
+ return `Completed ${pipelineName}: ${joined}`;
321
+ }
322
+ function stageTitle(pipelineName) {
323
+ return STAGE_TITLES[pipelineName] ?? pipelineName;
324
+ }
325
+ /** Build the markdown preamble for the active (or final) stage. */
326
+ export function buildPreamble(recipe, stageIndex, stages) {
327
+ const total = recipe.stages.length;
328
+ const lines = [];
329
+ if (stageIndex >= total) {
330
+ lines.push(`### Full automation complete (${total} of ${total} stages)`);
331
+ }
332
+ else {
333
+ const title = stageTitle(recipe.stages[stageIndex].pipeline_name);
334
+ lines.push(`### Stage ${stageIndex + 1} of ${total} — ${title}`);
335
+ }
336
+ const priorSummaries = [];
337
+ const upTo = Math.min(stageIndex, stages.length);
338
+ for (let i = 0; i < upTo; i++) {
339
+ const s = stages[i];
340
+ if (s && typeof s.summary === "string" && s.summary.trim() !== "") {
341
+ priorSummaries.push(`- ${s.summary}`);
342
+ }
343
+ }
344
+ if (priorSummaries.length > 0) {
345
+ lines.push("", "Completed so far:", ...priorSummaries);
346
+ }
347
+ return lines.join("\n");
348
+ }
349
+ const PRIOR_RESULT_MAX_CHARS = 2000;
350
+ /**
351
+ * Build a compact, read-only appendix surfacing the OUTPUTS of prior server-side
352
+ * `mcp_call` steps (e.g. `get_project_standards`) so the chain's driving agent
353
+ * can see context it would otherwise miss — the chain forwards only the
354
+ * agent_task `instruction`, unlike the standalone pipeline path whose envelope
355
+ * carries `results`. Skips agent_task results (the agent produced those) and
356
+ * failed steps; truncates each output. Returns "" when there is nothing to add,
357
+ * so the appendix is purely additive.
358
+ */
359
+ function buildPriorResultsAppendix(results) {
360
+ if (!Array.isArray(results))
361
+ return "";
362
+ const lines = [];
363
+ for (const r of results) {
364
+ if (r.type !== "mcp_call" || !r.ok)
365
+ continue;
366
+ const raw = typeof r.result === "string" ? r.result : stringifyResult(r.result);
367
+ if (raw.trim() === "")
368
+ continue;
369
+ const truncated = raw.length > PRIOR_RESULT_MAX_CHARS
370
+ ? `${raw.slice(0, PRIOR_RESULT_MAX_CHARS)}… [truncated]`
371
+ : raw;
372
+ lines.push(`- \`${r.tool ?? r.description}\`: ${truncated}`);
373
+ }
374
+ if (lines.length === 0)
375
+ return "";
376
+ return (`\n\n## Prior step outputs (read-only context)\n` +
377
+ `Server-side step outputs from earlier in this pipeline run that you cannot ` +
378
+ `fetch yourself:\n${lines.join("\n")}`);
379
+ }
380
+ function stringifyResult(value) {
381
+ if (value === undefined || value === null)
382
+ return "";
383
+ try {
384
+ return JSON.stringify(value);
385
+ }
386
+ catch {
387
+ return String(value);
388
+ }
389
+ }
390
+ // ---------------------------------------------------------------------------
391
+ // Envelope builders
392
+ // ---------------------------------------------------------------------------
393
+ function failedEnvelope(code, message, extras = {}) {
394
+ return {
395
+ status: "failed",
396
+ error_code: code,
397
+ error: message,
398
+ ...extras,
399
+ };
400
+ }
401
+ function buildNeedsAgentTaskEnvelope(args) {
402
+ const env = {
403
+ status: "needs_agent_task",
404
+ chain_run_id: args.chainRunId,
405
+ chain_stage: args.chainStage,
406
+ chain_step: args.chainStep,
407
+ chain_total: args.chainTotal,
408
+ preamble: args.preamble,
409
+ next_action: { kind: "agent_task", instruction: args.instruction },
410
+ };
411
+ if (args.pipelineRunId)
412
+ env.pipeline_run_id = args.pipelineRunId;
413
+ if (typeof args.pipelineStep === "number")
414
+ env.pipeline_step = args.pipelineStep;
415
+ if (typeof args.pipelineTotal === "number")
416
+ env.pipeline_total = args.pipelineTotal;
417
+ return env;
418
+ }
419
+ function buildCompletedEnvelope(recipe, row) {
420
+ const total = recipe.stages.length;
421
+ return {
422
+ status: "completed",
423
+ chain_run_id: row.chain_run_id,
424
+ chain_stage: CHAIN_NAME,
425
+ chain_step: total,
426
+ chain_total: total,
427
+ preamble: buildPreamble(recipe, total, row.stages),
428
+ next_action: { kind: "complete" },
429
+ };
430
+ }
431
+ /** Patch the chain + current stage as failed and return a VALIDATION envelope. */
432
+ async function failChainValidation(persistence, recipe, row, message) {
433
+ const idx = row.current_stage_index;
434
+ const stages = cloneStages(row.stages);
435
+ if (stages[idx])
436
+ stages[idx].status = "failed";
437
+ try {
438
+ await persistence.patchRun(row.chain_run_id, {
439
+ stages,
440
+ status: "failed",
441
+ });
442
+ }
443
+ catch {
444
+ // best-effort — the failure is what matters
445
+ }
446
+ return failedEnvelope("VALIDATION", message, {
447
+ chain_run_id: row.chain_run_id,
448
+ chain_stage: recipe.stages[idx]?.pipeline_name,
449
+ chain_step: idx + 1,
450
+ chain_total: recipe.stages.length,
451
+ });
452
+ }
453
+ function cloneStages(stages) {
454
+ return stages.map((s) => ({ ...s }));
455
+ }
456
+ /** True when ``value`` is a string whose trimmed content is non-empty. */
457
+ function isNonEmpty(value) {
458
+ return typeof value === "string" && value.trim() !== "";
459
+ }
460
+ const SLUG_STOP_WORDS = new Set([
461
+ "the", "a", "an", "of", "to", "and", "or", "for", "in", "on", "with",
462
+ ]);
463
+ /**
464
+ * Derive a kebab-case ``slug`` from an idea — the same shape the standalone
465
+ * idea-to-ticket command produces (lowercase, ~6-8 meaningful words, stop-words
466
+ * skipped, non-alphanumerics dropped, truncated to ~60 chars). Pure and
467
+ * deterministic so the chain can supply idea-to-ticket's required ``slug``
468
+ * variable without consulting the agent.
469
+ */
470
+ export function deriveSlug(idea) {
471
+ const words = idea
472
+ .toLowerCase()
473
+ .replace(/[^a-z0-9\s-]/g, " ")
474
+ .split(/[\s-]+/)
475
+ .filter((w) => w.length > 0 && !SLUG_STOP_WORDS.has(w));
476
+ let slug = words.slice(0, 8).join("-");
477
+ if (slug.length > 60) {
478
+ slug = slug.slice(0, 60).replace(/-+$/g, "");
479
+ }
480
+ return slug || "idea";
481
+ }
482
+ /**
483
+ * Derive idea-to-ticket's required ``run_id`` (the per-run artifact-directory
484
+ * id) from the already-unique ``chain_run_id``. Deterministic — no clock or RNG
485
+ * — so the value is stable across any re-derivation within a chain run.
486
+ */
487
+ export function deriveRunId(chainRunId) {
488
+ return isNonEmpty(chainRunId) ? `fa-${chainRunId}` : "fa-run";
489
+ }
490
+ /** Build the string variable map for a stage's child pipeline. */
491
+ function buildStageVariables(stage, args, extra = {}) {
492
+ const baseVars = {
493
+ idea: toStringVariable(args.idea),
494
+ auto_approve: toStringVariable(args.auto_approve),
495
+ scheduled_at: toStringVariable(args.scheduled_at),
496
+ max_children: toStringVariable(args.max_children),
497
+ allow_duplicate: toStringVariable(args.allow_duplicate),
498
+ agent: toStringVariable(args.agent),
499
+ ...extra,
500
+ };
501
+ const resolved = {};
502
+ for (const [k, template] of Object.entries(stage.variables ?? {})) {
503
+ resolved[k] = toStringVariable(substituteChainValue(template, baseVars));
504
+ }
505
+ return resolved;
506
+ }
507
+ /** Resolve a fan-out / cross-stage input list from prior stage outputs. */
508
+ function resolveCrossStageList(row, stageIndex, key) {
509
+ for (let i = stageIndex - 1; i >= 0; i--) {
510
+ const outputs = row.stages[i]?.outputs;
511
+ if (outputs && Array.isArray(outputs[key])) {
512
+ return outputs[key].filter((v) => typeof v === "string");
513
+ }
514
+ }
515
+ return null;
516
+ }
517
+ // ---------------------------------------------------------------------------
518
+ // Child-pipeline envelope handling
519
+ // ---------------------------------------------------------------------------
520
+ /**
521
+ * Process a child pipeline envelope (from runPipeline/resumePipeline) for the
522
+ * current stage. Returns a StageOutcome:
523
+ * - pause: child needs an agent task — chain paused.
524
+ * - fail: child failed (or produced no usable cross-stage data).
525
+ * - advanced: child completed; chain row was patched to reflect progress.
526
+ *
527
+ * For single-child stages (idea-to-ticket), "advanced" finalizes the stage and
528
+ * bumps current_stage_index. For fan-out stages (review-ticket), "advanced"
529
+ * only records the completed child and increments current_child_index; the
530
+ * caller's loop decides when the whole stage is finished.
531
+ */
532
+ async function handleChildPipelineEnvelope(persistence, recipe, row, childEnv, opts) {
533
+ const idx = row.current_stage_index;
534
+ const stageRecipe = recipe.stages[idx];
535
+ const total = recipe.stages.length;
536
+ if (childEnv.status === "needs_agent_task") {
537
+ const stages = cloneStages(row.stages);
538
+ const stage = stages[idx];
539
+ stage.status = "paused";
540
+ stage.pipeline_run_id = childEnv.pipeline_run_id;
541
+ if (opts.fanOut) {
542
+ stage.current_child_index = opts.childIndex ?? stage.current_child_index ?? 0;
543
+ stage.child_runs = upsertChildRun(stage.child_runs, {
544
+ ticket_key: opts.ticketKey,
545
+ pipeline_run_id: childEnv.pipeline_run_id,
546
+ status: "paused",
547
+ });
548
+ }
549
+ let updated;
550
+ try {
551
+ updated = await persistence.patchRun(row.chain_run_id, {
552
+ stages,
553
+ status: "paused",
554
+ });
555
+ }
556
+ catch (err) {
557
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
558
+ }
559
+ const instruction = `${childEnv.instruction}` +
560
+ `${buildPriorResultsAppendix(childEnv.results)}\n\n` +
561
+ `When done, call \`resume_full_automation\` with \`chain_run_id\` ` +
562
+ `"${row.chain_run_id}" and \`agent_result\` set to the result described above.`;
563
+ return {
564
+ kind: "pause",
565
+ envelope: buildNeedsAgentTaskEnvelope({
566
+ chainRunId: updated.chain_run_id,
567
+ chainStage: stageRecipe.pipeline_name,
568
+ chainStep: idx + 1,
569
+ chainTotal: total,
570
+ preamble: buildPreamble(recipe, idx, updated.stages),
571
+ instruction,
572
+ pipelineRunId: childEnv.pipeline_run_id,
573
+ pipelineStep: childEnv.step_index,
574
+ pipelineTotal: childEnv.total_steps,
575
+ }),
576
+ };
577
+ }
578
+ if (childEnv.status === "failed") {
579
+ // Stage 0 vagueness or no-keys produced => VALIDATION. Anchor the sentinel
580
+ // check to the failure-text fields (the halt error + per-step error/result
581
+ // strings) rather than the whole stringified envelope, so an echoed input,
582
+ // agent_result, or arg that happens to contain the token can't misclassify.
583
+ if (idx === 0 && failedEnvelopeMentions(childEnv, "too_vague_to_ticket")) {
584
+ return {
585
+ kind: "fail",
586
+ envelope: await failChainValidation(persistence, recipe, row, "Idea was too vague to produce a ticket (too_vague_to_ticket)."),
587
+ };
588
+ }
589
+ const code = isChainErrorCode(childEnv.error_code)
590
+ ? childEnv.error_code
591
+ : "TOOL_ERROR";
592
+ const stages = cloneStages(row.stages);
593
+ if (stages[idx])
594
+ stages[idx].status = "failed";
595
+ try {
596
+ await persistence.patchRun(row.chain_run_id, { stages, status: "failed" });
597
+ }
598
+ catch {
599
+ // best-effort
600
+ }
601
+ return {
602
+ kind: "fail",
603
+ envelope: failedEnvelope(code, childEnv.error, {
604
+ chain_run_id: row.chain_run_id,
605
+ chain_stage: stageRecipe.pipeline_name,
606
+ chain_step: idx + 1,
607
+ chain_total: total,
608
+ }),
609
+ };
610
+ }
611
+ // childEnv.status === "completed"
612
+ if (opts.fanOut) {
613
+ const stages = cloneStages(row.stages);
614
+ const stage = stages[idx];
615
+ const childIndex = opts.childIndex ?? stage.current_child_index ?? 0;
616
+ stage.child_runs = upsertChildRun(stage.child_runs, {
617
+ ticket_key: opts.ticketKey,
618
+ pipeline_run_id: childEnv.pipeline_run_id,
619
+ status: "completed",
620
+ });
621
+ stage.current_child_index = childIndex + 1;
622
+ stage.pipeline_run_id = null;
623
+ stage.status = "running";
624
+ let updated;
625
+ try {
626
+ updated = await persistence.patchRun(row.chain_run_id, {
627
+ stages,
628
+ status: "running",
629
+ });
630
+ }
631
+ catch (err) {
632
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
633
+ }
634
+ return { kind: "advanced", row: updated };
635
+ }
636
+ // Single-child stage (idea-to-ticket): finalize. Derive created keys ONLY from
637
+ // the upload step's structured payload — never the whole transcript — so
638
+ // duplicate-detection search hits cannot become review/start-tickets targets.
639
+ const keys = extractCreatedKeysFromCompletedChild(childEnv);
640
+ if (keys.length === 0) {
641
+ return {
642
+ kind: "fail",
643
+ envelope: await failChainValidation(persistence, recipe, row, "idea-to-ticket produced no structured created_ticket_keys payload; cannot continue the chain."),
644
+ };
645
+ }
646
+ const stages = cloneStages(row.stages);
647
+ const stage = stages[idx];
648
+ stage.status = "completed";
649
+ stage.pipeline_run_id = null;
650
+ stage.outputs = { created_ticket_keys: keys };
651
+ stage.summary = summarizeStageCompletion(stageRecipe.pipeline_name, keys);
652
+ let updated;
653
+ try {
654
+ updated = await persistence.patchRun(row.chain_run_id, {
655
+ stages,
656
+ current_stage_index: idx + 1,
657
+ status: "running",
658
+ expected_status: "running",
659
+ expected_current_stage_index: idx,
660
+ });
661
+ }
662
+ catch (err) {
663
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
664
+ }
665
+ return { kind: "advanced", row: updated };
666
+ }
667
+ function upsertChildRun(existing, entry) {
668
+ const list = existing ? [...existing] : [];
669
+ const i = list.findIndex((c) => c.ticket_key !== undefined && c.ticket_key === entry.ticket_key);
670
+ if (i >= 0) {
671
+ list[i] = { ...list[i], ...entry };
672
+ }
673
+ else {
674
+ list.push(entry);
675
+ }
676
+ return list;
677
+ }
678
+ function isChainErrorCode(value) {
679
+ return (value === "VALIDATION" ||
680
+ value === "NOT_FOUND" ||
681
+ value === "EXPIRED" ||
682
+ value === "REPO_MISMATCH" ||
683
+ value === "TOOL_ERROR");
684
+ }
685
+ function persistenceFailEnvelope(err, row, recipe) {
686
+ const idx = row.current_stage_index;
687
+ if (err instanceof ChainPersistenceError) {
688
+ return failedEnvelope(err.code, err.message, {
689
+ chain_run_id: row.chain_run_id,
690
+ chain_stage: recipe.stages[idx]?.pipeline_name,
691
+ chain_step: idx + 1,
692
+ chain_total: recipe.stages.length,
693
+ });
694
+ }
695
+ return failedEnvelope("TOOL_ERROR", "An unexpected persistence error occurred.", {
696
+ chain_run_id: row.chain_run_id,
697
+ chain_stage: recipe.stages[idx]?.pipeline_name,
698
+ chain_step: idx + 1,
699
+ chain_total: recipe.stages.length,
700
+ });
701
+ }
702
+ // ---------------------------------------------------------------------------
703
+ // Stage starters
704
+ // ---------------------------------------------------------------------------
705
+ async function startOrContinueIdeaToTicketStage(deps, persistence, recipe, row, autoApprove) {
706
+ const idx = row.current_stage_index;
707
+ const stageRecipe = recipe.stages[idx];
708
+ const resolved = buildStageVariables(stageRecipe, row.args);
709
+ // idea-to-ticket declares [idea, slug, run_id, docs_dir, allow_duplicate,
710
+ // auto_approve_external, max_children]. `docs_dir` is auto-injected by the
711
+ // pipeline runner; the rest must be supplied here. `slug`/`run_id` are
712
+ // DERIVED (not chain inputs), so they cannot be recipe templates — inject
713
+ // them in code. We also backfill sane defaults so an omitted/empty chain
714
+ // input still satisfies the child pipeline's required-variable contract.
715
+ const variables = {
716
+ ...resolved,
717
+ slug: deriveSlug(toStringVariable(row.args.idea)),
718
+ run_id: deriveRunId(row.chain_run_id),
719
+ auto_approve_external: isNonEmpty(resolved.auto_approve_external)
720
+ ? resolved.auto_approve_external
721
+ : toStringVariable(row.args.auto_approve) || "false",
722
+ allow_duplicate: isNonEmpty(resolved.allow_duplicate)
723
+ ? resolved.allow_duplicate
724
+ : "false",
725
+ max_children: isNonEmpty(resolved.max_children)
726
+ ? resolved.max_children
727
+ : "10",
728
+ };
729
+ const stages = cloneStages(row.stages);
730
+ stages[idx].status = "running";
731
+ stages[idx].input_values = variables;
732
+ try {
733
+ row = await persistence.patchRun(row.chain_run_id, { stages, status: "running" });
734
+ }
735
+ catch (err) {
736
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
737
+ }
738
+ const childEnv = await runPipeline(deps, {
739
+ pipeline: stageRecipe.pipeline_name,
740
+ variables,
741
+ auto_approve: autoApprove,
742
+ ttl_seconds: numericArg(row.args.ttl_seconds),
743
+ });
744
+ return handleChildPipelineEnvelope(persistence, recipe, row, childEnv, {
745
+ fanOut: false,
746
+ });
747
+ }
748
+ async function startOrContinueReviewTicketStage(deps, persistence, recipe, row, autoApprove) {
749
+ const idx = row.current_stage_index;
750
+ const stageRecipe = recipe.stages[idx];
751
+ const fanOutInput = stageRecipe.fan_out_input ?? "created_ticket_keys";
752
+ const keys = resolveCrossStageList(row, idx, fanOutInput);
753
+ if (!keys || keys.length === 0) {
754
+ return {
755
+ kind: "fail",
756
+ envelope: await failChainValidation(persistence, recipe, row, `Missing cross-stage variable "${fanOutInput}" for review fan-out.`),
757
+ };
758
+ }
759
+ const childIndex = row.stages[idx].current_child_index ?? 0;
760
+ if (childIndex >= keys.length) {
761
+ // All children reviewed — finalize the stage.
762
+ const stages = cloneStages(row.stages);
763
+ const stage = stages[idx];
764
+ stage.status = "completed";
765
+ stage.pipeline_run_id = null;
766
+ stage.outputs = { reviewed_ticket_keys: keys };
767
+ stage.summary = summarizeStageCompletion(stageRecipe.pipeline_name, keys);
768
+ let updated;
769
+ try {
770
+ updated = await persistence.patchRun(row.chain_run_id, {
771
+ stages,
772
+ current_stage_index: idx + 1,
773
+ status: "running",
774
+ expected_status: "running",
775
+ expected_current_stage_index: idx,
776
+ });
777
+ }
778
+ catch (err) {
779
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
780
+ }
781
+ return { kind: "advanced", row: updated };
782
+ }
783
+ // Start the next pending child review.
784
+ const ticketKey = keys[childIndex];
785
+ const fanOutVariable = stageRecipe.fan_out_variable ?? "ticket_key";
786
+ const variables = buildStageVariables(stageRecipe, row.args, {
787
+ item: ticketKey,
788
+ [fanOutVariable]: ticketKey,
789
+ });
790
+ const stages = cloneStages(row.stages);
791
+ const stage = stages[idx];
792
+ stage.status = "running";
793
+ stage.current_child_index = childIndex;
794
+ stage.input_values = variables;
795
+ try {
796
+ row = await persistence.patchRun(row.chain_run_id, { stages, status: "running" });
797
+ }
798
+ catch (err) {
799
+ return { kind: "fail", envelope: persistenceFailEnvelope(err, row, recipe) };
800
+ }
801
+ const childEnv = await runPipeline(deps, {
802
+ pipeline: stageRecipe.pipeline_name,
803
+ variables,
804
+ auto_approve: autoApprove,
805
+ ttl_seconds: numericArg(row.args.ttl_seconds),
806
+ });
807
+ return handleChildPipelineEnvelope(persistence, recipe, row, childEnv, {
808
+ fanOut: true,
809
+ ticketKey,
810
+ childIndex,
811
+ });
812
+ }
813
+ async function startStartTicketsStage(persistence, recipe, row) {
814
+ const idx = row.current_stage_index;
815
+ const stageRecipe = recipe.stages[idx];
816
+ const total = recipe.stages.length;
817
+ const fanOutInput = stageRecipe.fan_out_input ?? "reviewed_ticket_keys";
818
+ const keys = resolveCrossStageList(row, idx, fanOutInput);
819
+ if (!keys || keys.length === 0) {
820
+ return failChainValidation(persistence, recipe, row, `Missing cross-stage variable "${fanOutInput}" for start-tickets.`);
821
+ }
822
+ // Hands-off chain runs (auto_approve=true, the default) spawn the
823
+ // implementation agents auto-approved too, so they don't stall on approval
824
+ // gates. A --require-approval run (auto_approve=false) spawns them interactive.
825
+ const autoApproveFlag = row.args.auto_approve === true ? " --auto-approve" : "";
826
+ const command = `/start-tickets ${keys.join(" ")}${autoApproveFlag}`;
827
+ const stages = cloneStages(row.stages);
828
+ stages[idx].status = "paused";
829
+ stages[idx].pipeline_run_id = null;
830
+ stages[idx].input_values = { reviewed_ticket_keys: keys.join(" ") };
831
+ let updated;
832
+ try {
833
+ updated = await persistence.patchRun(row.chain_run_id, {
834
+ stages,
835
+ status: "paused",
836
+ });
837
+ }
838
+ catch (err) {
839
+ return persistenceFailEnvelope(err, row, recipe);
840
+ }
841
+ const instruction = `Invoke the start-tickets command in this same session to spawn one ` +
842
+ `worktree per reviewed ticket:\n\n${command}\n\n` +
843
+ `When the worktrees have been spawned, call \`resume_full_automation\` with ` +
844
+ `\`chain_run_id\` "${row.chain_run_id}" and \`agent_result\` set to a short ` +
845
+ `summary of what start-tickets reported.`;
846
+ return buildNeedsAgentTaskEnvelope({
847
+ chainRunId: updated.chain_run_id,
848
+ chainStage: START_TICKETS_PIPELINE,
849
+ chainStep: idx + 1,
850
+ chainTotal: total,
851
+ preamble: buildPreamble(recipe, idx, updated.stages),
852
+ instruction,
853
+ });
854
+ }
855
+ function numericArg(value) {
856
+ if (typeof value === "number" && Number.isFinite(value))
857
+ return value;
858
+ return undefined;
859
+ }
860
+ // ---------------------------------------------------------------------------
861
+ // Chain driver
862
+ // ---------------------------------------------------------------------------
863
+ /**
864
+ * Drive the chain forward by starting the current stage's next unit of work.
865
+ * Called after a fresh create, or after a child completes and we advance.
866
+ * Stops at: a child pipeline pause, the start-tickets seam, completion, a
867
+ * typed validation failure, or a child pipeline failure.
868
+ */
869
+ async function continueChainExecution(deps, persistence, recipe, row, autoApprove) {
870
+ // Bounded by total stages * (max children + 1); guard against runaway loops.
871
+ let guard = 0;
872
+ const guardMax = 10000;
873
+ while (guard++ < guardMax) {
874
+ const idx = row.current_stage_index;
875
+ const total = recipe.stages.length;
876
+ if (idx >= total) {
877
+ try {
878
+ row = await persistence.patchRun(row.chain_run_id, { status: "completed" });
879
+ }
880
+ catch (err) {
881
+ return persistenceFailEnvelope(err, row, recipe);
882
+ }
883
+ return buildCompletedEnvelope(recipe, row);
884
+ }
885
+ const stageRecipe = recipe.stages[idx];
886
+ let outcome = null;
887
+ if (stageRecipe.pipeline_name === START_TICKETS_PIPELINE) {
888
+ return startStartTicketsStage(persistence, recipe, row);
889
+ }
890
+ else if (stageRecipe.fan_out_input) {
891
+ outcome = await startOrContinueReviewTicketStage(deps, persistence, recipe, row, autoApprove);
892
+ }
893
+ else {
894
+ outcome = await startOrContinueIdeaToTicketStage(deps, persistence, recipe, row, autoApprove);
895
+ }
896
+ if (outcome.kind === "pause" || outcome.kind === "fail") {
897
+ return outcome.envelope;
898
+ }
899
+ row = outcome.row;
900
+ }
901
+ return failedEnvelope("TOOL_ERROR", "Chain execution exceeded its step guard.", {
902
+ chain_run_id: row.chain_run_id,
903
+ chain_total: recipe.stages.length,
904
+ });
905
+ }
906
+ // ---------------------------------------------------------------------------
907
+ // Public entry points
908
+ // ---------------------------------------------------------------------------
909
+ export async function runFullAutomation(deps, input) {
910
+ try {
911
+ if (typeof input.idea !== "string" || input.idea.trim() === "") {
912
+ return failedEnvelope("VALIDATION", "idea must be a non-empty string.");
913
+ }
914
+ const agent = input.agent ?? "claude";
915
+ if (agent !== "claude") {
916
+ return failedEnvelope("VALIDATION", `Unsupported agent "${String(input.agent)}". Only "claude" is supported.`);
917
+ }
918
+ const recipe = deps.chainRecipes[CHAIN_NAME];
919
+ if (!recipe) {
920
+ return failedEnvelope("VALIDATION", `Chain recipe "${CHAIN_NAME}" not found.`);
921
+ }
922
+ // Full automation is hands-off by default: when the caller omits
923
+ // auto_approve, run without approval gates. The /full-automation command
924
+ // sends an explicit boolean (false only for --require-approval); this
925
+ // default governs direct run_full_automation callers.
926
+ const autoApprove = input.auto_approve === undefined
927
+ ? true
928
+ : normalizeAutoApprove(input.auto_approve);
929
+ const args = {
930
+ idea: input.idea,
931
+ auto_approve: autoApprove,
932
+ scheduled_at: input.scheduled_at ?? "",
933
+ max_children: input.max_children,
934
+ allow_duplicate: input.allow_duplicate,
935
+ agent,
936
+ ttl_seconds: input.ttl_seconds,
937
+ };
938
+ const initialStages = recipe.stages.map((stage) => ({
939
+ pipeline_name: stage.pipeline_name,
940
+ status: "pending",
941
+ }));
942
+ const persistence = createChainPersistenceClient({
943
+ baseUrl: deps.baseUrl,
944
+ apiKey: deps.apiKey,
945
+ repoName: deps.repoName,
946
+ });
947
+ let row;
948
+ try {
949
+ row = await persistence.createRun({
950
+ chain_name: CHAIN_NAME,
951
+ args,
952
+ current_stage_index: 0,
953
+ stages: initialStages,
954
+ status: "running",
955
+ ttl_seconds: input.ttl_seconds,
956
+ });
957
+ }
958
+ catch (err) {
959
+ if (err instanceof ChainPersistenceError) {
960
+ return failedEnvelope(err.code, err.message);
961
+ }
962
+ return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while creating the chain run.");
963
+ }
964
+ return continueChainExecution(deps, persistence, recipe, row, autoApprove);
965
+ }
966
+ catch (err) {
967
+ // The public envelope is intentionally sanitized (no stack traces), but
968
+ // log to stderr so smoke-testers / operators have a forensic trail (the
969
+ // MCP server runs outside Sentry).
970
+ console.error("[chain-orchestrator] unexpected error in runFullAutomation:", err);
971
+ return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while executing the full-automation chain.");
972
+ }
973
+ }
974
+ export async function resumeFullAutomation(deps, input) {
975
+ try {
976
+ const recipe = deps.chainRecipes[CHAIN_NAME];
977
+ if (!recipe) {
978
+ return failedEnvelope("VALIDATION", `Chain recipe "${CHAIN_NAME}" not found.`, { chain_run_id: input.chain_run_id });
979
+ }
980
+ const persistence = createChainPersistenceClient({
981
+ baseUrl: deps.baseUrl,
982
+ apiKey: deps.apiKey,
983
+ repoName: deps.repoName,
984
+ });
985
+ let row;
986
+ try {
987
+ row = await persistence.getRun(input.chain_run_id);
988
+ }
989
+ catch (err) {
990
+ if (err instanceof ChainPersistenceError) {
991
+ return failedEnvelope(err.code, err.message, {
992
+ chain_run_id: input.chain_run_id,
993
+ });
994
+ }
995
+ return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while fetching the chain run.", { chain_run_id: input.chain_run_id });
996
+ }
997
+ if (row.status === "expired") {
998
+ return failedEnvelope("EXPIRED", "Chain run has expired.", {
999
+ chain_run_id: row.chain_run_id,
1000
+ chain_total: recipe.stages.length,
1001
+ });
1002
+ }
1003
+ const autoApprove = normalizeAutoApprove(row.args.auto_approve);
1004
+ const idx = row.current_stage_index;
1005
+ const stageRecipe = recipe.stages[idx];
1006
+ const total = recipe.stages.length;
1007
+ if (!stageRecipe) {
1008
+ return failedEnvelope("VALIDATION", `Chain run has no active stage at index ${idx}.`, { chain_run_id: row.chain_run_id, chain_total: total });
1009
+ }
1010
+ // Stage-3 start-tickets seam: complete the chain on a non-empty result.
1011
+ if (stageRecipe.pipeline_name === START_TICKETS_PIPELINE) {
1012
+ if (typeof input.agent_result !== "string" ||
1013
+ input.agent_result.trim() === "") {
1014
+ return failedEnvelope("VALIDATION", "agent_result must be a non-empty string to complete the start-tickets stage.", {
1015
+ chain_run_id: row.chain_run_id,
1016
+ chain_stage: START_TICKETS_PIPELINE,
1017
+ chain_step: idx + 1,
1018
+ chain_total: total,
1019
+ });
1020
+ }
1021
+ const startedKeys = resolveCrossStageList(row, idx, stageRecipe.fan_out_input ?? "reviewed_ticket_keys") ?? [];
1022
+ const stages = cloneStages(row.stages);
1023
+ stages[idx].status = "completed";
1024
+ stages[idx].pipeline_run_id = null;
1025
+ stages[idx].outputs = { started_ticket_keys: startedKeys };
1026
+ stages[idx].summary = summarizeStageCompletion(START_TICKETS_PIPELINE, startedKeys);
1027
+ let updated;
1028
+ try {
1029
+ updated = await persistence.patchRun(row.chain_run_id, {
1030
+ stages,
1031
+ current_stage_index: idx + 1,
1032
+ status: "completed",
1033
+ expected_status: "paused",
1034
+ expected_current_stage_index: idx,
1035
+ });
1036
+ }
1037
+ catch (err) {
1038
+ return persistenceFailEnvelope(err, row, recipe);
1039
+ }
1040
+ return buildCompletedEnvelope(recipe, updated);
1041
+ }
1042
+ // Otherwise resume the active paused child pipeline.
1043
+ const activePipelineRunId = row.stages[idx]?.pipeline_run_id;
1044
+ if (!activePipelineRunId) {
1045
+ return failedEnvelope("VALIDATION", `No active child pipeline to resume for stage ${idx + 1}.`, {
1046
+ chain_run_id: row.chain_run_id,
1047
+ chain_stage: stageRecipe.pipeline_name,
1048
+ chain_step: idx + 1,
1049
+ chain_total: total,
1050
+ });
1051
+ }
1052
+ // Snapshot the inner pipeline run before touching anything. This lets us
1053
+ // resume the normal `paused` case, recover idempotently when the inner run
1054
+ // already reached a terminal state on a prior (failed-to-advance) resume,
1055
+ // and refuse cleanly when it is in an indeterminate state.
1056
+ const peek = await peekPipelineRun(deps, activePipelineRunId);
1057
+ if ("error_code" in peek) {
1058
+ return failedEnvelope(peek.error_code, peek.error, {
1059
+ chain_run_id: row.chain_run_id,
1060
+ chain_stage: stageRecipe.pipeline_name,
1061
+ chain_step: idx + 1,
1062
+ chain_total: total,
1063
+ });
1064
+ }
1065
+ if (peek.status !== "paused" &&
1066
+ peek.status !== "completed" &&
1067
+ peek.status !== "failed") {
1068
+ // running / expired — results are not known-final, so we can neither
1069
+ // resume (only paused runs resume) nor safely synthesize an advance.
1070
+ return {
1071
+ status: "failed",
1072
+ error_code: "VALIDATION",
1073
+ error: `Inner pipeline run is in status "${peek.status}" and cannot be ` +
1074
+ `safely resumed or recovered. Inspect pipeline_run_id ` +
1075
+ `${activePipelineRunId}.`,
1076
+ chain_run_id: row.chain_run_id,
1077
+ chain_stage: stageRecipe.pipeline_name,
1078
+ chain_step: idx + 1,
1079
+ chain_total: total,
1080
+ pipeline_run_id: activePipelineRunId,
1081
+ resumable: false,
1082
+ };
1083
+ }
1084
+ // Mirror the start path's invariant: the chain row must be "running" while
1085
+ // its inner pipeline is being advanced. The approval-gate pause left the
1086
+ // chain "paused"; flip it back BEFORE the completed-advance patch (which
1087
+ // asserts expected_status "running"). Idempotent (no expected_status) so a
1088
+ // recovery resume of an already-"running" chain does not conflict.
1089
+ try {
1090
+ row = await persistence.patchRun(row.chain_run_id, { status: "running" });
1091
+ }
1092
+ catch (err) {
1093
+ return persistenceFailEnvelope(err, row, recipe);
1094
+ }
1095
+ let childEnv;
1096
+ if (peek.status === "paused") {
1097
+ childEnv = await resumePipeline(deps, {
1098
+ pipeline_run_id: activePipelineRunId,
1099
+ agent_result: input.agent_result,
1100
+ });
1101
+ }
1102
+ else if (peek.status === "completed") {
1103
+ // Idempotent recovery: the inner pipeline already completed on a prior
1104
+ // resume; synthesize its completed envelope (no re-execution) so the
1105
+ // stage advances from the persisted results.
1106
+ childEnv = {
1107
+ status: "completed",
1108
+ pipeline_run_id: peek.pipeline_run_id,
1109
+ pipeline: peek.pipeline,
1110
+ total_steps: peek.total_steps,
1111
+ results: peek.results,
1112
+ };
1113
+ }
1114
+ else {
1115
+ // peek.status === "failed" — let handleChildPipelineEnvelope map it to a
1116
+ // stage failure from the persisted results. The pipeline run row does NOT
1117
+ // persist a top-level error_code (only `status: "failed"` + per-step
1118
+ // results), so the original code cannot be reconstructed here; surface the
1119
+ // failed step's recorded error text for diagnostics and default the code
1120
+ // to TOOL_ERROR.
1121
+ const failedStepError = peek.results.find((r) => !r.ok && typeof r.error === "string")?.error;
1122
+ childEnv = {
1123
+ status: "failed",
1124
+ error_code: "TOOL_ERROR",
1125
+ error: failedStepError
1126
+ ? `Inner pipeline run failed before the chain could advance: ${failedStepError}`
1127
+ : "Inner pipeline run failed before the chain could advance.",
1128
+ pipeline_run_id: peek.pipeline_run_id,
1129
+ pipeline: peek.pipeline,
1130
+ results: peek.results,
1131
+ };
1132
+ }
1133
+ const fanOut = !!stageRecipe.fan_out_input;
1134
+ const childIndex = row.stages[idx]?.current_child_index ?? 0;
1135
+ const ticketKey = fanOut
1136
+ ? (resolveCrossStageList(row, idx, stageRecipe.fan_out_input) ?? [])[childIndex]
1137
+ : undefined;
1138
+ const outcome = await handleChildPipelineEnvelope(persistence, recipe, row, childEnv, { fanOut, ticketKey, childIndex });
1139
+ if (outcome.kind === "pause" || outcome.kind === "fail") {
1140
+ return outcome.envelope;
1141
+ }
1142
+ return continueChainExecution(deps, persistence, recipe, outcome.row, autoApprove);
1143
+ }
1144
+ catch (err) {
1145
+ // Sanitized public envelope; log to stderr for a forensic trail (see
1146
+ // runFullAutomation's catch — the MCP server runs outside Sentry).
1147
+ console.error("[chain-orchestrator] unexpected error in resumeFullAutomation:", err);
1148
+ return failedEnvelope("TOOL_ERROR", "An unexpected error occurred while resuming the full-automation chain.", { chain_run_id: input.chain_run_id });
1149
+ }
1150
+ }