@akagilnc/pi-workflow-roles 0.1.3572 → 0.1.3621
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/dist/acp-host/production-host.js +20239 -10556
- package/dist/analyst-gate-cycles-read.js +387 -0
- package/dist/archivist-record-entry.js +22 -1
- package/dist/audit-escalation.js +6 -0
- package/dist/auditor-soul.js +74 -0
- package/dist/collector-config.js +86 -0
- package/dist/collector-evidence.js +316 -0
- package/dist/collector-github.js +527 -0
- package/dist/collector-ledger.js +853 -0
- package/dist/collector-tool-schemas.js +33 -0
- package/dist/compliance-transport.js +104 -52
- package/dist/diarist-mechanical.js +411 -0
- package/dist/diarist-ticket-resolution.js +177 -0
- package/dist/diarist.js +311 -0
- package/dist/doctor-auditor.js +25 -0
- package/dist/doctor-contracts.js +2 -0
- package/dist/doctor-evidence.js +142 -0
- package/dist/gatekeeper-role.js +104 -81
- package/dist/host-transition-prior-native.js +78 -0
- package/dist/institutional-resolution.js +1 -95
- package/dist/judge-auditor.js +26 -0
- package/dist/ledger-session-read.js +227 -0
- package/dist/merger-git-state.js +78 -0
- package/dist/navigator-attendance.js +15 -7
- package/dist/navigator-public-session.js +170 -0
- package/dist/navigator-session-contracts.js +36 -40
- package/dist/notary-source-run.js +122 -0
- package/dist/package-contracts/auditor-output.js +66 -0
- package/dist/package-contracts/evidence-child-output.js +34 -0
- package/dist/package-contracts/judge-output.js +2 -0
- package/dist/package-contracts/terminating-tools.js +25 -2
- package/dist/package-resources/method-skill.js +254 -0
- package/dist/packaged-role-registry.js +36 -0
- package/dist/pi/durable-principal.js +61 -0
- package/dist/pi/in-process-session.js +25 -6
- package/dist/pi/known-failure.js +52 -0
- package/dist/pi/role-turn-host.js +430 -0
- package/dist/public-cli/auto-resume.js +414 -0
- package/dist/public-cli/cli-errors.js +8 -0
- package/dist/public-cli/cli-io.js +1 -0
- package/dist/public-cli/command-renderer.js +5 -0
- package/dist/public-cli/doctor-run.js +84 -0
- package/dist/public-cli/inspector-run.js +136 -0
- package/dist/public-cli/instruction-seat-run.js +256 -0
- package/dist/public-cli/invocation.js +2225 -0
- package/dist/public-cli/judge-run.js +118 -0
- package/dist/public-cli/load-production-acp-host.js +40 -0
- package/dist/public-cli/main.js +1140 -652
- package/dist/public-cli/notary-run.js +161 -0
- package/dist/public-cli/option-definitions.js +1192 -0
- package/dist/public-cli/post-admission.js +667 -0
- package/dist/public-cli/public-run-credentials.js +49 -0
- package/dist/public-cli/registry.js +4 -1
- package/dist/public-cli/reviewer-dispatch-rejection.js +77 -0
- package/dist/public-cli/run-lifecycle.js +1344 -0
- package/dist/public-cli/seat-ticket-binding.js +113 -0
- package/dist/public-cli/settlement.js +3441 -0
- package/dist/public-cli/terminal.js +135 -0
- package/dist/public-cli/turn-request.js +25 -0
- package/dist/public-role-summons.js +301 -0
- package/dist/receipt-delivery-policy.js +10 -0
- package/dist/reviewer-child-executor.js +80 -8
- package/dist/reviewer-execution-ledger.js +2 -1
- package/dist/run-terminal-artifacts.js +195 -0
- package/dist/run-ticket-number.js +40 -0
- package/dist/session-assistant-usage.js +107 -0
- package/dist/shape-unreadable-failure.js +34 -0
- package/dist/submission-errors.js +3 -0
- package/dist/submission-ledger.js +388 -0
- package/dist/ticket-provenance-contracts.js +115 -0
- package/dist/ticket-provenance.js +340 -0
- package/extensions/role-runtime.ts +7 -0
- package/package.json +1 -1
- package/scripts/build-package.mjs +2 -1
- package/souls/doctor-auditor.md +1 -0
- package/souls/evidence-child.md +1 -0
- package/src/acp-host/production-host.ts +3 -0
- package/src/acp-host/role-envelope.ts +2 -2
- package/src/acp-host/role-turn-host.ts +9 -1
- package/src/analyst-gate-cycles-read.ts +37 -2
- package/src/archivist-record-entry.ts +45 -1
- package/src/audit-escalation.ts +17 -0
- package/src/auditor-role.ts +22 -0
- package/src/auditor-soul.ts +42 -0
- package/src/compliance-transport.ts +177 -66
- package/src/doctor-auditor.ts +10 -14
- package/src/doctor-contracts.ts +1 -0
- package/src/doctor-role.ts +2 -2
- package/src/evidence-child-role.ts +22 -0
- package/src/gatekeeper-pass-envelope.ts +109 -0
- package/src/gatekeeper-role.ts +145 -104
- package/src/host-contracts.ts +9 -1
- package/src/institutional-resolution.ts +8 -163
- package/src/judge-auditor.ts +10 -15
- package/src/judge-role.ts +8 -0
- package/src/navigator-attendance.ts +25 -9
- package/src/navigator-public-session.ts +217 -0
- package/src/navigator-session-contracts.ts +61 -54
- package/src/notary-source-run.ts +3 -1
- package/src/package-contracts/auditor-output.ts +82 -0
- package/src/package-contracts/evidence-child-output.ts +51 -0
- package/src/package-contracts/judge-output.ts +1 -0
- package/src/package-contracts/reviewer-output.ts +2 -2
- package/src/package-contracts/terminating-tools.ts +31 -1
- package/src/packaged-role-registry.ts +43 -0
- package/src/pi/adapter.ts +2 -1
- package/src/pi/in-process-session.ts +38 -12
- package/src/pi/role-turn-host.ts +34 -0
- package/src/public-cli/cli.ts +14 -6
- package/src/public-cli/instruction-seat-run.ts +289 -50
- package/src/public-cli/invocation.ts +66 -25
- package/src/public-cli/option-definitions.ts +61 -0
- package/src/public-cli/post-admission.ts +7 -1
- package/src/public-cli/registry.ts +4 -1
- package/src/public-cli/run-lifecycle.ts +22 -3
- package/src/public-cli/settlement.ts +120 -5
- package/src/public-cli/terminal.ts +2 -0
- package/src/public-role-summons.ts +431 -0
- package/src/receipt-delivery-policy.ts +10 -0
- package/src/reviewer-agent.ts +1 -1
- package/src/reviewer-child-executor.ts +114 -12
- package/src/reviewer-execution-ledger.ts +3 -2
- package/src/role-runtime.ts +158 -17
- package/src/session-assistant-usage.ts +119 -0
- package/src/session-opening-materials.ts +1 -1
- package/src/shape-unreadable-failure.ts +49 -0
- package/src/submission-errors.ts +3 -0
- package/dist/evidence-child-executor.js +0 -829
- package/src/evidence-child-executor.ts +0 -1140
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable Role run lifecycle for public CLI (ADR 0052 / #11 / #108 / #416).
|
|
3
|
+
* States: admitted → running → resumable | terminal.
|
|
4
|
+
* #416 (owner 2026-08-22): resume no longer gates on terminal/resumable or typed 429 —
|
|
5
|
+
* any existing run with an available Pi session principal may be resumed; caller decides.
|
|
6
|
+
* Prose is never regex-classified as quota evidence.
|
|
7
|
+
*/
|
|
8
|
+
import { chmod, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { activationBookDirectory, resolveActivationLedgerHome, } from "../activation-ledger-topology.js";
|
|
11
|
+
import { readRunTicketNumber } from "../run-ticket-number.js";
|
|
12
|
+
import { CliUsageError } from "./cli-errors.js";
|
|
13
|
+
import { readLatestTypedProviderHttpObservation, } from "../typed-provider-http.js";
|
|
14
|
+
export { clearTypedProviderHttpObservation, recordTypedProviderHttpStatus, readLatestTypedProviderHttpObservation, } from "../typed-provider-http.js";
|
|
15
|
+
import { parseCollectorRepository } from "../collector-config.js";
|
|
16
|
+
import { appendEngineSessionMaterial, engineSessionMaterialFromOptions, } from "../package-resources/engine-material.js";
|
|
17
|
+
import { recordEffectiveInvocationModel, requireAuthorityRef, } from "./invocation.js";
|
|
18
|
+
/** Providers eligible for v1 typed-429 resume (Codex / xAI only).
|
|
19
|
+
* @deprecated v1 429-only gate removed by #416 (owner 2026-08-22: "根本不要有限制"). Kept for compatibility; do not use for new branching.
|
|
20
|
+
*/
|
|
21
|
+
export const V1_RESUMABLE_PROVIDERS = ["openai-codex", "xai"];
|
|
22
|
+
/** Default for the #422 configurable single-call auto-resume ceiling
|
|
23
|
+
* (public-cli.json top-level `autoResumeLimit`). No longer the runtime truth
|
|
24
|
+
* source: runWithAutoResumeLoop receives the effective value once per call.
|
|
25
|
+
*/
|
|
26
|
+
export const AUTO_RESUME_LIMIT = 2;
|
|
27
|
+
/** Package-owned turn trigger for resume. Not caller instruction and not semantic task content. */
|
|
28
|
+
export const RESUME_TRANSPORT_ENVELOPE = "[ak-role:resume-continue]";
|
|
29
|
+
/**
|
|
30
|
+
* Unique continuation-prompt selector for manual/auto resume (#471 / #600).
|
|
31
|
+
* Message present → base bytes unchanged; absent → package transport envelope.
|
|
32
|
+
* When engine material is present, append structured engine coordinates (same
|
|
33
|
+
* delivery as initial transport prompts). Zero parse, zero classify, zero narrow.
|
|
34
|
+
*/
|
|
35
|
+
export function selectResumeContinuationPrompt(message, engineMaterial) {
|
|
36
|
+
const base = message !== undefined ? message : RESUME_TRANSPORT_ENVELOPE;
|
|
37
|
+
return appendEngineSessionMaterial([base], engineMaterial).join("\n");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resume continuation with engine material resolved from the seat env (#600).
|
|
41
|
+
* Seat table / invocation engine axis rides the same prompt seam as initial runs.
|
|
42
|
+
*/
|
|
43
|
+
export function buildResumeContinuationPrompt(options) {
|
|
44
|
+
return selectResumeContinuationPrompt(options.message, engineSessionMaterialFromOptions({
|
|
45
|
+
...(options.engine === undefined ? {} : { engine: options.engine }),
|
|
46
|
+
packageRoot: options.packageRoot,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
const RUN_STATE_FILE = "run-state.json";
|
|
50
|
+
const WRITER_LOCK_FILE = "writer.lock";
|
|
51
|
+
/** @deprecated #416: 429-only classification removed; kept for compatibility. */
|
|
52
|
+
export function isV1ResumableProvider(provider) {
|
|
53
|
+
return V1_RESUMABLE_PROVIDERS.includes(provider);
|
|
54
|
+
}
|
|
55
|
+
/** @deprecated #416: 429-only observation no longer gates resume; kept for historical runs. */
|
|
56
|
+
export async function readTypedHttp429Observation(runDirectory) {
|
|
57
|
+
const observation = await readLatestTypedProviderHttpObservation(runDirectory);
|
|
58
|
+
if (observation === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (observation.httpStatus !== 429)
|
|
61
|
+
return undefined;
|
|
62
|
+
if (!isV1ResumableProvider(observation.provider))
|
|
63
|
+
return undefined;
|
|
64
|
+
return { httpStatus: 429, provider: observation.provider };
|
|
65
|
+
}
|
|
66
|
+
/** Complete public resume command. Run ID is revealed only through this command text. */
|
|
67
|
+
export function renderResumeCommand(runId) {
|
|
68
|
+
return `ak-role resume ${runId}`;
|
|
69
|
+
}
|
|
70
|
+
export async function writeRoleRunState(runDirectory, record) {
|
|
71
|
+
const payload = { ...record, runDirectory };
|
|
72
|
+
await writeFile(join(runDirectory, RUN_STATE_FILE), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
73
|
+
}
|
|
74
|
+
function parseSameTicketSummonsMaterials(raw) {
|
|
75
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
76
|
+
return undefined;
|
|
77
|
+
const record = raw;
|
|
78
|
+
const instruction = typeof record.instruction === "string" ? record.instruction : undefined;
|
|
79
|
+
const instructionEmpty = typeof record.instructionEmpty === "boolean" ? record.instructionEmpty : undefined;
|
|
80
|
+
const attachmentPaths = Array.isArray(record.attachmentPaths)
|
|
81
|
+
? record.attachmentPaths.filter((p) => typeof p === "string" && p.length > 0)
|
|
82
|
+
: undefined;
|
|
83
|
+
const sourceRunPath = typeof record.sourceRunPath === "string" && record.sourceRunPath.trim() !== ""
|
|
84
|
+
? record.sourceRunPath
|
|
85
|
+
: undefined;
|
|
86
|
+
let sourceRun;
|
|
87
|
+
if (record.sourceRun !== null &&
|
|
88
|
+
typeof record.sourceRun === "object" &&
|
|
89
|
+
!Array.isArray(record.sourceRun)) {
|
|
90
|
+
const sr = record.sourceRun;
|
|
91
|
+
if (typeof sr.runId === "string" &&
|
|
92
|
+
sr.runId.trim() !== "" &&
|
|
93
|
+
typeof sr.role === "string" &&
|
|
94
|
+
sr.role.trim() !== "" &&
|
|
95
|
+
typeof sr.runDirectory === "string" &&
|
|
96
|
+
sr.runDirectory.trim() !== "") {
|
|
97
|
+
sourceRun = {
|
|
98
|
+
runId: sr.runId,
|
|
99
|
+
role: sr.role,
|
|
100
|
+
runDirectory: sr.runDirectory,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (instruction === undefined &&
|
|
105
|
+
instructionEmpty === undefined &&
|
|
106
|
+
(attachmentPaths === undefined || attachmentPaths.length === 0) &&
|
|
107
|
+
sourceRunPath === undefined &&
|
|
108
|
+
sourceRun === undefined) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
...(instruction === undefined ? {} : { instruction }),
|
|
113
|
+
...(instructionEmpty === undefined ? {} : { instructionEmpty }),
|
|
114
|
+
...(attachmentPaths === undefined || attachmentPaths.length === 0
|
|
115
|
+
? {}
|
|
116
|
+
: { attachmentPaths }),
|
|
117
|
+
...(sourceRunPath === undefined ? {} : { sourceRunPath }),
|
|
118
|
+
...(sourceRun === undefined ? {} : { sourceRun }),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function parseCurrentCourtState(raw) {
|
|
122
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
123
|
+
return undefined;
|
|
124
|
+
const record = raw;
|
|
125
|
+
if (typeof record.courtAttemptId !== "string" || record.courtAttemptId.length === 0) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const summons = parseSameTicketSummonsMaterials(record.summons);
|
|
129
|
+
return {
|
|
130
|
+
courtAttemptId: record.courtAttemptId,
|
|
131
|
+
...(summons === undefined ? {} : { summons }),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function readRoleRunStateDisk(runDirectory) {
|
|
135
|
+
let raw;
|
|
136
|
+
try {
|
|
137
|
+
raw = JSON.parse(await readFile(join(runDirectory, RUN_STATE_FILE), "utf8"));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
const record = raw;
|
|
146
|
+
if (typeof record.runId !== "string" || record.runId.trim() === "") {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
if (record.role !== "judge" &&
|
|
150
|
+
record.role !== "coder" &&
|
|
151
|
+
record.role !== "fixer" &&
|
|
152
|
+
record.role !== "collector" &&
|
|
153
|
+
record.role !== "doctor" &&
|
|
154
|
+
record.role !== "reviewer" &&
|
|
155
|
+
record.role !== "merger" &&
|
|
156
|
+
record.role !== "notary" &&
|
|
157
|
+
record.role !== "countersign" &&
|
|
158
|
+
record.role !== "gleaner-left" &&
|
|
159
|
+
record.role !== "inspector" &&
|
|
160
|
+
record.role !== "gatekeeper" &&
|
|
161
|
+
record.role !== "navigator" &&
|
|
162
|
+
record.role !== "auditor" &&
|
|
163
|
+
record.role !== "evidence-child" &&
|
|
164
|
+
record.role !== "diarist") {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
if (record.state !== "admitted" &&
|
|
168
|
+
record.state !== "running" &&
|
|
169
|
+
record.state !== "resumable" &&
|
|
170
|
+
record.state !== "terminal") {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
if (typeof record.bookKey !== "string")
|
|
174
|
+
return undefined;
|
|
175
|
+
if (typeof record.projectRoot !== "string")
|
|
176
|
+
return undefined;
|
|
177
|
+
if (typeof record.sessionDirectory !== "string")
|
|
178
|
+
return undefined;
|
|
179
|
+
if (typeof record.admittedRequestPath !== "string")
|
|
180
|
+
return undefined;
|
|
181
|
+
const runDir = typeof record.runDirectory === "string" && record.runDirectory.trim() !== ""
|
|
182
|
+
? record.runDirectory
|
|
183
|
+
: runDirectory;
|
|
184
|
+
// Principal wire stays uninterpreted — authority owns legacy sessionFile fallback.
|
|
185
|
+
const principalWire = {
|
|
186
|
+
sessionDirectory: record.sessionDirectory,
|
|
187
|
+
...(typeof record.sessionFile === "string"
|
|
188
|
+
? { sessionFile: record.sessionFile }
|
|
189
|
+
: {}),
|
|
190
|
+
};
|
|
191
|
+
let resumable;
|
|
192
|
+
if (record.resumable !== undefined && record.resumable !== null) {
|
|
193
|
+
if (typeof record.resumable === "object" &&
|
|
194
|
+
!Array.isArray(record.resumable)) {
|
|
195
|
+
const r = record.resumable;
|
|
196
|
+
if (r.httpStatus === 429 &&
|
|
197
|
+
typeof r.provider === "string" &&
|
|
198
|
+
isV1ResumableProvider(r.provider)) {
|
|
199
|
+
resumable = { httpStatus: 429, provider: r.provider };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const phase = record.phase === "plan" || record.phase === "apply"
|
|
204
|
+
? record.phase
|
|
205
|
+
: undefined;
|
|
206
|
+
const currentCourt = parseCurrentCourtState(record.currentCourt);
|
|
207
|
+
return {
|
|
208
|
+
runId: record.runId,
|
|
209
|
+
role: record.role,
|
|
210
|
+
state: record.state,
|
|
211
|
+
bookKey: record.bookKey,
|
|
212
|
+
projectRoot: record.projectRoot,
|
|
213
|
+
runDirectory: runDir,
|
|
214
|
+
admittedRequestPath: record.admittedRequestPath,
|
|
215
|
+
principalWire,
|
|
216
|
+
...(phase === undefined ? {} : { phase }),
|
|
217
|
+
...(resumable === undefined ? {} : { resumable }),
|
|
218
|
+
...(currentCourt === undefined ? {} : { currentCourt }),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async function writeRoleRunStateDisk(runDirectory, disk) {
|
|
222
|
+
const payload = {
|
|
223
|
+
runId: disk.runId,
|
|
224
|
+
role: disk.role,
|
|
225
|
+
state: disk.state,
|
|
226
|
+
bookKey: disk.bookKey,
|
|
227
|
+
projectRoot: disk.projectRoot,
|
|
228
|
+
runDirectory: disk.runDirectory,
|
|
229
|
+
sessionDirectory: disk.principalWire.sessionDirectory,
|
|
230
|
+
...(disk.principalWire.sessionFile === undefined
|
|
231
|
+
? {}
|
|
232
|
+
: { sessionFile: disk.principalWire.sessionFile }),
|
|
233
|
+
admittedRequestPath: disk.admittedRequestPath,
|
|
234
|
+
...(disk.phase === undefined ? {} : { phase: disk.phase }),
|
|
235
|
+
...(disk.resumable === undefined ? {} : { resumable: disk.resumable }),
|
|
236
|
+
...(disk.currentCourt === undefined ? {} : { currentCourt: disk.currentCourt }),
|
|
237
|
+
};
|
|
238
|
+
await writeFile(join(runDirectory, RUN_STATE_FILE), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
239
|
+
}
|
|
240
|
+
/** One authority.decode of the uninterpreted wire → record + opaque principal (frozen wire itself). */
|
|
241
|
+
function materializeRoleRunFromDisk(disk, authority) {
|
|
242
|
+
try {
|
|
243
|
+
const coordinates = authority.decode(disk.principalWire);
|
|
244
|
+
return {
|
|
245
|
+
principal: disk.principalWire,
|
|
246
|
+
run: {
|
|
247
|
+
runId: disk.runId,
|
|
248
|
+
role: disk.role,
|
|
249
|
+
state: disk.state,
|
|
250
|
+
bookKey: disk.bookKey,
|
|
251
|
+
projectRoot: disk.projectRoot,
|
|
252
|
+
sessionDirectory: coordinates.sessionDirectory,
|
|
253
|
+
sessionFile: coordinates.sessionFile,
|
|
254
|
+
runDirectory: disk.runDirectory,
|
|
255
|
+
admittedRequestPath: disk.admittedRequestPath,
|
|
256
|
+
...(disk.phase === undefined ? {} : { phase: disk.phase }),
|
|
257
|
+
...(disk.resumable === undefined ? {} : { resumable: disk.resumable }),
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Read durable run-state and materialize principal coordinates through the
|
|
267
|
+
* injected host authority (legacy sessionFile fallback lives only in the codec).
|
|
268
|
+
*/
|
|
269
|
+
export async function readRoleRunState(runDirectory, authority) {
|
|
270
|
+
const disk = await readRoleRunStateDisk(runDirectory);
|
|
271
|
+
if (disk === undefined)
|
|
272
|
+
return undefined;
|
|
273
|
+
return materializeRoleRunFromDisk(disk, authority)?.run;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Envelope identity only — no principal payload interpretation.
|
|
277
|
+
* Used by notary locator / role peek that never consume session coordinates.
|
|
278
|
+
*/
|
|
279
|
+
export async function readRoleRunIdentity(runDirectory) {
|
|
280
|
+
const disk = await readRoleRunStateDisk(runDirectory);
|
|
281
|
+
if (disk === undefined)
|
|
282
|
+
return undefined;
|
|
283
|
+
return {
|
|
284
|
+
runId: disk.runId,
|
|
285
|
+
role: disk.role,
|
|
286
|
+
bookKey: disk.bookKey,
|
|
287
|
+
runDirectory: disk.runDirectory,
|
|
288
|
+
state: disk.state,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
export async function markRunAdmitted(admitted, authority) {
|
|
292
|
+
const { sessionDirectory, sessionFile } = authority.decode(admitted.principal);
|
|
293
|
+
await writeRoleRunState(admitted.runDirectory, {
|
|
294
|
+
runId: admitted.runId,
|
|
295
|
+
role: admitted.role,
|
|
296
|
+
state: "admitted",
|
|
297
|
+
bookKey: admitted.bookKey,
|
|
298
|
+
projectRoot: admitted.projectRoot,
|
|
299
|
+
sessionDirectory,
|
|
300
|
+
sessionFile,
|
|
301
|
+
admittedRequestPath: admitted.admittedRequestPath,
|
|
302
|
+
...(admitted.role === "coder" || admitted.role === "fixer"
|
|
303
|
+
? { phase: admitted.phase }
|
|
304
|
+
: {}),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Shared dispatch execution seam: record the effective launch model (initial or
|
|
309
|
+
* resume override) and the authoritative seat engine/host onto invocation.json,
|
|
310
|
+
* then transition to running.
|
|
311
|
+
* Role runners must not coordinate lifecycle ledger writes themselves.
|
|
312
|
+
* Engine axis is authoritative here (#617): present string is written; omit/undefined
|
|
313
|
+
* clears any prior engine key so unset-engine + resume does not keep a stale value.
|
|
314
|
+
* (Non-authoritative partial updates still use recordEffectiveInvocationModel directly
|
|
315
|
+
* with `engine: undefined` to preserve.)
|
|
316
|
+
*/
|
|
317
|
+
export async function markRunRunning(runDirectory, effectiveModel, effectiveEngine, effectiveHost) {
|
|
318
|
+
await recordEffectiveInvocationModel(runDirectory, effectiveModel,
|
|
319
|
+
// Authoritative seat projection: absent engine ⇒ null (delete).
|
|
320
|
+
effectiveEngine === undefined ? null : effectiveEngine, effectiveHost);
|
|
321
|
+
const current = await readRoleRunStateDisk(runDirectory);
|
|
322
|
+
if (current === undefined) {
|
|
323
|
+
throw new Error("cannot mark running: run state missing");
|
|
324
|
+
}
|
|
325
|
+
// Omit resumable while a writer is active. Principal wire is passed through uninterpreted.
|
|
326
|
+
// Preserve open currentCourt across running transitions (#637).
|
|
327
|
+
await writeRoleRunStateDisk(runDirectory, {
|
|
328
|
+
runId: current.runId,
|
|
329
|
+
role: current.role,
|
|
330
|
+
state: "running",
|
|
331
|
+
bookKey: current.bookKey,
|
|
332
|
+
projectRoot: current.projectRoot,
|
|
333
|
+
runDirectory: current.runDirectory,
|
|
334
|
+
admittedRequestPath: current.admittedRequestPath,
|
|
335
|
+
principalWire: current.principalWire,
|
|
336
|
+
...(current.phase === undefined ? {} : { phase: current.phase }),
|
|
337
|
+
...(current.currentCourt === undefined ? {} : { currentCourt: current.currentCourt }),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
/** @deprecated #416: 429-only resumable marker; kept for historical runs. */
|
|
341
|
+
export async function markRunResumable(runDirectory, observation) {
|
|
342
|
+
const current = await readRoleRunStateDisk(runDirectory);
|
|
343
|
+
if (current === undefined) {
|
|
344
|
+
throw new Error("cannot mark resumable: run state missing");
|
|
345
|
+
}
|
|
346
|
+
await writeRoleRunStateDisk(runDirectory, {
|
|
347
|
+
...current,
|
|
348
|
+
state: "resumable",
|
|
349
|
+
resumable: observation,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
export async function markRunTerminal(runDirectory) {
|
|
353
|
+
const current = await readRoleRunStateDisk(runDirectory);
|
|
354
|
+
if (current === undefined) {
|
|
355
|
+
throw new Error("cannot mark terminal: run state missing");
|
|
356
|
+
}
|
|
357
|
+
// Preserve open currentCourt: terminal after a failed/incomplete court must still
|
|
358
|
+
// let bare resume continue that court (#637).
|
|
359
|
+
await writeRoleRunStateDisk(runDirectory, {
|
|
360
|
+
runId: current.runId,
|
|
361
|
+
role: current.role,
|
|
362
|
+
state: "terminal",
|
|
363
|
+
bookKey: current.bookKey,
|
|
364
|
+
projectRoot: current.projectRoot,
|
|
365
|
+
runDirectory: current.runDirectory,
|
|
366
|
+
admittedRequestPath: current.admittedRequestPath,
|
|
367
|
+
principalWire: current.principalWire,
|
|
368
|
+
...(current.phase === undefined ? {} : { phase: current.phase }),
|
|
369
|
+
...(current.currentCourt === undefined ? {} : { currentCourt: current.currentCourt }),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
/** Read the open court turn on a retained run, if any (#637). */
|
|
373
|
+
export async function readCurrentCourt(runDirectory) {
|
|
374
|
+
const current = await readRoleRunStateDisk(runDirectory);
|
|
375
|
+
return current?.currentCourt;
|
|
376
|
+
}
|
|
377
|
+
/** Persist the open court turn identity + materials (#637). */
|
|
378
|
+
export async function recordCurrentCourt(runDirectory, court) {
|
|
379
|
+
const current = await readRoleRunStateDisk(runDirectory);
|
|
380
|
+
if (current === undefined) {
|
|
381
|
+
throw new Error("cannot record current court: run state missing");
|
|
382
|
+
}
|
|
383
|
+
await writeRoleRunStateDisk(runDirectory, {
|
|
384
|
+
...current,
|
|
385
|
+
currentCourt: court,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Clear open court after this courtAttemptId seals, or when the open court is
|
|
390
|
+
* already sealed and bare resume returns to run-scoped idempotence (#637).
|
|
391
|
+
* When expectedCourtAttemptId is set, clear only if it still matches — never
|
|
392
|
+
* drop a different court recorded under the writer lease after our judgment.
|
|
393
|
+
*/
|
|
394
|
+
export async function clearCurrentCourt(runDirectory, expectedCourtAttemptId) {
|
|
395
|
+
const current = await readRoleRunStateDisk(runDirectory);
|
|
396
|
+
if (current === undefined || current.currentCourt === undefined)
|
|
397
|
+
return;
|
|
398
|
+
if (expectedCourtAttemptId !== undefined &&
|
|
399
|
+
current.currentCourt.courtAttemptId !== expectedCourtAttemptId) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
await writeRoleRunStateDisk(runDirectory, {
|
|
403
|
+
runId: current.runId,
|
|
404
|
+
role: current.role,
|
|
405
|
+
state: current.state,
|
|
406
|
+
bookKey: current.bookKey,
|
|
407
|
+
projectRoot: current.projectRoot,
|
|
408
|
+
runDirectory: current.runDirectory,
|
|
409
|
+
admittedRequestPath: current.admittedRequestPath,
|
|
410
|
+
principalWire: current.principalWire,
|
|
411
|
+
...(current.phase === undefined ? {} : { phase: current.phase }),
|
|
412
|
+
...(current.resumable === undefined ? {} : { resumable: current.resumable }),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* True when the host authority reports the durable principal available.
|
|
417
|
+
* Resume must reopen this exact principal; directory-latest is not identity.
|
|
418
|
+
*/
|
|
419
|
+
export async function isDurablePrincipalAvailable(principal, authority) {
|
|
420
|
+
return authority.isAvailable(principal);
|
|
421
|
+
}
|
|
422
|
+
export class RunWriterLeaseHeldError extends Error {
|
|
423
|
+
code = "AK_RUN_WRITER_LEASE_HELD";
|
|
424
|
+
constructor(message = "role run writer lease is already held") {
|
|
425
|
+
super(message);
|
|
426
|
+
this.name = "RunWriterLeaseHeldError";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* True error identity for diagnostics — name/code/message as-is, never a
|
|
431
|
+
* guessed label (failure-honesty constitution).
|
|
432
|
+
*/
|
|
433
|
+
export function describeErrorIdentity(error) {
|
|
434
|
+
const candidate = error;
|
|
435
|
+
const name = typeof candidate?.name === "string" && candidate.name !== ""
|
|
436
|
+
? candidate.name
|
|
437
|
+
: typeof error;
|
|
438
|
+
const code = typeof candidate?.code === "string" || typeof candidate?.code === "number"
|
|
439
|
+
? ` code=${String(candidate.code)}`
|
|
440
|
+
: "";
|
|
441
|
+
const message = typeof candidate?.message === "string" && candidate.message !== ""
|
|
442
|
+
? `: ${candidate.message}`
|
|
443
|
+
: "";
|
|
444
|
+
return `${name}${code}${message}`;
|
|
445
|
+
}
|
|
446
|
+
function errorCodeOf(error) {
|
|
447
|
+
return error.code;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Signal-0 liveness probe. Only ESRCH proves absence; any other refusal
|
|
451
|
+
* (e.g. EPERM) means the holder process exists.
|
|
452
|
+
*/
|
|
453
|
+
function isProcessAlive(pid) {
|
|
454
|
+
try {
|
|
455
|
+
process.kill(pid, 0);
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
return errorCodeOf(error) !== "ESRCH";
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Holder autopsy for an existing writer.lock (#552). "absent" covers no file,
|
|
464
|
+
* no parseable pid (a live creator mid-acquisition reads as empty, and so does
|
|
465
|
+
* the crash-window leftover), and unreadable files — absent alone never
|
|
466
|
+
* authorizes reclaim; only a "dead" verdict does. A non-ENOENT read failure
|
|
467
|
+
* still decides "absent" but rides along as readFailure so the true cause can
|
|
468
|
+
* land in the cleanup sink instead of being laundered away.
|
|
469
|
+
*/
|
|
470
|
+
async function autopsyWriterLock(lockPath) {
|
|
471
|
+
let content;
|
|
472
|
+
try {
|
|
473
|
+
content = await readFile(lockPath, "utf8");
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
if (errorCodeOf(error) === "ENOENT")
|
|
477
|
+
return { verdict: "absent" };
|
|
478
|
+
return { verdict: "absent", readFailure: error };
|
|
479
|
+
}
|
|
480
|
+
const normalized = content.trim();
|
|
481
|
+
if (!/^[1-9]\d*$/.test(normalized))
|
|
482
|
+
return { verdict: "absent" };
|
|
483
|
+
const pid = Number.parseInt(normalized, 10);
|
|
484
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
485
|
+
return { verdict: "absent" };
|
|
486
|
+
return isProcessAlive(pid) ? { verdict: "alive", pid } : { verdict: "dead", pid };
|
|
487
|
+
}
|
|
488
|
+
function describeAutopsy(autopsy) {
|
|
489
|
+
switch (autopsy.verdict) {
|
|
490
|
+
case "alive":
|
|
491
|
+
return `live pid ${autopsy.pid}`;
|
|
492
|
+
case "dead":
|
|
493
|
+
return `dead pid ${autopsy.pid}`;
|
|
494
|
+
case "absent":
|
|
495
|
+
return autopsy.readFailure !== undefined
|
|
496
|
+
? "unreadable lock"
|
|
497
|
+
: "absent or unparseable holder";
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Remove one lock whose re-read autopsy is still a verified-dead holder, or
|
|
502
|
+
* leave it for the next round otherwise. The pre-unlink re-read guard means a
|
|
503
|
+
* concurrent writer that re-locked between the autopsy and the unlink cannot
|
|
504
|
+
* have its live lock stolen. Residual race: a writer can still re-lock between
|
|
505
|
+
* the re-read and the unlink itself; POSIX offers no compare-and-delete, and
|
|
506
|
+
* this narrows the window to a single syscall pair.
|
|
507
|
+
*
|
|
508
|
+
* An EACCES unlink (non-writable run directory) is recovered by restoring the
|
|
509
|
+
* directory permissions only — never by a blind retrying unlink. After the
|
|
510
|
+
* chmod the caller loop re-runs the create/autopsy cycle, so any unlink still
|
|
511
|
+
* follows a fresh verified-dead verdict on the current pathname content; a
|
|
512
|
+
* contender that installed its live lock inside the recovery window reads as
|
|
513
|
+
* alive and is left alone (#629).
|
|
514
|
+
*
|
|
515
|
+
* Returns whether the lock was actually deleted.
|
|
516
|
+
*/
|
|
517
|
+
async function reclaimStaleWriterLock(lockPath, runDirectory) {
|
|
518
|
+
const current = await autopsyWriterLock(lockPath);
|
|
519
|
+
if (current.verdict !== "dead")
|
|
520
|
+
return { reclaimed: false };
|
|
521
|
+
try {
|
|
522
|
+
await unlink(lockPath);
|
|
523
|
+
return { reclaimed: true };
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
if (errorCodeOf(error) === "ENOENT")
|
|
527
|
+
return { reclaimed: false };
|
|
528
|
+
if (errorCodeOf(error) !== "EACCES")
|
|
529
|
+
throw error;
|
|
530
|
+
// Restore directory permissions and drop the round: re-unlinking here
|
|
531
|
+
// without a fresh autopsy could delete a contender's live lock that was
|
|
532
|
+
// installed while the directory was unwritable (#629 TOCTOU).
|
|
533
|
+
await chmod(runDirectory, 0o755);
|
|
534
|
+
// A chmod-proof EACCES (e.g. a deny-delete ACE the mode change cannot
|
|
535
|
+
// clear) recurs every round; hand the identity to the caller so the final
|
|
536
|
+
// stayed-contested refusal can still name the true cause (#629).
|
|
537
|
+
return { reclaimed: false, eaccesFailure: error };
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async function createWriterLease(lockPath, runDirectory, reportCleanupFailure) {
|
|
541
|
+
const handle = await open(lockPath, "wx");
|
|
542
|
+
try {
|
|
543
|
+
await handle.writeFile(`${process.pid}\n`, "utf8");
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
await handle.close().catch(() => undefined);
|
|
547
|
+
await unlink(lockPath).catch(() => undefined);
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
let released = false;
|
|
551
|
+
return {
|
|
552
|
+
lockPath,
|
|
553
|
+
async release() {
|
|
554
|
+
if (released)
|
|
555
|
+
return;
|
|
556
|
+
released = true;
|
|
557
|
+
await handle.close().catch(() => undefined);
|
|
558
|
+
try {
|
|
559
|
+
await unlink(lockPath);
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
if (errorCodeOf(error) === "EACCES") {
|
|
563
|
+
try {
|
|
564
|
+
await chmod(runDirectory, 0o755);
|
|
565
|
+
await unlink(lockPath);
|
|
566
|
+
}
|
|
567
|
+
catch (retryError) {
|
|
568
|
+
reportCleanupFailure(retryError);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
reportCleanupFailure(error);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const WRITER_LEASE_RECLAIM_ROUNDS = 3;
|
|
579
|
+
/**
|
|
580
|
+
* Acquire the one-writer lease for a Role run. Exclusive create — no second
|
|
581
|
+
* writer; a concurrent acquire rejects without dispatch.
|
|
582
|
+
*
|
|
583
|
+
* A contested lock gets a holder autopsy before rejection (#552): only a
|
|
584
|
+
* verified-dead holder pid — parseable pid, signal-0 ESRCH, and still dead on
|
|
585
|
+
* the pre-unlink re-read — authorizes reclaim, because no writer is left to
|
|
586
|
+
* release the lock; acquire then retries the create. An empty, unparseable, or
|
|
587
|
+
* unreadable lock proves no dead holder (a live creator is mid-acquisition
|
|
588
|
+
* between the exclusive create and its pid write), so it rejects as
|
|
589
|
+
* RunWriterLeaseHeldError naming the path and the lock stays on disk — a
|
|
590
|
+
* crash-window empty lock blocking a resume is that refusal's known residue,
|
|
591
|
+
* not safely fixable by unlink here. A live holder rejects the same typed
|
|
592
|
+
* error naming the pid and path. A pid recycled by an unrelated process reads
|
|
593
|
+
* as alive — that degrades to the same typed rejection, never worse than the
|
|
594
|
+
* pre-#552 behavior. Reclaim rounds are bounded by
|
|
595
|
+
* WRITER_LEASE_RECLAIM_ROUNDS; a lock that stays contested (e.g. a reclaim
|
|
596
|
+
* race repeatedly lost) surfaces the same typed error instead of spinning.
|
|
597
|
+
* When every round's unlink fails with a chmod-proof EACCES, the final
|
|
598
|
+
* refusal additionally carries the last reclaim failure's error identity so
|
|
599
|
+
* the true cause stays observable (#629).
|
|
600
|
+
*
|
|
601
|
+
* `onCleanupFailure` receives a non-terminal diagnostic line when release-time
|
|
602
|
+
* lock cleanup fails, a contested lock cannot be read, or a stale lock is
|
|
603
|
+
* reclaimed (the #556 orphan-pi residual declaration). Release stays
|
|
604
|
+
* best-effort, but no diagnostic promises that the next acquire reclaims a
|
|
605
|
+
* residual lock: a release-failed residual carries this process's live pid
|
|
606
|
+
* (the next acquire rejects it as held), and an unreadable contested lock is
|
|
607
|
+
* left in place. The true error identity must still land somewhere observable
|
|
608
|
+
* — silent swallowing is forbidden.
|
|
609
|
+
*/
|
|
610
|
+
export async function acquireRunWriterLease(runDirectory, onCleanupFailure) {
|
|
611
|
+
const reportDiagnostic = (diagnostic, kind) => {
|
|
612
|
+
const line = diagnostic.endsWith("\n") ? diagnostic : `${diagnostic}\n`;
|
|
613
|
+
try {
|
|
614
|
+
onCleanupFailure?.(line, kind);
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
// diagnostic-sink failure is itself best-effort; never break acquire()/release().
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
/**
|
|
621
|
+
* Release-time cleanup failure: the release could not remove the lock, so
|
|
622
|
+
* the residual lock (carrying this process's pid) stays on disk — the next
|
|
623
|
+
* acquire reads it as live and rejects; nothing here may promise that the
|
|
624
|
+
* next acquire reclaims it.
|
|
625
|
+
*/
|
|
626
|
+
const reportCleanupFailure = (error) => {
|
|
627
|
+
reportDiagnostic(`writer lease lock cleanup failed (release is best-effort; residual lock left in place) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`);
|
|
628
|
+
};
|
|
629
|
+
/** Contested-lock read failure: nothing was cleaned up; the lock stays exactly where it is. */
|
|
630
|
+
const reportReadFailure = (error) => {
|
|
631
|
+
reportDiagnostic(`writer lease lock read failed (holder liveness unverifiable; lock left in place) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`);
|
|
632
|
+
};
|
|
633
|
+
const lockPath = join(runDirectory, WRITER_LOCK_FILE);
|
|
634
|
+
let lastAutopsy = { verdict: "absent" };
|
|
635
|
+
let lastReclaimFailure;
|
|
636
|
+
for (let reclaimsLeft = WRITER_LEASE_RECLAIM_ROUNDS;; reclaimsLeft -= 1) {
|
|
637
|
+
try {
|
|
638
|
+
return await createWriterLease(lockPath, runDirectory, reportCleanupFailure);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
if (errorCodeOf(error) !== "EEXIST")
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
lastAutopsy = await autopsyWriterLock(lockPath);
|
|
645
|
+
if (lastAutopsy.verdict === "absent" && lastAutopsy.readFailure !== undefined) {
|
|
646
|
+
reportReadFailure(lastAutopsy.readFailure);
|
|
647
|
+
}
|
|
648
|
+
if (lastAutopsy.verdict === "alive") {
|
|
649
|
+
throw new RunWriterLeaseHeldError(`role run writer lease is already held by live pid ${lastAutopsy.pid} at ${lockPath}`);
|
|
650
|
+
}
|
|
651
|
+
if (lastAutopsy.verdict === "absent") {
|
|
652
|
+
throw new RunWriterLeaseHeldError(lastAutopsy.readFailure !== undefined
|
|
653
|
+
? `role run writer lease lock is unreadable at ${lockPath}: ${describeErrorIdentity(lastAutopsy.readFailure)}; holder liveness unverifiable, lock left in place`
|
|
654
|
+
: `role run writer lease lock at ${lockPath} has no verifiable holder pid (empty or unparseable); holder liveness unverifiable, lock left in place`);
|
|
655
|
+
}
|
|
656
|
+
if (reclaimsLeft <= 0)
|
|
657
|
+
break;
|
|
658
|
+
let reclaimed = false;
|
|
659
|
+
try {
|
|
660
|
+
const outcome = await reclaimStaleWriterLock(lockPath, runDirectory);
|
|
661
|
+
reclaimed = outcome.reclaimed;
|
|
662
|
+
if (!reclaimed && outcome.eaccesFailure !== undefined) {
|
|
663
|
+
lastReclaimFailure = outcome.eaccesFailure;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
catch (reclaimError) {
|
|
667
|
+
throw new RunWriterLeaseHeldError(`stale writer lease reclaim failed at ${lockPath} (autopsy: ${describeAutopsy(lastAutopsy)}): ${describeErrorIdentity(reclaimError)}`);
|
|
668
|
+
}
|
|
669
|
+
if (reclaimed) {
|
|
670
|
+
reportDiagnostic(`stale writer lease reclaimed at ${lockPath} (holder pid ${lastAutopsy.pid} verified dead): the killed holder may have left an orphaned pi child still writing this run — check for a surviving pi process on this run before continuing`, "stale-reclaimed");
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
throw new RunWriterLeaseHeldError(lastReclaimFailure !== undefined
|
|
674
|
+
? `role run writer lease stayed contested at ${lockPath} after ${WRITER_LEASE_RECLAIM_ROUNDS} reclaims (last autopsy: ${describeAutopsy(lastAutopsy)}; last reclaim failure: ${describeErrorIdentity(lastReclaimFailure)})`
|
|
675
|
+
: `role run writer lease stayed contested at ${lockPath} after ${WRITER_LEASE_RECLAIM_ROUNDS} reclaims (last autopsy: ${describeAutopsy(lastAutopsy)})`);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Locate a Role run directory by run ID under the ledger books home.
|
|
679
|
+
* Returns undefined when the ID is unknown.
|
|
680
|
+
*/
|
|
681
|
+
export async function findRunDirectoryById(home, runId) {
|
|
682
|
+
if (runId.trim() === "")
|
|
683
|
+
return undefined;
|
|
684
|
+
const ledgerHome = resolveActivationLedgerHome(home);
|
|
685
|
+
const booksRoot = join(ledgerHome, "books");
|
|
686
|
+
let bookKeys;
|
|
687
|
+
try {
|
|
688
|
+
bookKeys = await readdir(booksRoot);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
return undefined;
|
|
692
|
+
}
|
|
693
|
+
for (const bookKey of bookKeys) {
|
|
694
|
+
const runsDir = join(activationBookDirectory(ledgerHome, bookKey), "runs");
|
|
695
|
+
let entries;
|
|
696
|
+
try {
|
|
697
|
+
entries = await readdir(runsDir);
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
for (const entry of entries) {
|
|
703
|
+
if (entry === `${runId}@judge` || entry.startsWith(`${runId}@`)) {
|
|
704
|
+
return join(runsDir, entry);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Locate the latest retained run for one seat+ticket under a book (#637).
|
|
712
|
+
* Same walk surface as findRunDirectoryById; ticket identity from durable pages.
|
|
713
|
+
* runId is UUIDv7 — lexicographic max is latest. No parallel index.
|
|
714
|
+
* Only a truly missing runs directory means no history; damage/permission errors propagate.
|
|
715
|
+
*/
|
|
716
|
+
export async function findLatestRunIdForSeatTicket(input) {
|
|
717
|
+
const ledgerHome = resolveActivationLedgerHome(input.home);
|
|
718
|
+
const runsDir = join(activationBookDirectory(ledgerHome, input.bookKey), "runs");
|
|
719
|
+
let entries;
|
|
720
|
+
try {
|
|
721
|
+
entries = await readdir(runsDir);
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
if (errorCodeOf(error) === "ENOENT")
|
|
725
|
+
return undefined;
|
|
726
|
+
throw error;
|
|
727
|
+
}
|
|
728
|
+
const suffix = `@${input.role}`;
|
|
729
|
+
let best;
|
|
730
|
+
for (const entry of entries) {
|
|
731
|
+
if (!entry.endsWith(suffix))
|
|
732
|
+
continue;
|
|
733
|
+
const runId = entry.slice(0, entry.length - suffix.length);
|
|
734
|
+
if (runId.length === 0)
|
|
735
|
+
continue;
|
|
736
|
+
const ticketNumber = await readRunTicketNumber(join(runsDir, entry));
|
|
737
|
+
if (ticketNumber !== input.ticketNumber)
|
|
738
|
+
continue;
|
|
739
|
+
if (best === undefined || runId > best)
|
|
740
|
+
best = runId;
|
|
741
|
+
}
|
|
742
|
+
return best;
|
|
743
|
+
}
|
|
744
|
+
/** Restore optional correlation + typed ticket identity from a durable admitted page. */
|
|
745
|
+
function parsePersistedTicketIdentity(record) {
|
|
746
|
+
const correlationId = typeof record.correlationId === "string" && record.correlationId.trim() !== ""
|
|
747
|
+
? record.correlationId
|
|
748
|
+
: undefined;
|
|
749
|
+
const ticketNumber = typeof record.ticketNumber === "number" &&
|
|
750
|
+
Number.isSafeInteger(record.ticketNumber) &&
|
|
751
|
+
record.ticketNumber >= 1
|
|
752
|
+
? record.ticketNumber
|
|
753
|
+
: undefined;
|
|
754
|
+
const ticketResolution = record.ticketResolution === "true-unbound" ? "true-unbound" : undefined;
|
|
755
|
+
return {
|
|
756
|
+
...(correlationId === undefined ? {} : { correlationId }),
|
|
757
|
+
...(ticketNumber === undefined ? {} : { ticketNumber }),
|
|
758
|
+
...(ticketResolution === undefined ? {} : { ticketResolution }),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
function restoredTicketFields(fields) {
|
|
762
|
+
return {
|
|
763
|
+
...(fields.correlationId === undefined ? {} : { correlationId: fields.correlationId }),
|
|
764
|
+
...(fields.ticketNumber === undefined ? {} : { ticketNumber: fields.ticketNumber }),
|
|
765
|
+
...(fields.ticketResolution === undefined
|
|
766
|
+
? {}
|
|
767
|
+
: { ticketResolution: fields.ticketResolution }),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
async function loadResumableRunRecord(home, runId, authority) {
|
|
771
|
+
const runDirectory = await findRunDirectoryById(home, runId);
|
|
772
|
+
if (runDirectory === undefined) {
|
|
773
|
+
throw new CliUsageError(`unknown role run id: ${runId}`);
|
|
774
|
+
}
|
|
775
|
+
const disk = await readRoleRunStateDisk(runDirectory);
|
|
776
|
+
if (disk === undefined) {
|
|
777
|
+
throw new CliUsageError(`unknown role run id: ${runId}`);
|
|
778
|
+
}
|
|
779
|
+
// #416: removed terminal/resumable gates per owner decision "根本不要有限制" (2026-08-22).
|
|
780
|
+
// Only the exact Pi session principal check remains as honest failure.
|
|
781
|
+
// One authority.decode of the uninterpreted wire yields both principal and record.
|
|
782
|
+
const materialized = materializeRoleRunFromDisk(disk, authority);
|
|
783
|
+
if (materialized === undefined) {
|
|
784
|
+
throw new CliUsageError(`unknown role run id: ${runId}`);
|
|
785
|
+
}
|
|
786
|
+
const { run, principal } = materialized;
|
|
787
|
+
if (!(await isDurablePrincipalAvailable(principal, authority))) {
|
|
788
|
+
throw new CliUsageError(`role run Pi session principal is unavailable: ${runId}`);
|
|
789
|
+
}
|
|
790
|
+
// Reconstruct admitted identity from durable run record + admitted-request.json.
|
|
791
|
+
let instruction = "";
|
|
792
|
+
let instructionEmpty = true;
|
|
793
|
+
let attachments = [];
|
|
794
|
+
let phase;
|
|
795
|
+
let taskPath;
|
|
796
|
+
let packetPath;
|
|
797
|
+
let prerequisitesPath;
|
|
798
|
+
let prerequisites;
|
|
799
|
+
let baseRevision;
|
|
800
|
+
let authorityRefs;
|
|
801
|
+
let mergerInputPath;
|
|
802
|
+
let derived;
|
|
803
|
+
let correlationId;
|
|
804
|
+
let ticketNumber;
|
|
805
|
+
let ticketResolution;
|
|
806
|
+
let prNumber;
|
|
807
|
+
let repository;
|
|
808
|
+
let repositoryDisplay;
|
|
809
|
+
let requestManifestPath;
|
|
810
|
+
let manifestDigest;
|
|
811
|
+
let issueNumber;
|
|
812
|
+
let caseRunsPath;
|
|
813
|
+
let caseIdentity;
|
|
814
|
+
let sourceRunPath;
|
|
815
|
+
let sourceRun;
|
|
816
|
+
try {
|
|
817
|
+
const raw = JSON.parse(await readFile(run.admittedRequestPath, "utf8"));
|
|
818
|
+
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
|
819
|
+
const record = raw;
|
|
820
|
+
if (typeof record.instruction === "string") {
|
|
821
|
+
instruction = record.instruction;
|
|
822
|
+
}
|
|
823
|
+
if (typeof record.instructionEmpty === "boolean") {
|
|
824
|
+
instructionEmpty = record.instructionEmpty;
|
|
825
|
+
}
|
|
826
|
+
if (Array.isArray(record.attachments)) {
|
|
827
|
+
attachments = record.attachments;
|
|
828
|
+
}
|
|
829
|
+
if (record.phase === "plan" || record.phase === "apply") {
|
|
830
|
+
phase = record.phase;
|
|
831
|
+
}
|
|
832
|
+
if (typeof record.taskPath === "string" && record.taskPath.trim() !== "") {
|
|
833
|
+
taskPath = record.taskPath;
|
|
834
|
+
}
|
|
835
|
+
if (typeof record.packetPath === "string" && record.packetPath.trim() !== "") {
|
|
836
|
+
packetPath = record.packetPath;
|
|
837
|
+
}
|
|
838
|
+
if (typeof record.prerequisitesPath === "string" &&
|
|
839
|
+
record.prerequisitesPath.trim() !== "") {
|
|
840
|
+
prerequisitesPath = record.prerequisitesPath;
|
|
841
|
+
}
|
|
842
|
+
if (Array.isArray(record.prerequisites)) {
|
|
843
|
+
prerequisites = record.prerequisites;
|
|
844
|
+
}
|
|
845
|
+
if (typeof record.baseRevision === "string" &&
|
|
846
|
+
record.baseRevision.trim() !== "") {
|
|
847
|
+
baseRevision = record.baseRevision;
|
|
848
|
+
}
|
|
849
|
+
// Collector — admitted repository/PR identity (#633 resume).
|
|
850
|
+
if (typeof record.prNumber === "number" && Number.isSafeInteger(record.prNumber) && record.prNumber >= 1) {
|
|
851
|
+
prNumber = record.prNumber;
|
|
852
|
+
}
|
|
853
|
+
if (typeof record.repository === "string" && record.repository.trim() !== "") {
|
|
854
|
+
repository = record.repository;
|
|
855
|
+
}
|
|
856
|
+
if (typeof record.repositoryDisplay === "string" && record.repositoryDisplay.trim() !== "") {
|
|
857
|
+
repositoryDisplay = record.repositoryDisplay;
|
|
858
|
+
}
|
|
859
|
+
if (typeof record.requestManifestPath === "string" && record.requestManifestPath.trim() !== "") {
|
|
860
|
+
requestManifestPath = record.requestManifestPath;
|
|
861
|
+
}
|
|
862
|
+
if (typeof record.manifestDigest === "string" && record.manifestDigest.trim() !== "") {
|
|
863
|
+
manifestDigest = record.manifestDigest;
|
|
864
|
+
}
|
|
865
|
+
// Doctor — admitted single-case identity (#633 resume).
|
|
866
|
+
if (typeof record.issueNumber === "number" && Number.isSafeInteger(record.issueNumber) && record.issueNumber >= 1) {
|
|
867
|
+
issueNumber = record.issueNumber;
|
|
868
|
+
}
|
|
869
|
+
if (typeof record.caseRunsPath === "string" && record.caseRunsPath.trim() !== "") {
|
|
870
|
+
caseRunsPath = record.caseRunsPath;
|
|
871
|
+
}
|
|
872
|
+
if (record.caseIdentity !== null &&
|
|
873
|
+
typeof record.caseIdentity === "object" &&
|
|
874
|
+
!Array.isArray(record.caseIdentity)) {
|
|
875
|
+
const ci = record.caseIdentity;
|
|
876
|
+
if (typeof ci.issueNumber === "number" &&
|
|
877
|
+
Number.isSafeInteger(ci.issueNumber) &&
|
|
878
|
+
ci.issueNumber >= 1 &&
|
|
879
|
+
typeof ci.runsPath === "string" &&
|
|
880
|
+
ci.runsPath.trim() !== "") {
|
|
881
|
+
caseIdentity = { issueNumber: ci.issueNumber, runsPath: ci.runsPath };
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
// Notary — admitted source-run locator identity (#633 resume).
|
|
885
|
+
if (typeof record.sourceRunPath === "string" && record.sourceRunPath.trim() !== "") {
|
|
886
|
+
sourceRunPath = record.sourceRunPath;
|
|
887
|
+
}
|
|
888
|
+
if (record.sourceRun !== null &&
|
|
889
|
+
typeof record.sourceRun === "object" &&
|
|
890
|
+
!Array.isArray(record.sourceRun)) {
|
|
891
|
+
const sr = record.sourceRun;
|
|
892
|
+
if (typeof sr.runId === "string" &&
|
|
893
|
+
sr.runId.trim() !== "" &&
|
|
894
|
+
typeof sr.role === "string" &&
|
|
895
|
+
sr.role.trim() !== "" &&
|
|
896
|
+
typeof sr.runDirectory === "string" &&
|
|
897
|
+
sr.runDirectory.trim() !== "") {
|
|
898
|
+
sourceRun = {
|
|
899
|
+
runId: sr.runId,
|
|
900
|
+
role: sr.role,
|
|
901
|
+
runDirectory: sr.runDirectory,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (Array.isArray(record.authorityRefs)) {
|
|
906
|
+
// Reuse unique --authority-ref grammar; blank/inline prose must not resume as authority.
|
|
907
|
+
authorityRefs = Object.freeze(record.authorityRefs.map((ref) => {
|
|
908
|
+
if (typeof ref !== "string") {
|
|
909
|
+
throw new CliUsageError("role run admitted authority refs must be durable reference strings");
|
|
910
|
+
}
|
|
911
|
+
return requireAuthorityRef(ref);
|
|
912
|
+
}));
|
|
913
|
+
}
|
|
914
|
+
if (typeof record.mergerInputPath === "string" &&
|
|
915
|
+
record.mergerInputPath.trim() !== "") {
|
|
916
|
+
mergerInputPath = record.mergerInputPath;
|
|
917
|
+
}
|
|
918
|
+
if (record.derived !== null &&
|
|
919
|
+
typeof record.derived === "object" &&
|
|
920
|
+
!Array.isArray(record.derived)) {
|
|
921
|
+
const d = record.derived;
|
|
922
|
+
if (typeof d.targetObjectId === "string" &&
|
|
923
|
+
typeof d.sourceObjectId === "string" &&
|
|
924
|
+
typeof d.automaticMergeTreeId === "string" &&
|
|
925
|
+
Array.isArray(d.expectedConflictPaths) &&
|
|
926
|
+
Array.isArray(d.resolutionScope) &&
|
|
927
|
+
d.expectedConflictPaths.every((p) => typeof p === "string") &&
|
|
928
|
+
d.resolutionScope.every((p) => typeof p === "string")) {
|
|
929
|
+
derived = {
|
|
930
|
+
targetObjectId: d.targetObjectId,
|
|
931
|
+
sourceObjectId: d.sourceObjectId,
|
|
932
|
+
automaticMergeTreeId: d.automaticMergeTreeId,
|
|
933
|
+
expectedConflictPaths: d.expectedConflictPaths,
|
|
934
|
+
resolutionScope: d.resolutionScope,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
const fromAdmitted = parsePersistedTicketIdentity(record);
|
|
939
|
+
correlationId = fromAdmitted.correlationId;
|
|
940
|
+
ticketNumber = fromAdmitted.ticketNumber;
|
|
941
|
+
ticketResolution = fromAdmitted.ticketResolution;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
catch (error) {
|
|
945
|
+
// Preserve unique --authority-ref grammar failures; do not collapse to unreadable.
|
|
946
|
+
if (error instanceof CliUsageError)
|
|
947
|
+
throw error;
|
|
948
|
+
throw new CliUsageError(`role run admitted request is unreadable: ${runId}`, { cause: error });
|
|
949
|
+
}
|
|
950
|
+
let model;
|
|
951
|
+
try {
|
|
952
|
+
const invocationRaw = JSON.parse(await readFile(join(run.runDirectory, "invocation.json"), "utf8"));
|
|
953
|
+
if (invocationRaw !== null &&
|
|
954
|
+
typeof invocationRaw === "object" &&
|
|
955
|
+
!Array.isArray(invocationRaw)) {
|
|
956
|
+
const rec = invocationRaw;
|
|
957
|
+
if (typeof rec.provider === "string" && typeof rec.model === "string") {
|
|
958
|
+
model = {
|
|
959
|
+
provider: rec.provider,
|
|
960
|
+
model: rec.model,
|
|
961
|
+
...(typeof rec.thinking === "string"
|
|
962
|
+
? { thinking: rec.thinking }
|
|
963
|
+
: {}),
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
if (correlationId === undefined ||
|
|
967
|
+
ticketNumber === undefined ||
|
|
968
|
+
ticketResolution === undefined) {
|
|
969
|
+
const fromInvocation = parsePersistedTicketIdentity(rec);
|
|
970
|
+
if (correlationId === undefined)
|
|
971
|
+
correlationId = fromInvocation.correlationId;
|
|
972
|
+
if (ticketNumber === undefined)
|
|
973
|
+
ticketNumber = fromInvocation.ticketNumber;
|
|
974
|
+
if (ticketResolution === undefined) {
|
|
975
|
+
ticketResolution = fromInvocation.ticketResolution;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
catch (error) {
|
|
981
|
+
if (!(error instanceof Error &&
|
|
982
|
+
"code" in error &&
|
|
983
|
+
error.code === "ENOENT")) {
|
|
984
|
+
throw new CliUsageError(`role run invocation identity is unreadable: ${runId}`, { cause: error });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return {
|
|
988
|
+
run,
|
|
989
|
+
principal,
|
|
990
|
+
...(run.resumable === undefined ? {} : { observation: run.resumable }),
|
|
991
|
+
admittedFields: {
|
|
992
|
+
instruction,
|
|
993
|
+
instructionEmpty,
|
|
994
|
+
attachments,
|
|
995
|
+
...(phase === undefined ? {} : { phase }),
|
|
996
|
+
...(taskPath === undefined ? {} : { taskPath }),
|
|
997
|
+
...(packetPath === undefined ? {} : { packetPath }),
|
|
998
|
+
...(prerequisitesPath === undefined ? {} : { prerequisitesPath }),
|
|
999
|
+
...(prerequisites === undefined ? {} : { prerequisites }),
|
|
1000
|
+
...(baseRevision === undefined ? {} : { baseRevision }),
|
|
1001
|
+
...(authorityRefs === undefined ? {} : { authorityRefs }),
|
|
1002
|
+
...(mergerInputPath === undefined ? {} : { mergerInputPath }),
|
|
1003
|
+
...(derived === undefined ? {} : { derived }),
|
|
1004
|
+
...(correlationId === undefined ? {} : { correlationId }),
|
|
1005
|
+
...(ticketNumber === undefined ? {} : { ticketNumber }),
|
|
1006
|
+
...(ticketResolution === undefined ? {} : { ticketResolution }),
|
|
1007
|
+
...(prNumber === undefined ? {} : { prNumber }),
|
|
1008
|
+
...(repository === undefined ? {} : { repository }),
|
|
1009
|
+
...(repositoryDisplay === undefined ? {} : { repositoryDisplay }),
|
|
1010
|
+
...(requestManifestPath === undefined ? {} : { requestManifestPath }),
|
|
1011
|
+
...(manifestDigest === undefined ? {} : { manifestDigest }),
|
|
1012
|
+
...(issueNumber === undefined ? {} : { issueNumber }),
|
|
1013
|
+
...(caseRunsPath === undefined ? {} : { caseRunsPath }),
|
|
1014
|
+
...(caseIdentity === undefined ? {} : { caseIdentity }),
|
|
1015
|
+
...(sourceRunPath === undefined ? {} : { sourceRunPath }),
|
|
1016
|
+
...(sourceRun === undefined ? {} : { sourceRun }),
|
|
1017
|
+
...(model === undefined ? {} : { model }),
|
|
1018
|
+
},
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Base admitted projection shared by every seat loader: durable identity +
|
|
1023
|
+
* instruction/attachments + principal + restored ticket identity + model (#633).
|
|
1024
|
+
* Seat loaders add only their seat-specific fields on top.
|
|
1025
|
+
*/
|
|
1026
|
+
function resumedBaseAdmitted(loaded) {
|
|
1027
|
+
return {
|
|
1028
|
+
runId: loaded.run.runId,
|
|
1029
|
+
bookKey: loaded.run.bookKey,
|
|
1030
|
+
projectRoot: loaded.run.projectRoot,
|
|
1031
|
+
instruction: loaded.admittedFields.instruction,
|
|
1032
|
+
instructionEmpty: loaded.admittedFields.instructionEmpty,
|
|
1033
|
+
attachments: loaded.admittedFields.attachments,
|
|
1034
|
+
runDirectory: loaded.run.runDirectory,
|
|
1035
|
+
principal: loaded.principal,
|
|
1036
|
+
admittedRequestPath: loaded.run.admittedRequestPath,
|
|
1037
|
+
...(loaded.admittedFields.model === undefined ? {} : { model: loaded.admittedFields.model }),
|
|
1038
|
+
...restoredTicketFields(loaded.admittedFields),
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
/** Loaded-run envelope projection; seat loaders add only their seat fields. */
|
|
1042
|
+
function seatLoadedResult(loaded, admitted) {
|
|
1043
|
+
return {
|
|
1044
|
+
admitted,
|
|
1045
|
+
run: loaded.run,
|
|
1046
|
+
...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Load a resumable Judge run for resume. Rejects unknown, terminal,
|
|
1051
|
+
* non-resumable, and non-Judge IDs without replaying dispatch.
|
|
1052
|
+
*/
|
|
1053
|
+
export async function loadResumableJudgeRun(home, runId, authority) {
|
|
1054
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1055
|
+
if (loaded.run.role !== "judge") {
|
|
1056
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not judge`);
|
|
1057
|
+
}
|
|
1058
|
+
const admitted = {
|
|
1059
|
+
role: "judge",
|
|
1060
|
+
...resumedBaseAdmitted(loaded),
|
|
1061
|
+
};
|
|
1062
|
+
return seatLoadedResult(loaded, admitted);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Load a resumable Coder run for resume. Phase and task path are restored from
|
|
1066
|
+
* the admitted request so continuation stays role-correct (#109).
|
|
1067
|
+
*/
|
|
1068
|
+
export async function loadResumableCoderRun(home, runId, authority) {
|
|
1069
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1070
|
+
if (loaded.run.role !== "coder") {
|
|
1071
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not coder`);
|
|
1072
|
+
}
|
|
1073
|
+
const phase = loaded.admittedFields.phase ?? loaded.run.phase;
|
|
1074
|
+
if (phase !== "plan" && phase !== "apply") {
|
|
1075
|
+
throw new CliUsageError(`role run admitted coder phase is missing: ${runId}`);
|
|
1076
|
+
}
|
|
1077
|
+
const taskPath = loaded.admittedFields.taskPath;
|
|
1078
|
+
if (taskPath === undefined) {
|
|
1079
|
+
throw new CliUsageError(`role run admitted coder task path is missing: ${runId}`);
|
|
1080
|
+
}
|
|
1081
|
+
if (loaded.admittedFields.instruction.trim() === "") {
|
|
1082
|
+
throw new CliUsageError(`role run admitted coder task is blank: ${runId}`);
|
|
1083
|
+
}
|
|
1084
|
+
const admitted = {
|
|
1085
|
+
role: "coder",
|
|
1086
|
+
phase,
|
|
1087
|
+
...resumedBaseAdmitted(loaded),
|
|
1088
|
+
instructionEmpty: false,
|
|
1089
|
+
taskPath,
|
|
1090
|
+
};
|
|
1091
|
+
return seatLoadedResult(loaded, admitted);
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Load a resumable Fixer run for resume. Phase, packet, and prerequisites are
|
|
1095
|
+
* restored from the admitted request so continuation stays role-correct (#110).
|
|
1096
|
+
*/
|
|
1097
|
+
export async function loadResumableFixerRun(home, runId, authority) {
|
|
1098
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1099
|
+
if (loaded.run.role !== "fixer") {
|
|
1100
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not fixer`);
|
|
1101
|
+
}
|
|
1102
|
+
const phase = loaded.admittedFields.phase ?? loaded.run.phase;
|
|
1103
|
+
if (phase !== "plan" && phase !== "apply") {
|
|
1104
|
+
throw new CliUsageError(`role run admitted fixer phase is missing: ${runId}`);
|
|
1105
|
+
}
|
|
1106
|
+
const packetPath = loaded.admittedFields.packetPath;
|
|
1107
|
+
if (packetPath === undefined) {
|
|
1108
|
+
throw new CliUsageError(`role run admitted fixer packet path is missing: ${runId}`);
|
|
1109
|
+
}
|
|
1110
|
+
if (loaded.admittedFields.instruction.trim() === "") {
|
|
1111
|
+
throw new CliUsageError(`role run admitted fixer instruction is blank: ${runId}`);
|
|
1112
|
+
}
|
|
1113
|
+
const prerequisites = loaded.admittedFields.prerequisites ?? Object.freeze([]);
|
|
1114
|
+
const admitted = {
|
|
1115
|
+
role: "fixer",
|
|
1116
|
+
phase,
|
|
1117
|
+
...resumedBaseAdmitted(loaded),
|
|
1118
|
+
instructionEmpty: false,
|
|
1119
|
+
packetPath,
|
|
1120
|
+
...(loaded.admittedFields.prerequisitesPath === undefined
|
|
1121
|
+
? {}
|
|
1122
|
+
: { prerequisitesPath: loaded.admittedFields.prerequisitesPath }),
|
|
1123
|
+
prerequisites,
|
|
1124
|
+
};
|
|
1125
|
+
return seatLoadedResult(loaded, admitted);
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Peek the durable role of a run id without enforcing resumable state.
|
|
1129
|
+
* Used by public resume dispatch to pick the role-correct seat and path.
|
|
1130
|
+
*/
|
|
1131
|
+
/**
|
|
1132
|
+
* Load a resumable Reviewer run for resume. Fixed base is restored from the
|
|
1133
|
+
* admitted request; caller instruction remains optional provenance only.
|
|
1134
|
+
*/
|
|
1135
|
+
export async function loadResumableReviewerRun(home, runId, authority) {
|
|
1136
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1137
|
+
if (loaded.run.role !== "reviewer") {
|
|
1138
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not reviewer`);
|
|
1139
|
+
}
|
|
1140
|
+
const baseRevision = loaded.admittedFields.baseRevision;
|
|
1141
|
+
if (baseRevision === undefined || baseRevision.trim() === "") {
|
|
1142
|
+
throw new CliUsageError(`role run admitted reviewer base revision is missing: ${runId}`);
|
|
1143
|
+
}
|
|
1144
|
+
const admitted = {
|
|
1145
|
+
role: "reviewer",
|
|
1146
|
+
...resumedBaseAdmitted(loaded),
|
|
1147
|
+
baseRevision,
|
|
1148
|
+
authorityRefs: Object.freeze([...(loaded.admittedFields.authorityRefs ?? [])]),
|
|
1149
|
+
};
|
|
1150
|
+
return seatLoadedResult(loaded, admitted);
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Load a resumable Countersign run for resume (#599). Ticket binding and
|
|
1154
|
+
* attachments restore from the admitted request.
|
|
1155
|
+
*/
|
|
1156
|
+
export async function loadResumableCountersignRun(home, runId, authority) {
|
|
1157
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1158
|
+
if (loaded.run.role !== "countersign") {
|
|
1159
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not countersign`);
|
|
1160
|
+
}
|
|
1161
|
+
const admitted = {
|
|
1162
|
+
role: "countersign",
|
|
1163
|
+
...resumedBaseAdmitted(loaded),
|
|
1164
|
+
};
|
|
1165
|
+
return seatLoadedResult(loaded, admitted);
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Load a resumable Gleaner-Left run for resume (#599). Fixed base restores from
|
|
1169
|
+
* the admitted request so continuation stays comparison-correct.
|
|
1170
|
+
*/
|
|
1171
|
+
export async function loadResumableInstructionSeatRun(home, runId, authority) {
|
|
1172
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1173
|
+
if (loaded.run.role !== "gatekeeper"
|
|
1174
|
+
&& loaded.run.role !== "navigator"
|
|
1175
|
+
&& loaded.run.role !== "auditor"
|
|
1176
|
+
&& loaded.run.role !== "evidence-child") {
|
|
1177
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not an instruction seat`);
|
|
1178
|
+
}
|
|
1179
|
+
const admitted = {
|
|
1180
|
+
role: loaded.run.role,
|
|
1181
|
+
...resumedBaseAdmitted(loaded),
|
|
1182
|
+
};
|
|
1183
|
+
return seatLoadedResult(loaded, admitted);
|
|
1184
|
+
}
|
|
1185
|
+
export async function loadResumableGleanerLeftRun(home, runId, authority) {
|
|
1186
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1187
|
+
if (loaded.run.role !== "gleaner-left") {
|
|
1188
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not gleaner-left`);
|
|
1189
|
+
}
|
|
1190
|
+
const baseRevision = loaded.admittedFields.baseRevision;
|
|
1191
|
+
if (baseRevision === undefined || baseRevision.trim() === "") {
|
|
1192
|
+
throw new CliUsageError(`role run admitted gleaner-left base revision is missing: ${runId}`);
|
|
1193
|
+
}
|
|
1194
|
+
const admitted = {
|
|
1195
|
+
role: "gleaner-left",
|
|
1196
|
+
...resumedBaseAdmitted(loaded),
|
|
1197
|
+
baseRevision,
|
|
1198
|
+
};
|
|
1199
|
+
return seatLoadedResult(loaded, admitted);
|
|
1200
|
+
}
|
|
1201
|
+
export async function loadResumableDiaristRun(home, runId, authority) {
|
|
1202
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1203
|
+
if (loaded.run.role !== "diarist") {
|
|
1204
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not diarist`);
|
|
1205
|
+
}
|
|
1206
|
+
const admitted = {
|
|
1207
|
+
role: "diarist",
|
|
1208
|
+
...resumedBaseAdmitted(loaded),
|
|
1209
|
+
};
|
|
1210
|
+
return seatLoadedResult(loaded, admitted);
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Load a resumable Merger run for resume. Derived envelope + internal input path
|
|
1214
|
+
* are restored from the admitted request (#114).
|
|
1215
|
+
*/
|
|
1216
|
+
export async function loadResumableMergerRun(home, runId, authority) {
|
|
1217
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1218
|
+
if (loaded.run.role !== "merger") {
|
|
1219
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not merger`);
|
|
1220
|
+
}
|
|
1221
|
+
const mergerInputPath = loaded.admittedFields.mergerInputPath;
|
|
1222
|
+
if (mergerInputPath === undefined) {
|
|
1223
|
+
throw new CliUsageError(`role run admitted merger input path is missing: ${runId}`);
|
|
1224
|
+
}
|
|
1225
|
+
const derived = loaded.admittedFields.derived;
|
|
1226
|
+
if (derived === undefined) {
|
|
1227
|
+
throw new CliUsageError(`role run admitted merger envelope is missing: ${runId}`);
|
|
1228
|
+
}
|
|
1229
|
+
if (loaded.admittedFields.instruction.trim() === "") {
|
|
1230
|
+
throw new CliUsageError(`role run admitted merger task is blank: ${runId}`);
|
|
1231
|
+
}
|
|
1232
|
+
const admitted = {
|
|
1233
|
+
role: "merger",
|
|
1234
|
+
...resumedBaseAdmitted(loaded),
|
|
1235
|
+
instructionEmpty: false,
|
|
1236
|
+
mergerInputPath,
|
|
1237
|
+
derived,
|
|
1238
|
+
};
|
|
1239
|
+
return seatLoadedResult(loaded, admitted);
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Load a resumable Collector run for resume (#633). Admitted repository/PR
|
|
1243
|
+
* identity restores from the durable admitted request so the continuation
|
|
1244
|
+
* stays binding-correct.
|
|
1245
|
+
*/
|
|
1246
|
+
export async function loadResumableCollectorRun(home, runId, authority) {
|
|
1247
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1248
|
+
if (loaded.run.role !== "collector") {
|
|
1249
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not collector`);
|
|
1250
|
+
}
|
|
1251
|
+
const { prNumber, repository, repositoryDisplay, manifestDigest } = loaded.admittedFields;
|
|
1252
|
+
if (prNumber === undefined ||
|
|
1253
|
+
repository === undefined ||
|
|
1254
|
+
repositoryDisplay === undefined ||
|
|
1255
|
+
manifestDigest === undefined) {
|
|
1256
|
+
throw new CliUsageError(`role run admitted collector repository identity is missing: ${runId}`);
|
|
1257
|
+
}
|
|
1258
|
+
let parsedRepository;
|
|
1259
|
+
try {
|
|
1260
|
+
parsedRepository = parseCollectorRepository(repositoryDisplay);
|
|
1261
|
+
}
|
|
1262
|
+
catch (error) {
|
|
1263
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1264
|
+
throw new CliUsageError(detail, { cause: error });
|
|
1265
|
+
}
|
|
1266
|
+
if (parsedRepository.canonical !== repository) {
|
|
1267
|
+
throw new CliUsageError(`role run admitted collector repository does not match its canonical form: ${runId}`);
|
|
1268
|
+
}
|
|
1269
|
+
const admitted = {
|
|
1270
|
+
role: "collector",
|
|
1271
|
+
...resumedBaseAdmitted(loaded),
|
|
1272
|
+
prNumber,
|
|
1273
|
+
repository: parsedRepository,
|
|
1274
|
+
...(loaded.admittedFields.requestManifestPath === undefined
|
|
1275
|
+
? {}
|
|
1276
|
+
: { requestManifestPath: loaded.admittedFields.requestManifestPath }),
|
|
1277
|
+
manifestDigest,
|
|
1278
|
+
};
|
|
1279
|
+
return seatLoadedResult(loaded, admitted);
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Load a resumable Doctor run for resume (#633). Issue + retained case
|
|
1283
|
+
* identity restore from the durable admitted request.
|
|
1284
|
+
*/
|
|
1285
|
+
export async function loadResumableDoctorRun(home, runId, authority) {
|
|
1286
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1287
|
+
if (loaded.run.role !== "doctor") {
|
|
1288
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not doctor`);
|
|
1289
|
+
}
|
|
1290
|
+
const { issueNumber, caseRunsPath, caseIdentity } = loaded.admittedFields;
|
|
1291
|
+
if (issueNumber === undefined ||
|
|
1292
|
+
caseRunsPath === undefined ||
|
|
1293
|
+
caseIdentity === undefined) {
|
|
1294
|
+
throw new CliUsageError(`role run admitted doctor case identity is missing: ${runId}`);
|
|
1295
|
+
}
|
|
1296
|
+
const admitted = {
|
|
1297
|
+
role: "doctor",
|
|
1298
|
+
...resumedBaseAdmitted(loaded),
|
|
1299
|
+
issueNumber,
|
|
1300
|
+
caseRunsPath,
|
|
1301
|
+
caseIdentity,
|
|
1302
|
+
};
|
|
1303
|
+
return seatLoadedResult(loaded, admitted);
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Load a resumable Notary run for resume (#633). Source-run locator restores
|
|
1307
|
+
* from the durable admitted request; the locator is not re-resolved.
|
|
1308
|
+
*/
|
|
1309
|
+
export async function loadResumableNotaryRun(home, runId, authority) {
|
|
1310
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1311
|
+
if (loaded.run.role !== "notary") {
|
|
1312
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not notary`);
|
|
1313
|
+
}
|
|
1314
|
+
const { sourceRunPath, sourceRun } = loaded.admittedFields;
|
|
1315
|
+
if (sourceRunPath === undefined || sourceRun === undefined) {
|
|
1316
|
+
throw new CliUsageError(`role run admitted notary source-run locator is missing: ${runId}`);
|
|
1317
|
+
}
|
|
1318
|
+
const admitted = {
|
|
1319
|
+
role: "notary",
|
|
1320
|
+
...resumedBaseAdmitted(loaded),
|
|
1321
|
+
sourceRunPath,
|
|
1322
|
+
sourceRun,
|
|
1323
|
+
};
|
|
1324
|
+
return seatLoadedResult(loaded, admitted);
|
|
1325
|
+
}
|
|
1326
|
+
/** Load a resumable Inspector run for resume (#633); base admitted identity only. */
|
|
1327
|
+
export async function loadResumableInspectorRun(home, runId, authority) {
|
|
1328
|
+
const loaded = await loadResumableRunRecord(home, runId, authority);
|
|
1329
|
+
if (loaded.run.role !== "inspector") {
|
|
1330
|
+
throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not inspector`);
|
|
1331
|
+
}
|
|
1332
|
+
const admitted = {
|
|
1333
|
+
role: "inspector",
|
|
1334
|
+
...resumedBaseAdmitted(loaded),
|
|
1335
|
+
};
|
|
1336
|
+
return seatLoadedResult(loaded, admitted);
|
|
1337
|
+
}
|
|
1338
|
+
export async function peekRoleRunRole(home, runId) {
|
|
1339
|
+
const runDirectory = await findRunDirectoryById(home, runId);
|
|
1340
|
+
if (runDirectory === undefined)
|
|
1341
|
+
return undefined;
|
|
1342
|
+
const run = await readRoleRunIdentity(runDirectory);
|
|
1343
|
+
return run?.role;
|
|
1344
|
+
}
|