@bridge_gpt/mcp-server 0.2.62 → 0.2.65
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 +47 -8
- package/build/agent-launchers/claude-executor-adapter.js +134 -3
- package/build/agent-launchers/executor-adapter.js +54 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +335 -22
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +324 -0
- package/build/conductor/epic-implementer-cli.js +142 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +22 -11
- package/build/conductor/recovery-cli.js +354 -13
- package/build/conductor/recovery-operations.js +251 -5
- package/build/conductor/repair-contract.js +8 -0
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +50 -12
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +77 -11
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1211 -116
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +35 -29
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-profile.js +14 -5
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +6 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +76 -4
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1294 -26
- package/build/executor/job-types.js +10 -2
- package/build/executor/preflight.js +99 -0
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +262 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +145 -0
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +12 -0
- package/build/executor/worker-command.js +49 -13
- package/build/executor/worker-finalization.js +88 -11
- package/build/executor/worker-guard-hook.js +29 -4
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +537 -943
- package/build/install-bridge-conductor.js +231 -41
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +8 -7
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/supervisor.js +142 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +28 -17
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/docs/CONDUCTOR.md +116 -2
- package/package.json +2 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parked-child continuation — the CLI-side contract (BAPI-1300, D4b).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `api/models/epic_child_continuation.py`. Three things live here:
|
|
5
|
+
*
|
|
6
|
+
* - the canonical request FINGERPRINT, computed byte-for-byte like the server
|
|
7
|
+
* (see `docs/epic-child-continuation-wire-contract.md` and its golden vectors);
|
|
8
|
+
* - STRICT parsing of the server's typed result bodies — an unknown outcome, a
|
|
9
|
+
* missing receipt identity, or an invalid runtime outcome is a parse failure,
|
|
10
|
+
* which the caller treats as transport uncertainty, never as success;
|
|
11
|
+
* - the local RECOVERY REFERENCE: one small file per in-flight request, written
|
|
12
|
+
* BEFORE the request is sent, so a lost response or a restarted process can
|
|
13
|
+
* look the receipt up with the ORIGINAL bindings and request id instead of
|
|
14
|
+
* minting a new request.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here performs HTTP. This module never imports the Bridge API client, so
|
|
17
|
+
* it stays free of network and credential concerns.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { mkdir as fsMkdir, readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile, } from "node:fs/promises";
|
|
23
|
+
import { getPrimaryCredentialStorePath } from "../credential-store.js";
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Wire constants (must equal the Python module)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
export const CONTINUATION_WIRE_VERSION = 1;
|
|
28
|
+
export const CONTINUATION_OPERATION = "continue_parked_child";
|
|
29
|
+
export const CONTINUATION_OUTCOMES = [
|
|
30
|
+
"committed",
|
|
31
|
+
"already_continued",
|
|
32
|
+
"stale_historical_episode",
|
|
33
|
+
"refused",
|
|
34
|
+
"unsupported",
|
|
35
|
+
"unconfirmed",
|
|
36
|
+
];
|
|
37
|
+
export const CONTINUATION_RUNTIME_OUTCOMES = [
|
|
38
|
+
"queued",
|
|
39
|
+
"running",
|
|
40
|
+
"succeeded",
|
|
41
|
+
"failed",
|
|
42
|
+
"cancelled",
|
|
43
|
+
"dead_letter",
|
|
44
|
+
"unavailable",
|
|
45
|
+
];
|
|
46
|
+
const REQUEST_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,63}$/;
|
|
47
|
+
const FULL_SHA_RE = /^[0-9a-f]{40}$/;
|
|
48
|
+
const FINGERPRINT_RE = /^[0-9a-f]{64}$/;
|
|
49
|
+
const REASON_CODE_RE = /^[a-z_]{1,64}$/;
|
|
50
|
+
const MAX_TEXT = 500;
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Canonical fingerprint
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
function canonicalize(value) {
|
|
55
|
+
if (Array.isArray(value))
|
|
56
|
+
return value.map((item) => canonicalize(item));
|
|
57
|
+
if (value !== null && typeof value === "object") {
|
|
58
|
+
const record = value;
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const key of Object.keys(record).sort()) {
|
|
61
|
+
out[key] = canonicalize(record[key]);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
if (typeof value === "number" && !Number.isSafeInteger(value)) {
|
|
66
|
+
// The contract carries integers only; a float would serialize differently
|
|
67
|
+
// across runtimes, so it is refused rather than silently hashed.
|
|
68
|
+
throw new TypeError("canonical JSON accepts integers only");
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
/** Canonical UTF-8 JSON text: sorted keys at every depth, array order kept. */
|
|
73
|
+
export function canonicalJson(value) {
|
|
74
|
+
return JSON.stringify(canonicalize(value));
|
|
75
|
+
}
|
|
76
|
+
/** Lowercase SHA-256 hex of {@link canonicalJson}. */
|
|
77
|
+
export function canonicalSha256(value) {
|
|
78
|
+
return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
|
|
79
|
+
}
|
|
80
|
+
/** The fingerprint input document (identical to the Python builder). */
|
|
81
|
+
export function continuationFingerprintInput(b) {
|
|
82
|
+
return {
|
|
83
|
+
v: CONTINUATION_WIRE_VERSION,
|
|
84
|
+
operation: CONTINUATION_OPERATION,
|
|
85
|
+
target: {
|
|
86
|
+
repo_name: b.repoName,
|
|
87
|
+
epic_run_id: b.epicRunId,
|
|
88
|
+
ticket_key: b.ticketKey,
|
|
89
|
+
gate: b.parkedGate,
|
|
90
|
+
},
|
|
91
|
+
bindings: {
|
|
92
|
+
expected_row_version: b.expectedRowVersion,
|
|
93
|
+
expected_head_sha: b.expectedHeadSha,
|
|
94
|
+
source_park_generation: b.sourceParkGeneration,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function computeContinuationFingerprint(b) {
|
|
99
|
+
return canonicalSha256(continuationFingerprintInput(b));
|
|
100
|
+
}
|
|
101
|
+
/** A body that does not satisfy the typed contract. Never read as success. */
|
|
102
|
+
export class ContinuationParseError extends Error {
|
|
103
|
+
constructor(message) {
|
|
104
|
+
super(`malformed continuation response: ${message}`);
|
|
105
|
+
this.name = "ContinuationParseError";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function isRecord(value) {
|
|
109
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
function req(ok, value, what) {
|
|
112
|
+
if (!ok)
|
|
113
|
+
throw new ContinuationParseError(what);
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
function nonNegInt(value) {
|
|
117
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
118
|
+
}
|
|
119
|
+
function posInt(value) {
|
|
120
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
121
|
+
}
|
|
122
|
+
function boundedText(value) {
|
|
123
|
+
return typeof value === "string" && value.length <= MAX_TEXT;
|
|
124
|
+
}
|
|
125
|
+
function nullableText(value) {
|
|
126
|
+
if (value === null || value === undefined)
|
|
127
|
+
return null;
|
|
128
|
+
return req(boundedText(value), value, "text field");
|
|
129
|
+
}
|
|
130
|
+
export function parseContinuationReceipt(value) {
|
|
131
|
+
const r = req(isRecord(value), value, "receipt is not an object");
|
|
132
|
+
const runtime = r.runtime_outcome ?? null;
|
|
133
|
+
return {
|
|
134
|
+
receipt_id: req(typeof r.receipt_id === "string" && r.receipt_id.length > 0 && r.receipt_id.length <= 128, r.receipt_id, "receipt_id"),
|
|
135
|
+
request_id: req(typeof r.request_id === "string" && REQUEST_ID_RE.test(r.request_id), r.request_id, "request_id"),
|
|
136
|
+
epic_run_id: req(typeof r.epic_run_id === "string" && r.epic_run_id.length > 0, r.epic_run_id, "epic_run_id"),
|
|
137
|
+
ticket_key: req(typeof r.ticket_key === "string" && r.ticket_key.length > 0, r.ticket_key, "ticket_key"),
|
|
138
|
+
gate: req(typeof r.gate === "string" && r.gate.length > 0, r.gate, "gate"),
|
|
139
|
+
job_type: req(typeof r.job_type === "string" && r.job_type.length > 0, r.job_type, "job_type"),
|
|
140
|
+
source_park_generation: req(nonNegInt(r.source_park_generation), r.source_park_generation, "source_park_generation"),
|
|
141
|
+
resulting_park_generation: req(nonNegInt(r.resulting_park_generation), r.resulting_park_generation, "resulting_park_generation"),
|
|
142
|
+
original_job_id: r.original_job_id === null || r.original_job_id === undefined
|
|
143
|
+
? null
|
|
144
|
+
: req(posInt(r.original_job_id), r.original_job_id, "original_job_id"),
|
|
145
|
+
continuation_job_id: req(posInt(r.continuation_job_id), r.continuation_job_id, "continuation_job_id"),
|
|
146
|
+
bound_head_sha: r.bound_head_sha === null || r.bound_head_sha === undefined
|
|
147
|
+
? null
|
|
148
|
+
: req(typeof r.bound_head_sha === "string" && FULL_SHA_RE.test(r.bound_head_sha), r.bound_head_sha, "bound_head_sha"),
|
|
149
|
+
authorization_kind: req(r.authorization_kind === "operator" || r.authorization_kind === "driver", r.authorization_kind, "authorization_kind"),
|
|
150
|
+
authorization_ref: req(typeof r.authorization_ref === "string" && r.authorization_ref.length > 0 && r.authorization_ref.length <= 128, r.authorization_ref, "authorization_ref"),
|
|
151
|
+
committed_at: req(typeof r.committed_at === "string" && r.committed_at.length > 0, r.committed_at, "committed_at"),
|
|
152
|
+
runtime_outcome: runtime === null
|
|
153
|
+
? null
|
|
154
|
+
: req(CONTINUATION_RUNTIME_OUTCOMES.includes(runtime), runtime, "runtime_outcome"),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export function parseChildEpisodeView(value) {
|
|
158
|
+
const r = req(isRecord(value), value, "current is not an object");
|
|
159
|
+
const obligations = r.unresolved_obligations ?? [];
|
|
160
|
+
return {
|
|
161
|
+
status: req(typeof r.status === "string", r.status, "status"),
|
|
162
|
+
row_version: req(nonNegInt(r.row_version), r.row_version, "row_version"),
|
|
163
|
+
parked: req(typeof r.parked === "boolean", r.parked, "parked"),
|
|
164
|
+
parked_gate: nullableText(r.parked_gate),
|
|
165
|
+
park_generation: r.park_generation === null || r.park_generation === undefined
|
|
166
|
+
? null
|
|
167
|
+
: req(nonNegInt(r.park_generation), r.park_generation, "park_generation"),
|
|
168
|
+
expected_head_sha: nullableText(r.expected_head_sha),
|
|
169
|
+
parked_at: nullableText(r.parked_at),
|
|
170
|
+
parked_age_seconds: r.parked_age_seconds === null || r.parked_age_seconds === undefined
|
|
171
|
+
? null
|
|
172
|
+
: req(nonNegInt(r.parked_age_seconds), r.parked_age_seconds, "parked_age_seconds"),
|
|
173
|
+
needs_human_reason: nullableText(r.needs_human_reason),
|
|
174
|
+
unresolved_obligations: req(Array.isArray(obligations) && obligations.every((item) => typeof item === "string" && item.length <= 128), obligations, "unresolved_obligations"),
|
|
175
|
+
continuations_used: r.continuations_used === null || r.continuations_used === undefined
|
|
176
|
+
? null
|
|
177
|
+
: req(nonNegInt(r.continuations_used), r.continuations_used, "continuations_used"),
|
|
178
|
+
continuation_limit: r.continuation_limit === null || r.continuation_limit === undefined
|
|
179
|
+
? null
|
|
180
|
+
: req(nonNegInt(r.continuation_limit), r.continuation_limit, "continuation_limit"),
|
|
181
|
+
continuation_available: req(typeof r.continuation_available === "boolean", r.continuation_available, "continuation_available"),
|
|
182
|
+
continuation_unavailable_reason: nullableText(r.continuation_unavailable_reason),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function parseOutcome(value) {
|
|
186
|
+
return req(CONTINUATION_OUTCOMES.includes(value), value, "unknown outcome");
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Parse a continuation result body strictly. A committed or already-continued
|
|
190
|
+
* outcome MUST carry a receipt; anything less is malformed.
|
|
191
|
+
*/
|
|
192
|
+
export function parseContinueParkedChildResult(value) {
|
|
193
|
+
const r = req(isRecord(value), value, "body is not an object");
|
|
194
|
+
const outcome = parseOutcome(r.outcome);
|
|
195
|
+
const reason = r.reason_code ?? null;
|
|
196
|
+
const receipt = r.receipt === null || r.receipt === undefined ? null : parseContinuationReceipt(r.receipt);
|
|
197
|
+
if ((outcome === "committed" || outcome === "already_continued" || outcome === "stale_historical_episode") && receipt === null) {
|
|
198
|
+
throw new ContinuationParseError(`${outcome} without a receipt`);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
outcome,
|
|
202
|
+
reason_code: reason === null ? null : req(typeof reason === "string" && REASON_CODE_RE.test(reason), reason, "reason_code"),
|
|
203
|
+
explanation: req(boundedText(r.explanation), r.explanation, "explanation"),
|
|
204
|
+
replayed: r.replayed === true,
|
|
205
|
+
retryable: r.retryable === true,
|
|
206
|
+
receipt,
|
|
207
|
+
current: r.current === null || r.current === undefined ? null : parseChildEpisodeView(r.current),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
export function parseContinuationStatusResult(value) {
|
|
211
|
+
const r = req(isRecord(value), value, "body is not an object");
|
|
212
|
+
const found = req(typeof r.found === "boolean", r.found, "found");
|
|
213
|
+
const receipt = r.receipt === null || r.receipt === undefined ? null : parseContinuationReceipt(r.receipt);
|
|
214
|
+
if (found && receipt === null)
|
|
215
|
+
throw new ContinuationParseError("found without a receipt");
|
|
216
|
+
return {
|
|
217
|
+
epic_run_id: req(typeof r.epic_run_id === "string" && r.epic_run_id.length > 0, r.epic_run_id, "epic_run_id"),
|
|
218
|
+
ticket_key: req(typeof r.ticket_key === "string" && r.ticket_key.length > 0, r.ticket_key, "ticket_key"),
|
|
219
|
+
current: parseChildEpisodeView(r.current),
|
|
220
|
+
found,
|
|
221
|
+
outcome: r.outcome === null || r.outcome === undefined ? null : parseOutcome(r.outcome),
|
|
222
|
+
receipt,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** Directory holding recovery references: beside the user credential store. */
|
|
226
|
+
export function getContinuationReferenceDirectory(deps = {}) {
|
|
227
|
+
const credentials = getPrimaryCredentialStorePath({
|
|
228
|
+
env: deps.env ?? process.env,
|
|
229
|
+
homedir: deps.homedir ?? os.homedir,
|
|
230
|
+
});
|
|
231
|
+
return path.join(path.dirname(credentials), "epic-child-continuations");
|
|
232
|
+
}
|
|
233
|
+
function referenceFileName(epicRunId, ticketKey) {
|
|
234
|
+
// Hashed so an arbitrary run id or ticket key can never form a path segment.
|
|
235
|
+
const digest = createHash("sha256").update(`${epicRunId}\u0000${ticketKey}`, "utf8").digest("hex");
|
|
236
|
+
return `${digest.slice(0, 32)}.json`;
|
|
237
|
+
}
|
|
238
|
+
export function getContinuationReferencePath(epicRunId, ticketKey, deps = {}) {
|
|
239
|
+
return path.join(getContinuationReferenceDirectory(deps), referenceFileName(epicRunId, ticketKey));
|
|
240
|
+
}
|
|
241
|
+
function parseReference(raw) {
|
|
242
|
+
if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.bindings))
|
|
243
|
+
return null;
|
|
244
|
+
const b = raw.bindings;
|
|
245
|
+
const bindings = {
|
|
246
|
+
repoName: String(b.repoName ?? ""),
|
|
247
|
+
epicRunId: String(b.epicRunId ?? ""),
|
|
248
|
+
ticketKey: String(b.ticketKey ?? ""),
|
|
249
|
+
parkedGate: String(b.parkedGate ?? ""),
|
|
250
|
+
expectedRowVersion: b.expectedRowVersion,
|
|
251
|
+
expectedHeadSha: (b.expectedHeadSha ?? null),
|
|
252
|
+
sourceParkGeneration: b.sourceParkGeneration,
|
|
253
|
+
};
|
|
254
|
+
if (!bindings.repoName || !bindings.epicRunId || !bindings.ticketKey || !bindings.parkedGate ||
|
|
255
|
+
!nonNegInt(bindings.expectedRowVersion) || !nonNegInt(bindings.sourceParkGeneration) ||
|
|
256
|
+
(bindings.expectedHeadSha !== null && !FULL_SHA_RE.test(bindings.expectedHeadSha))) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const requestId = raw.requestId;
|
|
260
|
+
const fingerprint = raw.requestFingerprint;
|
|
261
|
+
if (typeof requestId !== "string" || !REQUEST_ID_RE.test(requestId))
|
|
262
|
+
return null;
|
|
263
|
+
if (typeof fingerprint !== "string" || !FINGERPRINT_RE.test(fingerprint))
|
|
264
|
+
return null;
|
|
265
|
+
// A stored reference whose fingerprint no longer matches its own bindings was
|
|
266
|
+
// tampered with or truncated; it is never trusted to authorize a replay.
|
|
267
|
+
if (computeContinuationFingerprint(bindings) !== fingerprint)
|
|
268
|
+
return null;
|
|
269
|
+
return {
|
|
270
|
+
version: 1,
|
|
271
|
+
bindings,
|
|
272
|
+
requestId,
|
|
273
|
+
requestFingerprint: fingerprint,
|
|
274
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "",
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/** Read the in-flight reference for a child, or `null` when none (or unusable). */
|
|
278
|
+
export async function readContinuationReference(epicRunId, ticketKey, deps = {}) {
|
|
279
|
+
const read = deps.readFile ?? fsReadFile;
|
|
280
|
+
let text;
|
|
281
|
+
try {
|
|
282
|
+
text = (await read(getContinuationReferencePath(epicRunId, ticketKey, deps), "utf8"));
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
return parseReference(JSON.parse(text));
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Durably write a reference (0700 directory, 0600 file, write-then-rename). */
|
|
295
|
+
export async function writeContinuationReference(reference, deps = {}, key = reference.bindings) {
|
|
296
|
+
const mkdir = deps.mkdir ?? fsMkdir;
|
|
297
|
+
const writeFile = deps.writeFile ?? fsWriteFile;
|
|
298
|
+
const rename = deps.rename ?? fsRename;
|
|
299
|
+
const dir = getContinuationReferenceDirectory(deps);
|
|
300
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
301
|
+
// ``key`` only names the file (the caller may address the run by its epic key);
|
|
302
|
+
// the stored bindings are always the exact, fingerprinted ones.
|
|
303
|
+
const target = getContinuationReferencePath(key.epicRunId, key.ticketKey, deps);
|
|
304
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
305
|
+
await writeFile(temp, `${JSON.stringify(reference, null, 2)}\n`, { mode: 0o600 });
|
|
306
|
+
await rename(temp, target);
|
|
307
|
+
}
|
|
308
|
+
/** Remove a child's reference once its outcome is authoritative. Never throws. */
|
|
309
|
+
export async function clearContinuationReference(epicRunId, ticketKey, deps = {}) {
|
|
310
|
+
const unlink = deps.unlink ?? fsUnlink;
|
|
311
|
+
try {
|
|
312
|
+
await unlink(getContinuationReferencePath(epicRunId, ticketKey, deps));
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// Already gone — nothing to clear.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** A fresh request id: a UUID, which satisfies the server's request-id bound. */
|
|
319
|
+
export function newContinuationRequestId() {
|
|
320
|
+
return randomUUID();
|
|
321
|
+
}
|
|
322
|
+
export function isFullSha(value) {
|
|
323
|
+
return typeof value === "string" && FULL_SHA_RE.test(value);
|
|
324
|
+
}
|
|
@@ -20,16 +20,19 @@
|
|
|
20
20
|
* unchanged until S5.
|
|
21
21
|
*/
|
|
22
22
|
import { readFileSync, unlinkSync } from "node:fs";
|
|
23
|
+
import { createRequire } from "node:module";
|
|
23
24
|
import { ConductorValidationError, ConductorEpicTickV1FrozenError, toConductorErrorEnvelope, } from "./errors.js";
|
|
24
25
|
import { emitConductorEvent, purgeConductorLedger, sendWorkerMessage, checkWorkerMessages, } from "./store.js";
|
|
25
26
|
import { isDuplicateConstraintError } from "./producer-ledger.js";
|
|
26
27
|
import { SEMANTIC_EVENT_TYPES } from "./taxonomy.js";
|
|
27
28
|
import { DRIVER_CONTEXT_MAX_BYTES, POLICY_PATCH_MAX_BYTES } from "./repair-contract.js";
|
|
28
|
-
import { installConductorGitHooks, resolveConductorHookBin } from "./git-hooks.js";
|
|
29
|
+
import { CONDUCTOR_BIN_UNRESOLVED_ERROR, installConductorGitHooks, resolveConductorHookBin, } from "./git-hooks.js";
|
|
30
|
+
import { HOOK_RUNTIME_PROBE_ACTION, HOOK_RUNTIME_PROBE_MARKER } from "./hook-runtime-qualification.js";
|
|
29
31
|
import { runFileScopeGuardCli } from "./file-scope-guard.js";
|
|
30
32
|
import { runPostCommitHookProducer, runReferenceTransactionHookProducer } from "./git-producer.js";
|
|
31
33
|
import { buildConductorDoctorReport, formatConductorDoctorReport } from "./doctor.js";
|
|
32
34
|
import { resolveSupervisorConfig } from "./supervisor-config.js";
|
|
35
|
+
import { VERSION } from "../version.generated.js";
|
|
33
36
|
// `runSupervisor` is imported LAZILY inside runSuperviseCommand: it transitively
|
|
34
37
|
// pulls in the SQLite store, and a static import here would force every
|
|
35
38
|
// epic-implementer CLI invocation (and every test that mocks ./store.js for the
|
|
@@ -103,6 +106,12 @@ export function getConductorUsage() {
|
|
|
103
106
|
" resume-run --epic-run-id <id> [--clear-quarantine] [--json]",
|
|
104
107
|
" Resume a paused run (a quarantined one only with --clear-quarantine);",
|
|
105
108
|
" activation is re-checked server-side. Active/no-control is a no-op.",
|
|
109
|
+
" continue-child --epic-run-id <id> --ticket-key <key> [--reason <text>] [--json]",
|
|
110
|
+
" Continue a parked child's CURRENT episode WITHOUT a reset: same attempt,",
|
|
111
|
+
" retained work, only the allowance left. At most one dispatch per episode.",
|
|
112
|
+
" continuation-receipt --epic-run-id <id> --ticket-key <key> [--request-id <id>] [--json]",
|
|
113
|
+
" Read-only: the child's current episode and whether a continuation",
|
|
114
|
+
" request's receipt proves it committed (use after a lost response).",
|
|
106
115
|
"",
|
|
107
116
|
"supervise options:",
|
|
108
117
|
" --run-id <id> Run/session identifier to supervise (required)",
|
|
@@ -241,6 +250,10 @@ export function getConductorUsage() {
|
|
|
241
250
|
" epic-implementer unpark --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
|
|
242
251
|
" # A human pushed directly to the PR and the ticket parked on head drift:",
|
|
243
252
|
" epic-implementer adopt-current-head-and-unpark --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
|
|
253
|
+
" # A child stopped mid-attempt and should carry on WITHOUT resetting its budget:",
|
|
254
|
+
" epic-implementer continue-child --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
|
|
255
|
+
" # The continue-child response was lost — did it commit?",
|
|
256
|
+
" epic-implementer continuation-receipt --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
|
|
244
257
|
].join("\n");
|
|
245
258
|
}
|
|
246
259
|
/**
|
|
@@ -253,6 +266,13 @@ export function getConductorUsage() {
|
|
|
253
266
|
* output name is now a fixed migration stub and must never be selected here.
|
|
254
267
|
*/
|
|
255
268
|
export const CONDUCTOR_HOOK_BIN_ACTION = "__hook-bin";
|
|
269
|
+
/**
|
|
270
|
+
* Private runtime probe (BAPI-1290): the bounded subprocess hook qualification
|
|
271
|
+
* runs as `node <entry> __hook-runtime-probe`. It reports the Node line, the
|
|
272
|
+
* native ABI, and whether `better-sqlite3` loads — never a path, never error
|
|
273
|
+
* text — and it never opens, creates, or migrates a ledger. Absent from usage.
|
|
274
|
+
*/
|
|
275
|
+
export { HOOK_RUNTIME_PROBE_ACTION } from "./hook-runtime-qualification.js";
|
|
256
276
|
const VALID_COMMANDS = new Set([
|
|
257
277
|
"emit-event",
|
|
258
278
|
"supervise",
|
|
@@ -282,10 +302,16 @@ const VALID_COMMANDS = new Set([
|
|
|
282
302
|
"scope-catch-up",
|
|
283
303
|
"patch-policy",
|
|
284
304
|
"resume-run",
|
|
305
|
+
// BAPI-1300: CLI-only parked-child continuation pair — never registered as MCP tools.
|
|
306
|
+
"continue-child",
|
|
307
|
+
"continuation-receipt",
|
|
285
308
|
// Private, and deliberately ABSENT from the usage text: `__hook-bin` exists as
|
|
286
309
|
// the bundled-artifact regression guard for BAPI-772 (mirroring `plane
|
|
287
310
|
// __entrypoint`), not as a supported operator workflow.
|
|
288
311
|
CONDUCTOR_HOOK_BIN_ACTION,
|
|
312
|
+
// Private, like `__hook-bin`: the bounded runtime probe hook qualification
|
|
313
|
+
// spawns (BAPI-1290). Never advertised as an operator workflow.
|
|
314
|
+
HOOK_RUNTIME_PROBE_ACTION,
|
|
289
315
|
]);
|
|
290
316
|
/**
|
|
291
317
|
* Parse the top-level conductor argv into a subcommand (without a CLI
|
|
@@ -694,13 +720,17 @@ export async function runDoctorCommand(argv, deps = {}) {
|
|
|
694
720
|
}
|
|
695
721
|
/**
|
|
696
722
|
* Run `install-git-hooks`: install/update the local managed `post-commit` and
|
|
697
|
-
* `reference-transaction` hooks. Returns 0
|
|
698
|
-
* worktree (degraded optional capability)
|
|
723
|
+
* `reference-transaction` hooks. Returns 0 when the directory is not a git
|
|
724
|
+
* worktree (degraded optional capability) without claiming anything was
|
|
725
|
+
* provisioned.
|
|
699
726
|
*
|
|
700
|
-
* BAPI-772: an unresolvable
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
* refusal
|
|
727
|
+
* BAPI-772: an unresolvable hook binary IS fatal (exit 1). BAPI-1290: so is an
|
|
728
|
+
* UNQUALIFIED runtime (an `npx` cache entry, an unsupported layout, a Node or
|
|
729
|
+
* native binding that does not load) — the refusal prints a fixed classification
|
|
730
|
+
* and action, never a probed path — and so is a planning refusal or a two-hook
|
|
731
|
+
* update that did not complete. A partial update prints each hook's outcome; it is
|
|
732
|
+
* never collapsed into success. None of these is an engine-admission failure:
|
|
733
|
+
* the hooks are optional local observability.
|
|
704
734
|
*/
|
|
705
735
|
export function runInstallGitHooksCommand(argv) {
|
|
706
736
|
const { bools } = tokenizeFlags(argv, new Set(), DIAGNOSTIC_BOOL_FLAGS);
|
|
@@ -711,14 +741,30 @@ export function runInstallGitHooksCommand(argv) {
|
|
|
711
741
|
const result = installConductorGitHooks();
|
|
712
742
|
if (!result.ok) {
|
|
713
743
|
if (bools.has("--json")) {
|
|
714
|
-
console.error(JSON.stringify(
|
|
744
|
+
console.error(JSON.stringify(result.error === CONDUCTOR_BIN_UNRESOLVED_ERROR
|
|
745
|
+
? { error: result.error, classification: result.classification, reason: result.reason }
|
|
746
|
+
: {
|
|
747
|
+
error: result.error,
|
|
748
|
+
reason: result.reason,
|
|
749
|
+
refused: result.refused.map((r) => ({ name: r.name, warning: r.warning })),
|
|
750
|
+
}));
|
|
715
751
|
return 1;
|
|
716
752
|
}
|
|
717
|
-
console.error(
|
|
753
|
+
console.error(result.error === CONDUCTOR_BIN_UNRESOLVED_ERROR
|
|
754
|
+
? `Error: conductor git hooks were NOT installed (${result.error}, ${result.classification}): ${result.reason}`
|
|
755
|
+
: `Error: conductor git hooks were NOT installed (${result.error}): ${result.reason}`);
|
|
756
|
+
console.error("Existing hooks were left unchanged. Hooks are optional local observability; " +
|
|
757
|
+
"engine admission is unaffected.");
|
|
718
758
|
return 1;
|
|
719
759
|
}
|
|
760
|
+
const incomplete = result.is_worktree && !result.complete;
|
|
720
761
|
if (bools.has("--json")) {
|
|
721
|
-
|
|
762
|
+
const payload = JSON.stringify(result);
|
|
763
|
+
if (incomplete) {
|
|
764
|
+
console.error(payload);
|
|
765
|
+
return 1;
|
|
766
|
+
}
|
|
767
|
+
console.log(payload);
|
|
722
768
|
return 0;
|
|
723
769
|
}
|
|
724
770
|
const lines = [
|
|
@@ -726,34 +772,101 @@ export function runInstallGitHooksCommand(argv) {
|
|
|
726
772
|
"───────────────────────────",
|
|
727
773
|
`is git worktree: ${result.is_worktree}`,
|
|
728
774
|
`hooks dir: ${result.hooks_dir ?? "n/a"}`,
|
|
729
|
-
`
|
|
775
|
+
`hook runtime: ${result.conductor_bin}`,
|
|
776
|
+
`runtime evidence: ${result.runtime.package_name}@${result.runtime.package_version}, ` +
|
|
777
|
+
`node ${result.runtime.node_version} (ABI ${result.runtime.node_modules_abi}), ` +
|
|
778
|
+
`${result.runtime.native_module} ${result.runtime.native_module_version ?? "unknown"}, ${result.runtime.platform}`,
|
|
730
779
|
];
|
|
731
780
|
for (const hook of result.installed) {
|
|
732
|
-
lines.push(` ${hook.name}: ${hook.
|
|
781
|
+
lines.push(` ${hook.name}: ${hook.outcome} (${hook.action}), usable=${hook.usable}` +
|
|
782
|
+
(hook.warning ? ` — ${hook.warning}` : ""));
|
|
783
|
+
}
|
|
784
|
+
if (result.is_worktree) {
|
|
785
|
+
lines.push(result.complete
|
|
786
|
+
? "status: complete — both managed hooks are published and usable"
|
|
787
|
+
: result.partial
|
|
788
|
+
? "status: PARTIAL — re-run `epic-implementer install-git-hooks` to converge"
|
|
789
|
+
: "status: NOT installed — no managed hook was changed");
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
lines.push("status: not provisioned — not a git worktree");
|
|
733
793
|
}
|
|
734
794
|
if (result.warnings.length > 0) {
|
|
735
795
|
lines.push("warnings:");
|
|
736
796
|
for (const w of result.warnings)
|
|
737
797
|
lines.push(` - ${w}`);
|
|
738
798
|
}
|
|
799
|
+
if (incomplete) {
|
|
800
|
+
console.error(lines.join("\n"));
|
|
801
|
+
return 1;
|
|
802
|
+
}
|
|
739
803
|
console.log(lines.join("\n"));
|
|
740
804
|
return 0;
|
|
741
805
|
}
|
|
742
806
|
/**
|
|
743
|
-
* Run the private `__hook-bin` diagnostic: resolve the hook
|
|
744
|
-
* executing artifact and print ONLY
|
|
745
|
-
* level automation can consume it directly.
|
|
746
|
-
*
|
|
807
|
+
* Run the private `__hook-bin` diagnostic: resolve and QUALIFY the hook entry
|
|
808
|
+
* for the executing artifact and print ONLY the stable launch path to stdout, so
|
|
809
|
+
* artifact-level automation can consume it directly. BAPI-1290: an existing but
|
|
810
|
+
* ephemeral or unqualified entry is NOT printed — the sanitized classification and
|
|
811
|
+
* action go to stderr and the command returns 1, never a stack trace or a path.
|
|
747
812
|
*/
|
|
748
813
|
export function runHookBinDiagnosticCommand() {
|
|
749
814
|
const resolution = resolveConductorHookBin();
|
|
750
815
|
if (!resolution.ok) {
|
|
751
|
-
console.error(`conductor hook bin UNRESOLVED: ${resolution.reason}`);
|
|
816
|
+
console.error(`conductor hook bin UNRESOLVED (${resolution.classification}): ${resolution.reason}`);
|
|
752
817
|
return 1;
|
|
753
818
|
}
|
|
754
819
|
console.log(resolution.path);
|
|
755
820
|
return 0;
|
|
756
821
|
}
|
|
822
|
+
/**
|
|
823
|
+
* Run the private `__hook-runtime-probe` (BAPI-1290): print ONE JSON line with the
|
|
824
|
+
* Node version, the native ABI, and whether `better-sqlite3` loads for this Node.
|
|
825
|
+
* It reuses the ledger's own read-only native-load classification, so the hook
|
|
826
|
+
* qualification and `epic-implementer doctor` cannot disagree about the binding.
|
|
827
|
+
* It never opens, creates, or migrates the ledger, never writes a file, and never
|
|
828
|
+
* prints a path or error text. Exit 0 whenever it could answer.
|
|
829
|
+
*/
|
|
830
|
+
export async function runHookRuntimeProbeCommand() {
|
|
831
|
+
let loads = false;
|
|
832
|
+
let failureKind = "unknown";
|
|
833
|
+
let abi = process.versions.modules ?? "unknown";
|
|
834
|
+
let moduleName = "better-sqlite3";
|
|
835
|
+
try {
|
|
836
|
+
const { inspectConductorLedgerNativeModuleLoad } = await import("./store.js");
|
|
837
|
+
const status = await inspectConductorLedgerNativeModuleLoad();
|
|
838
|
+
loads = status.loads;
|
|
839
|
+
failureKind = status.failure_kind;
|
|
840
|
+
abi = status.node_modules_abi;
|
|
841
|
+
moduleName = status.module;
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
loads = false;
|
|
845
|
+
failureKind = "unknown";
|
|
846
|
+
}
|
|
847
|
+
let nativeVersion = null;
|
|
848
|
+
if (loads) {
|
|
849
|
+
try {
|
|
850
|
+
const require = createRequire(import.meta.url);
|
|
851
|
+
const pkg = require(`${moduleName}/package.json`);
|
|
852
|
+
nativeVersion = typeof pkg.version === "string" ? pkg.version : null;
|
|
853
|
+
}
|
|
854
|
+
catch {
|
|
855
|
+
nativeVersion = null;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
console.log(JSON.stringify({
|
|
859
|
+
probe: HOOK_RUNTIME_PROBE_MARKER,
|
|
860
|
+
node_version: process.version,
|
|
861
|
+
node_modules_abi: abi,
|
|
862
|
+
native_module: moduleName,
|
|
863
|
+
native_module_version: nativeVersion,
|
|
864
|
+
native_loads: loads,
|
|
865
|
+
failure_kind: failureKind,
|
|
866
|
+
package_version: VERSION,
|
|
867
|
+
}));
|
|
868
|
+
return 0;
|
|
869
|
+
}
|
|
757
870
|
const GIT_HOOK_VALUE_FLAGS = new Set(["--phase", "--stdin-file"]);
|
|
758
871
|
const GIT_HOOK_BOOL_FLAGS = new Set(["--help"]);
|
|
759
872
|
/**
|
|
@@ -1265,6 +1378,10 @@ export async function runEpicImplementerCli(argv) {
|
|
|
1265
1378
|
// Dispatched before any MCP server construction or store access: the
|
|
1266
1379
|
// guard must be able to run against a bundle whose ledger is absent.
|
|
1267
1380
|
return runHookBinDiagnosticCommand();
|
|
1381
|
+
case HOOK_RUNTIME_PROBE_ACTION:
|
|
1382
|
+
// Also dispatched before any MCP construction or store open: it only
|
|
1383
|
+
// imports the native binding, so it can answer on a host with no ledger.
|
|
1384
|
+
return await runHookRuntimeProbeCommand();
|
|
1268
1385
|
case "file-scope-guard":
|
|
1269
1386
|
// BAPI-507 (N-2): warn-only worker file-scope guard. Always exits 0.
|
|
1270
1387
|
return runFileScopeGuardCli();
|
|
@@ -1314,6 +1431,14 @@ export async function runEpicImplementerCli(argv) {
|
|
|
1314
1431
|
const { runResumeRunCommand } = await import("./recovery-cli.js");
|
|
1315
1432
|
return await runResumeRunCommand(parsed.argv);
|
|
1316
1433
|
}
|
|
1434
|
+
case "continue-child": {
|
|
1435
|
+
const { runContinueChildCommand } = await import("./recovery-cli.js");
|
|
1436
|
+
return await runContinueChildCommand(parsed.argv);
|
|
1437
|
+
}
|
|
1438
|
+
case "continuation-receipt": {
|
|
1439
|
+
const { runContinuationReceiptCommand } = await import("./recovery-cli.js");
|
|
1440
|
+
return await runContinuationReceiptCommand(parsed.argv);
|
|
1441
|
+
}
|
|
1317
1442
|
default:
|
|
1318
1443
|
console.error('Error: Unknown command. Run "epic-implementer --help" for usage.');
|
|
1319
1444
|
return 1;
|