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