@akagilnc/pi-workflow-roles 0.1.4586 → 0.1.4631
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 +2 -2
- package/README.zh-CN.md +3 -3
- package/dist/acp-host/production-host.js +1184 -717
- package/dist/activation-ledger-topology.js +0 -4
- package/dist/archivist-record-topology.js +27 -35
- package/dist/headless-host/description.js +7 -4
- package/dist/headless-host/production-host.js +998 -595
- package/dist/migrate-book-topology.js +3 -6
- package/dist/navigator-attendance.js +146 -241
- package/dist/navigator-public-session.js +81 -15
- package/dist/package-contracts/navigator-output.js +30 -12
- package/dist/pi/role-turn-host.js +0 -3
- package/dist/public-cli/auto-resume.js +26 -0
- package/dist/public-cli/judge-run.js +2 -2
- package/dist/public-cli/main.js +1119 -870
- package/dist/public-cli/option-definitions.js +1 -1
- package/dist/public-cli/post-admission.js +103 -9
- package/dist/public-cli/process-cancel.js +57 -0
- package/dist/public-cli/run-lifecycle.js +29 -7
- package/dist/public-cli/settlement.js +46 -32
- package/dist/public-cli/terminal.js +8 -27
- package/dist/public-role-summons.js +22 -4
- package/dist/submission-ledger.js +56 -23
- package/package.json +1 -1
- package/resources/836-deleted-machine-instruction-inventory.md +2 -0
- package/resources/navigator-route-playbook.md +9 -0
- package/scripts/build-package.mjs +6 -2
- package/src/acp-host/role-turn-host.ts +129 -13
- package/src/activation-ledger-topology.ts +1 -6
- package/src/activation-ledger.ts +5 -236
- package/src/analyst-entry.ts +19 -2
- package/src/analyst-ledger.ts +111 -6
- package/src/archivist-record-entry.ts +1 -1
- package/src/headless-host/description.ts +17 -7
- package/src/headless-host/role-turn-host.ts +42 -11
- package/src/navigator-attendance.ts +290 -425
- package/src/navigator-public-session.ts +126 -25
- package/src/navigator-role.ts +2 -2
- package/src/package-contracts/navigator-output.ts +41 -25
- package/src/pi/role-turn-host.ts +0 -3
- package/src/public-cli/auto-resume.ts +34 -0
- package/src/public-cli/cli.ts +30 -0
- package/src/public-cli/coder-run.ts +2 -2
- package/src/public-cli/fixer-run.ts +2 -2
- package/src/public-cli/judge-run.ts +2 -2
- package/src/public-cli/main.ts +25 -3
- package/src/public-cli/merger-run.ts +2 -2
- package/src/public-cli/option-definitions.ts +1 -1
- package/src/public-cli/post-admission.ts +175 -7
- package/src/public-cli/process-cancel.ts +75 -0
- package/src/public-cli/run-lifecycle.ts +37 -7
- package/src/public-cli/settlement.ts +42 -33
- package/src/public-cli/terminal.ts +12 -37
- package/src/public-role-summons.ts +34 -5
- package/src/role-envelope.ts +89 -4
- package/src/role-runtime.ts +78 -65
- package/src/submission-ledger.ts +63 -21
- package/dist/activation-reconciliation.js +0 -61
- package/dist/public-cli/command-renderer.js +0 -5
- package/dist/public-command-renderer.js +0 -29
- package/src/activation-reconciliation.ts +0 -163
- package/src/public-cli/command-renderer.ts +0 -9
- package/src/public-command-renderer.ts +0 -55
package/src/activation-ledger.ts
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
writeSync,
|
|
7
|
-
} from "node:fs";
|
|
8
|
-
import { dirname, isAbsolute, resolve } from "node:path";
|
|
9
|
-
|
|
10
|
-
import type { ActivationSessionPointer } from "./activation-ledger-session.ts";
|
|
11
|
-
import {
|
|
12
|
-
ActivationLedgerError,
|
|
13
|
-
activationWaitingLedgerPath,
|
|
14
|
-
assertLedgerFileInsideHome,
|
|
15
|
-
ensureRealDirectoryTree,
|
|
16
|
-
errorText,
|
|
17
|
-
} from "./activation-ledger-topology.ts";
|
|
18
|
-
|
|
19
|
-
/** Private open flags: O_NOFOLLOW closes the lstat→open TOCTOU window. */
|
|
20
|
-
const ACTIVATION_LEDGER_APPEND_OPEN_FLAGS =
|
|
21
|
-
constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW;
|
|
22
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Activation ledger surface: durable session principal + book/home topology.
|
|
3
|
+
* #855: two-face waiting.jsonl reconciliation deleted — this module no longer
|
|
4
|
+
* appends accepted-activation facts or own a waiting path.
|
|
5
|
+
*/
|
|
23
6
|
export {
|
|
24
7
|
ActivationGitRepositoryRequiredError,
|
|
25
8
|
resolveBookKeyFromGit,
|
|
@@ -33,219 +16,5 @@ export {
|
|
|
33
16
|
export {
|
|
34
17
|
ActivationLedgerError,
|
|
35
18
|
activationBookDirectory,
|
|
36
|
-
activationWaitingLedgerPath,
|
|
37
19
|
resolveActivationLedgerHome,
|
|
38
20
|
} from "./activation-ledger-topology.ts";
|
|
39
|
-
|
|
40
|
-
/** Caller-preassigned correlation id, or an explicit absent identity (never empty string). */
|
|
41
|
-
export type ActivationCorrelationIdentity =
|
|
42
|
-
| { readonly kind: "caller"; readonly id: string }
|
|
43
|
-
| { readonly kind: "absent" };
|
|
44
|
-
|
|
45
|
-
export const ACCEPTED_ACTIVATION_EVENT = "accepted-activation" as const;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Package-owned index-only top-level keys for accepted-activation facts (ADR 0049).
|
|
49
|
-
* Sole machine key contract: projection and tests consume this descriptor — not prose markers.
|
|
50
|
-
*/
|
|
51
|
-
export const ACCEPTED_ACTIVATION_FACT_KEYS = Object.freeze([
|
|
52
|
-
"event",
|
|
53
|
-
"role",
|
|
54
|
-
"observedAt",
|
|
55
|
-
"bookKey",
|
|
56
|
-
"session",
|
|
57
|
-
"correlation",
|
|
58
|
-
] as const);
|
|
59
|
-
|
|
60
|
-
export type AcceptedActivationFactKey = (typeof ACCEPTED_ACTIVATION_FACT_KEYS)[number];
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Closed activation fact: index fields only (ADR 0049).
|
|
64
|
-
* No prompt, transcript, argv, excerpt, or other content.
|
|
65
|
-
*/
|
|
66
|
-
export type AcceptedActivationFact = {
|
|
67
|
-
readonly event: typeof ACCEPTED_ACTIVATION_EVENT;
|
|
68
|
-
readonly role: string;
|
|
69
|
-
readonly observedAt: string;
|
|
70
|
-
readonly bookKey: string;
|
|
71
|
-
readonly session: ActivationSessionPointer;
|
|
72
|
-
readonly correlation: ActivationCorrelationIdentity;
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
// Keys tuple ↔ fact type must stay exact (compile fail on drift; no second field list).
|
|
76
|
-
type ExactKeyMatch<T, K extends PropertyKey> =
|
|
77
|
-
Exclude<keyof T, K> | Exclude<K, keyof T> extends never ? true : never;
|
|
78
|
-
const _acceptedActivationFactKeysMatch: ExactKeyMatch<
|
|
79
|
-
AcceptedActivationFact,
|
|
80
|
-
AcceptedActivationFactKey
|
|
81
|
-
> = true;
|
|
82
|
-
void _acceptedActivationFactKeysMatch;
|
|
83
|
-
|
|
84
|
-
/** Trusted typed inputs for building the closed fact (canonical fact minus event discriminant). */
|
|
85
|
-
export type AcceptedActivationFactInput = Omit<AcceptedActivationFact, "event">;
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Host correlation channel (not a CLI flag): a non-blank AK_CORRELATION_ID carries
|
|
89
|
-
* the caller id verbatim; missing/blank/whitespace-only yields the typed absent identity.
|
|
90
|
-
*/
|
|
91
|
-
export function correlationIdentityFromEnv(
|
|
92
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
93
|
-
): ActivationCorrelationIdentity {
|
|
94
|
-
const raw = env.AK_CORRELATION_ID;
|
|
95
|
-
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
96
|
-
return { kind: "caller", id: raw };
|
|
97
|
-
}
|
|
98
|
-
return { kind: "absent" };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Descriptor-driven top-level pick: only ACCEPTED_ACTIVATION_FACT_KEYS leave this boundary.
|
|
103
|
-
* Nested session/correlation are rebuilt closed before the pick (ADR 0049: first-phase ledger holds zero content bytes by construction).
|
|
104
|
-
*/
|
|
105
|
-
function projectAcceptedActivationFact(
|
|
106
|
-
input: AcceptedActivationFactInput,
|
|
107
|
-
): AcceptedActivationFact {
|
|
108
|
-
const closed: AcceptedActivationFact = {
|
|
109
|
-
event: ACCEPTED_ACTIVATION_EVENT,
|
|
110
|
-
role: input.role,
|
|
111
|
-
observedAt: input.observedAt,
|
|
112
|
-
bookKey: input.bookKey,
|
|
113
|
-
session: { kind: "session-file", path: input.session.path },
|
|
114
|
-
correlation: input.correlation.kind === "caller"
|
|
115
|
-
? { kind: "caller", id: input.correlation.id }
|
|
116
|
-
: { kind: "absent" },
|
|
117
|
-
};
|
|
118
|
-
// Descriptor is the sole top-level emission contract (typed pick; no content keys).
|
|
119
|
-
return Object.fromEntries(
|
|
120
|
-
ACCEPTED_ACTIVATION_FACT_KEYS.map((key) => [key, closed[key]]),
|
|
121
|
-
) as AcceptedActivationFact;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Construct the closed fact from trusted typed inputs only (descriptor projection — no content keys). */
|
|
125
|
-
export function buildAcceptedActivationFact(input: AcceptedActivationFactInput): AcceptedActivationFact {
|
|
126
|
-
return projectAcceptedActivationFact(input);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Serialize only the closed index fields (descriptor projection — no content keys). */
|
|
130
|
-
export function serializeAcceptedActivationFact(fact: AcceptedActivationFact): string {
|
|
131
|
-
return `${JSON.stringify(projectAcceptedActivationFact(fact))}\n`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Bare O_APPEND write of one complete line under an absolute ledger home.
|
|
136
|
-
* Private helper: production-bound to writeSync; owns open/write/close honesty only.
|
|
137
|
-
* Close failure cannot mask open/write; simultaneous close is nested evidence.
|
|
138
|
-
*/
|
|
139
|
-
function appendActivationLedgerLine(
|
|
140
|
-
ledgerPath: string,
|
|
141
|
-
line: Uint8Array,
|
|
142
|
-
options: { ledgerHome: string },
|
|
143
|
-
): void {
|
|
144
|
-
if (!isAbsolute(options.ledgerHome)) {
|
|
145
|
-
throw new ActivationLedgerError(
|
|
146
|
-
`activation ledger home must be absolute: ${options.ledgerHome}`,
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
const resolvedLedger = resolve(ledgerPath);
|
|
150
|
-
const resolvedHome = resolve(options.ledgerHome);
|
|
151
|
-
const parent = dirname(resolvedLedger);
|
|
152
|
-
ensureRealDirectoryTree(resolvedHome, parent);
|
|
153
|
-
assertLedgerFileInsideHome(resolvedLedger, resolvedHome);
|
|
154
|
-
|
|
155
|
-
// Fail-closed guard (same shape as PR #418's publication seam): on platforms
|
|
156
|
-
// without O_NOFOLLOW (e.g. Windows, nodejs/node#41590) the JS bitwise-or in
|
|
157
|
-
// ACTIVATION_LEDGER_APPEND_OPEN_FLAGS would silently drop the flag and the
|
|
158
|
-
// lstat→open TOCTOU anti-symlink protection would vanish while the comment
|
|
159
|
-
// above still claims it. Refuse loudly via the existing typed ledger failure
|
|
160
|
-
// channel instead of appending unprotected; never silently degrade.
|
|
161
|
-
if (typeof constants.O_NOFOLLOW !== "number") {
|
|
162
|
-
throw new ActivationLedgerError(
|
|
163
|
-
"activation ledger append requires O_NOFOLLOW open-flag support (anti-symlink TOCTOU protection must not be silently dropped); refusing to append",
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
const bytes = Buffer.isBuffer(line) ? line : Buffer.from(line);
|
|
167
|
-
let ledgerFd: number | undefined;
|
|
168
|
-
let primaryFailure: unknown;
|
|
169
|
-
try {
|
|
170
|
-
try {
|
|
171
|
-
ledgerFd = openSync(resolvedLedger, ACTIVATION_LEDGER_APPEND_OPEN_FLAGS, 0o644);
|
|
172
|
-
} catch (error) {
|
|
173
|
-
// Native ELOOP/filesystem cause retained; no errno dispatch.
|
|
174
|
-
throw new ActivationLedgerError(
|
|
175
|
-
`activation ledger failed to open ledger file (${resolvedLedger}): ${errorText(error)}`,
|
|
176
|
-
{ cause: error },
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
let opened: ReturnType<typeof fstatSync>;
|
|
181
|
-
try {
|
|
182
|
-
opened = fstatSync(ledgerFd);
|
|
183
|
-
} catch (error) {
|
|
184
|
-
throw new ActivationLedgerError(
|
|
185
|
-
`activation ledger failed to fstat ledger file (${resolvedLedger}): ${errorText(error)}`,
|
|
186
|
-
{ cause: error },
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
if (!opened.isFile()) {
|
|
190
|
-
throw new ActivationLedgerError(
|
|
191
|
-
`activation ledger is not a regular file: ${resolvedLedger}`,
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const written = writeSync(ledgerFd, bytes, 0, bytes.length, null);
|
|
196
|
-
if (written !== bytes.length) {
|
|
197
|
-
throw new ActivationLedgerError(
|
|
198
|
-
`activation ledger short write: wrote ${written} of ${bytes.length} bytes to ${resolvedLedger}`,
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
} catch (error) {
|
|
202
|
-
primaryFailure = error;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
if (ledgerFd !== undefined) {
|
|
206
|
-
try {
|
|
207
|
-
closeSync(ledgerFd);
|
|
208
|
-
} catch (closeFailure) {
|
|
209
|
-
if (primaryFailure !== undefined) {
|
|
210
|
-
throw new AggregateError(
|
|
211
|
-
[primaryFailure, closeFailure],
|
|
212
|
-
"activation ledger operation and close failed",
|
|
213
|
-
{ cause: primaryFailure },
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
throw closeFailure;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (primaryFailure !== undefined) throw primaryFailure;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Append one complete JSONL record with one O_APPEND write of the full record.
|
|
225
|
-
* Shared-ledger contract: concurrent successful append-only producers cannot
|
|
226
|
-
* overwrite one another. A short write is an honest infrastructure failure
|
|
227
|
-
* (ADR 0049) — no non-append rollback/truncate. Close failure cannot mask the
|
|
228
|
-
* primary write cause; simultaneous close evidence is retained.
|
|
229
|
-
*/
|
|
230
|
-
export function appendAcceptedActivationFact(
|
|
231
|
-
ledgerPath: string,
|
|
232
|
-
fact: AcceptedActivationFact,
|
|
233
|
-
options: { ledgerHome: string },
|
|
234
|
-
): void {
|
|
235
|
-
appendActivationLedgerLine(
|
|
236
|
-
ledgerPath,
|
|
237
|
-
Buffer.from(serializeAcceptedActivationFact(fact), "utf8"),
|
|
238
|
-
{ ledgerHome: options.ledgerHome },
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export function appendAcceptedActivationToBook(options: {
|
|
243
|
-
ledgerHome: string;
|
|
244
|
-
fact: AcceptedActivationFact;
|
|
245
|
-
}): void {
|
|
246
|
-
appendAcceptedActivationFact(
|
|
247
|
-
activationWaitingLedgerPath(options.ledgerHome, options.fact.bookKey),
|
|
248
|
-
options.fact,
|
|
249
|
-
{ ledgerHome: options.ledgerHome },
|
|
250
|
-
);
|
|
251
|
-
}
|
package/src/analyst-entry.ts
CHANGED
|
@@ -22,9 +22,12 @@ import {
|
|
|
22
22
|
} from "./analyst-cohort.ts";
|
|
23
23
|
import {
|
|
24
24
|
scanAnalystIssueRuns,
|
|
25
|
+
type AnalystGhostLeg,
|
|
25
26
|
type AnalystReadableRunFacts,
|
|
26
27
|
type AnalystScopedRunScan,
|
|
27
28
|
} from "./analyst-ledger.ts";
|
|
29
|
+
|
|
30
|
+
export type { AnalystGhostLeg, AnalystGhostLeaseCheck } from "./analyst-ledger.ts";
|
|
28
31
|
import {
|
|
29
32
|
mergeAnalystLibraryIndexRows,
|
|
30
33
|
rowFromIssueMetricsPage,
|
|
@@ -173,6 +176,11 @@ export type AnalystIssueModeResult = {
|
|
|
173
176
|
readonly mode: "issue";
|
|
174
177
|
readonly page: AnalystIssueMetricsPage;
|
|
175
178
|
readonly pagePath: string;
|
|
179
|
+
/**
|
|
180
|
+
* #855 live observation: admitted|running runs whose writer lease cannot prove
|
|
181
|
+
* a live holder. Fresh each query — never cached on the metrics page.
|
|
182
|
+
*/
|
|
183
|
+
readonly ghostLegs: readonly AnalystGhostLeg[];
|
|
176
184
|
};
|
|
177
185
|
|
|
178
186
|
export type AnalystSweepModeResult = {
|
|
@@ -264,7 +272,16 @@ export async function readOrComputeAnalystIssuePage(
|
|
|
264
272
|
const raw = await readFile(pagePath, "utf8");
|
|
265
273
|
const page = JSON.parse(raw) as AnalystIssueMetricsPage;
|
|
266
274
|
if (cachedPageMatchesRequestedScope(page, { bookKey, ...input })) {
|
|
267
|
-
|
|
275
|
+
// #855 ghost legs are live observations — always fresh, never from the page cache.
|
|
276
|
+
const ghostScan = await scanAnalystIssueRuns({
|
|
277
|
+
bookKey,
|
|
278
|
+
...(issueNumber === undefined ? {} : { ticketNumber: issueNumber }),
|
|
279
|
+
...(options?.scanProjectRoot === undefined
|
|
280
|
+
? {}
|
|
281
|
+
: { projectRoot: options.scanProjectRoot }),
|
|
282
|
+
...(options?.home === undefined ? {} : { home: options.home }),
|
|
283
|
+
});
|
|
284
|
+
return { mode: "issue", page, pagePath, ghostLegs: ghostScan.ghostLegs };
|
|
268
285
|
}
|
|
269
286
|
// Existing page is for a different / absent ticket scope — same kernel recompute.
|
|
270
287
|
} catch (error) {
|
|
@@ -364,7 +381,7 @@ async function runAnalystIssueMode(
|
|
|
364
381
|
]);
|
|
365
382
|
}
|
|
366
383
|
|
|
367
|
-
return { mode: "issue", page, pagePath };
|
|
384
|
+
return { mode: "issue", page, pagePath, ghostLegs: scan.ghostLegs };
|
|
368
385
|
}
|
|
369
386
|
|
|
370
387
|
async function runAnalystSweepMode(
|
package/src/analyst-ledger.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
resolveActivationLedgerHome,
|
|
23
23
|
} from "./activation-ledger-topology.ts";
|
|
24
24
|
import { listBookRunDirectories } from "./role-run-placement.ts";
|
|
25
|
+
import { autopsyWriterLock } from "./public-cli/run-lifecycle.ts";
|
|
25
26
|
import { readRunTicketNumber } from "./run-ticket-number.ts";
|
|
26
27
|
import {
|
|
27
28
|
extractSessionModelSequence,
|
|
@@ -87,6 +88,64 @@ async function readExistingRunLifecycleState(
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* #855 lease check projection for ghost legs — index facts only (ADR 0049).
|
|
93
|
+
* Does not rename autopsy verdicts into stronger claims than observed.
|
|
94
|
+
*/
|
|
95
|
+
export type AnalystGhostLeaseCheck =
|
|
96
|
+
| { readonly kind: "holder-dead"; readonly pid: number }
|
|
97
|
+
| { readonly kind: "no-lease" }
|
|
98
|
+
| { readonly kind: "unverifiable"; readonly reason: "unreadable" | "unparseable" };
|
|
99
|
+
|
|
100
|
+
export type AnalystGhostLeg = {
|
|
101
|
+
readonly runId: string;
|
|
102
|
+
readonly runState: "admitted" | "running";
|
|
103
|
+
readonly leaseCheck: AnalystGhostLeaseCheck;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function ghostLeaseCheckFromAutopsy(
|
|
107
|
+
autopsy: Awaited<ReturnType<typeof autopsyWriterLock>>,
|
|
108
|
+
): AnalystGhostLeaseCheck | undefined {
|
|
109
|
+
switch (autopsy.verdict) {
|
|
110
|
+
case "alive":
|
|
111
|
+
return undefined;
|
|
112
|
+
case "dead":
|
|
113
|
+
return { kind: "holder-dead", pid: autopsy.pid };
|
|
114
|
+
case "absent":
|
|
115
|
+
return { kind: "no-lease" };
|
|
116
|
+
case "unknown":
|
|
117
|
+
return {
|
|
118
|
+
kind: "unverifiable",
|
|
119
|
+
reason: autopsy.reason === "unreadable" ? "unreadable" : "unparseable",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Classify one admitted|running run against writer lease liveness (#855).
|
|
126
|
+
* Alive holder → still in-flight (omit). Otherwise → ghost report fact only.
|
|
127
|
+
*/
|
|
128
|
+
async function classifyGhostCandidate(input: {
|
|
129
|
+
readonly runId: string;
|
|
130
|
+
readonly runState: "admitted" | "running";
|
|
131
|
+
readonly runDirectory: string;
|
|
132
|
+
}): Promise<
|
|
133
|
+
| { readonly kind: "live" }
|
|
134
|
+
| { readonly kind: "ghost"; readonly leg: AnalystGhostLeg }
|
|
135
|
+
> {
|
|
136
|
+
const autopsy = await autopsyWriterLock(join(input.runDirectory, "writer.lock"));
|
|
137
|
+
const leaseCheck = ghostLeaseCheckFromAutopsy(autopsy);
|
|
138
|
+
if (leaseCheck === undefined) return { kind: "live" };
|
|
139
|
+
return {
|
|
140
|
+
kind: "ghost",
|
|
141
|
+
leg: {
|
|
142
|
+
runId: input.runId,
|
|
143
|
+
runState: input.runState,
|
|
144
|
+
leaseCheck,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
90
149
|
function parseRunDirectoryName(
|
|
91
150
|
name: string,
|
|
92
151
|
): { runId: string; role: string } | undefined {
|
|
@@ -335,6 +394,8 @@ export type AnalystScopedRunScan = {
|
|
|
335
394
|
readonly unreadable: readonly AnalystUnreadableRun[];
|
|
336
395
|
/** C4: typed-ticket admits whose projectRoot mechanical key conflicted. */
|
|
337
396
|
readonly scopeConflicts: readonly AnalystScopeConflict[];
|
|
397
|
+
/** #855: admitted|running runs whose writer lease cannot prove a live holder. */
|
|
398
|
+
readonly ghostLegs: readonly AnalystGhostLeg[];
|
|
338
399
|
};
|
|
339
400
|
|
|
340
401
|
async function classifyScopedRun(input: {
|
|
@@ -343,9 +404,20 @@ async function classifyScopedRun(input: {
|
|
|
343
404
|
readonly role: string;
|
|
344
405
|
readonly runDirectory: string;
|
|
345
406
|
}): Promise<
|
|
346
|
-
| {
|
|
347
|
-
|
|
407
|
+
| {
|
|
408
|
+
readonly kind: "readable";
|
|
409
|
+
readonly facts: AnalystReadableRunFacts;
|
|
410
|
+
/** #855: present terminal still lists ghost when lease cannot prove live. */
|
|
411
|
+
readonly ghostLeg?: AnalystGhostLeg;
|
|
412
|
+
}
|
|
413
|
+
| {
|
|
414
|
+
readonly kind: "unreadable";
|
|
415
|
+
readonly entry: AnalystUnreadableRun;
|
|
416
|
+
/** #855: unreadable terminal still lists ghost when lease cannot prove live. */
|
|
417
|
+
readonly ghostLeg?: AnalystGhostLeg;
|
|
418
|
+
}
|
|
348
419
|
| { readonly kind: "live" }
|
|
420
|
+
| { readonly kind: "ghost"; readonly leg: AnalystGhostLeg }
|
|
349
421
|
> {
|
|
350
422
|
const missingSources: AnalystMissingSource[] = [];
|
|
351
423
|
const reasons: string[] = [];
|
|
@@ -434,13 +506,31 @@ async function classifyScopedRun(input: {
|
|
|
434
506
|
|
|
435
507
|
// 3) typed terminal artifact — absence is no-receipt only for terminal runs.
|
|
436
508
|
// Live admitted/running/resumable runs (existing run-state) are not dead legs.
|
|
509
|
+
// #855: writer-lease ghost check is independent of terminal artifact status.
|
|
510
|
+
// present → still emit metrics; unreadable → still enter missingSources;
|
|
511
|
+
// admitted|running + non-live lease → still list in ghostLegs (ticket #4).
|
|
512
|
+
const lifecycle = await readExistingRunLifecycleState(input.runDirectory);
|
|
513
|
+
let ghostLeg: AnalystGhostLeg | undefined;
|
|
514
|
+
if (lifecycle === "admitted" || lifecycle === "running") {
|
|
515
|
+
const ghost = await classifyGhostCandidate({
|
|
516
|
+
runId: input.runId,
|
|
517
|
+
runState: lifecycle,
|
|
518
|
+
runDirectory: input.runDirectory,
|
|
519
|
+
});
|
|
520
|
+
if (ghost.kind === "ghost") ghostLeg = ghost.leg;
|
|
521
|
+
}
|
|
522
|
+
|
|
437
523
|
try {
|
|
438
524
|
const artifact = await readRunTerminalArtifact(input.runDirectory);
|
|
439
525
|
if (artifact.status === "unreadable") {
|
|
440
526
|
missingSources.push("terminal-artifact");
|
|
441
527
|
reasons.push(`${artifact.file}: ${artifact.reason}`);
|
|
442
528
|
} else if (artifact.status === "absent") {
|
|
443
|
-
|
|
529
|
+
if (lifecycle === "admitted" || lifecycle === "running") {
|
|
530
|
+
// No terminal yet: ghost report only, or live in-flight omit.
|
|
531
|
+
if (ghostLeg !== undefined) return { kind: "ghost", leg: ghostLeg };
|
|
532
|
+
return { kind: "live" };
|
|
533
|
+
}
|
|
444
534
|
if (lifecycle !== undefined && LIVE_RUN_STATES.has(lifecycle)) {
|
|
445
535
|
return { kind: "live" };
|
|
446
536
|
}
|
|
@@ -479,6 +569,7 @@ async function classifyScopedRun(input: {
|
|
|
479
569
|
firstFrameAt,
|
|
480
570
|
lastFrameAt,
|
|
481
571
|
},
|
|
572
|
+
...(ghostLeg !== undefined ? { ghostLeg } : {}),
|
|
482
573
|
};
|
|
483
574
|
}
|
|
484
575
|
|
|
@@ -510,6 +601,7 @@ async function classifyScopedRun(input: {
|
|
|
510
601
|
firstFrameAt: { status: "present", at: frameSpan.startedAt },
|
|
511
602
|
lastFrameAt: { status: "present", at: frameSpan.endedAt },
|
|
512
603
|
},
|
|
604
|
+
...(ghostLeg !== undefined ? { ghostLeg } : {}),
|
|
513
605
|
};
|
|
514
606
|
}
|
|
515
607
|
|
|
@@ -525,6 +617,7 @@ async function classifyScopedRun(input: {
|
|
|
525
617
|
models,
|
|
526
618
|
gateCycles,
|
|
527
619
|
},
|
|
620
|
+
...(ghostLeg !== undefined ? { ghostLeg } : {}),
|
|
528
621
|
};
|
|
529
622
|
}
|
|
530
623
|
|
|
@@ -588,11 +681,12 @@ export async function scanAnalystIssueRuns(input: {
|
|
|
588
681
|
}
|
|
589
682
|
|
|
590
683
|
if (bookNames.length === 0) {
|
|
591
|
-
return { runs: [], unreadable: [], scopeConflicts: [] };
|
|
684
|
+
return { runs: [], unreadable: [], scopeConflicts: [], ghostLegs: [] };
|
|
592
685
|
}
|
|
593
686
|
|
|
594
687
|
const runs: AnalystReadableRunFacts[] = [];
|
|
595
688
|
const unreadable: AnalystUnreadableRun[] = [];
|
|
689
|
+
const ghostLegs: AnalystGhostLeg[] = [];
|
|
596
690
|
// scopeConflicts retained on the scan face for page envelope compat; book×ticket
|
|
597
691
|
// scope no longer emits projectRoot dual-key conflicts on the CLI path.
|
|
598
692
|
const scopeConflicts: AnalystScopeConflict[] = [];
|
|
@@ -638,9 +732,19 @@ export async function scanAnalystIssueRuns(input: {
|
|
|
638
732
|
role: parsed.role,
|
|
639
733
|
runDirectory,
|
|
640
734
|
});
|
|
641
|
-
if (classified.kind === "readable")
|
|
642
|
-
|
|
735
|
+
if (classified.kind === "readable") {
|
|
736
|
+
runs.push(classified.facts);
|
|
737
|
+
if (classified.ghostLeg !== undefined) ghostLegs.push(classified.ghostLeg);
|
|
738
|
+
} else if (classified.kind === "unreadable") {
|
|
739
|
+
unreadable.push(classified.entry);
|
|
740
|
+
if (classified.ghostLeg !== undefined) ghostLegs.push(classified.ghostLeg);
|
|
741
|
+
} else if (classified.kind === "ghost") {
|
|
742
|
+
ghostLegs.push(classified.leg);
|
|
743
|
+
}
|
|
643
744
|
// live in-flight runs are omitted from legs and unreadable (not failure/death).
|
|
745
|
+
// #855 ghost legs are reported separately — not counted as in-flight.
|
|
746
|
+
// Lease check is independent of terminal artifact: present/unreadable may
|
|
747
|
+
// still carry a ghostLeg alongside metrics or missingSources.
|
|
644
748
|
}
|
|
645
749
|
}
|
|
646
750
|
|
|
@@ -648,5 +752,6 @@ export async function scanAnalystIssueRuns(input: {
|
|
|
648
752
|
runs,
|
|
649
753
|
unreadable,
|
|
650
754
|
scopeConflicts,
|
|
755
|
+
ghostLegs,
|
|
651
756
|
};
|
|
652
757
|
}
|
|
@@ -83,7 +83,11 @@ export function headlessTurnArgs(options: {
|
|
|
83
83
|
readonly description: ClaudePrintHostDescription;
|
|
84
84
|
/** Absolute path written by the adapter; paired with `systemPromptFlag`. */
|
|
85
85
|
readonly systemPromptPath: string;
|
|
86
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Closed JSON Schema for structured_output seats.
|
|
88
|
+
* Optional: omit for prose-exit seats (#959 navigator) so the model may speak free text.
|
|
89
|
+
*/
|
|
90
|
+
readonly jsonSchema?: Readonly<Record<string, unknown>>;
|
|
87
91
|
/** Absolute path to host-native MCP config JSON; omitted when no AK MCP servers. */
|
|
88
92
|
readonly mcpConfigPath?: string;
|
|
89
93
|
readonly model?: string;
|
|
@@ -99,9 +103,10 @@ export function headlessTurnArgs(options: {
|
|
|
99
103
|
...description.fixedArgs,
|
|
100
104
|
description.systemPromptFlag,
|
|
101
105
|
options.systemPromptPath,
|
|
102
|
-
description.jsonSchemaFlag,
|
|
103
|
-
JSON.stringify(options.jsonSchema),
|
|
104
106
|
];
|
|
107
|
+
if (options.jsonSchema !== undefined) {
|
|
108
|
+
args.push(description.jsonSchemaFlag, JSON.stringify(options.jsonSchema));
|
|
109
|
+
}
|
|
105
110
|
if (options.pluginDir) args.push("--plugin-dir", options.pluginDir);
|
|
106
111
|
if (options.mcpConfigPath !== undefined && options.mcpConfigPath !== "") {
|
|
107
112
|
args.push(description.mcpConfigFlag, options.mcpConfigPath);
|
|
@@ -145,7 +150,7 @@ function codexTomlStringTable(entries: Readonly<Record<string, string>>): string
|
|
|
145
150
|
* Free-form JSON leaf for Type.Unknown under Codex strict transport.
|
|
146
151
|
* Strict rejects bare untyped nodes and root-level additionalProperties:true;
|
|
147
152
|
* a $defs anyOf of JSON values (with nested additionalProperties as $ref)
|
|
148
|
-
* is accepted and keeps array/object receipts expressible
|
|
153
|
+
* is accepted and keeps array/object receipts expressible.
|
|
149
154
|
* Package code still does not validate or reject the receipt against schema.
|
|
150
155
|
*/
|
|
151
156
|
const CODEX_JSON_VALUE_DEF = "codexJsonValue";
|
|
@@ -398,8 +403,11 @@ function codexMcpConfigArgs(
|
|
|
398
403
|
export function codexTurnArgs(options: {
|
|
399
404
|
/** Absolute path for `-c model_instructions_file=…`. */
|
|
400
405
|
readonly systemPromptPath: string;
|
|
401
|
-
/**
|
|
402
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Absolute path for `--output-schema` (closed transport schema).
|
|
408
|
+
* Optional: omit for prose-exit seats (#959 navigator) so agent_message stays free text.
|
|
409
|
+
*/
|
|
410
|
+
readonly outputSchemaPath?: string;
|
|
403
411
|
readonly mcpServers: readonly Readonly<Record<string, unknown>>[];
|
|
404
412
|
readonly model?: string;
|
|
405
413
|
readonly effort?: string;
|
|
@@ -441,7 +449,9 @@ export function codexTurnArgs(options: {
|
|
|
441
449
|
}
|
|
442
450
|
|
|
443
451
|
args.push("-c", `model_instructions_file=${codexTomlString(options.systemPromptPath)}`);
|
|
444
|
-
|
|
452
|
+
if (options.outputSchemaPath !== undefined) {
|
|
453
|
+
args.push("--output-schema", options.outputSchemaPath);
|
|
454
|
+
}
|
|
445
455
|
args.push(...codexMcpConfigArgs(options.mcpServers));
|
|
446
456
|
|
|
447
457
|
if (options.model !== undefined && options.model !== "") {
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
isPlainObject,
|
|
40
40
|
type HeadlessHostDescription,
|
|
41
41
|
} from "./description.ts";
|
|
42
|
+
import { NAVIGATOR_OUTPUT_TOOL_NAME } from "../package-contracts/navigator-output.ts";
|
|
42
43
|
|
|
43
44
|
export type HeadlessRoleTurnHostConfig = Readonly<{
|
|
44
45
|
description: HeadlessHostDescription;
|
|
@@ -406,7 +407,8 @@ function terminalFromSpawned(
|
|
|
406
407
|
function buildTurnArgs(options: {
|
|
407
408
|
readonly description: HeadlessHostDescription;
|
|
408
409
|
readonly systemPromptPath: string;
|
|
409
|
-
|
|
410
|
+
/** Closed schema; omit for #959 navigator prose-exit seats. */
|
|
411
|
+
readonly jsonSchema?: Readonly<Record<string, unknown>>;
|
|
410
412
|
readonly mcpServers: readonly Readonly<Record<string, unknown>>[];
|
|
411
413
|
readonly mcpConfigPath?: string;
|
|
412
414
|
readonly outputSchemaPath?: string;
|
|
@@ -422,10 +424,10 @@ function buildTurnArgs(options: {
|
|
|
422
424
|
if (options.sessionKind === "resume" && !options.sessionId) {
|
|
423
425
|
throw new Error("codex resume requires a bound thread_id");
|
|
424
426
|
}
|
|
425
|
-
|
|
427
|
+
// #959: prose-exit seats (navigator) omit --output-schema; other seats still require it.
|
|
426
428
|
return codexTurnArgs({
|
|
427
429
|
systemPromptPath: options.systemPromptPath,
|
|
428
|
-
outputSchemaPath: options.outputSchemaPath,
|
|
430
|
+
...(options.outputSchemaPath === undefined ? {} : { outputSchemaPath: options.outputSchemaPath }),
|
|
429
431
|
mcpServers: options.mcpServers,
|
|
430
432
|
...(options.model === undefined ? {} : { model: options.model }),
|
|
431
433
|
...(options.effort === undefined ? {} : { effort: options.effort }),
|
|
@@ -443,7 +445,8 @@ function buildTurnArgs(options: {
|
|
|
443
445
|
return headlessTurnArgs({
|
|
444
446
|
description: options.description,
|
|
445
447
|
systemPromptPath: options.systemPromptPath,
|
|
446
|
-
|
|
448
|
+
// #959: navigator omits --json-schema so free-form prose is a lawful exit.
|
|
449
|
+
...(options.jsonSchema === undefined ? {} : { jsonSchema: options.jsonSchema }),
|
|
447
450
|
mcpConfigPath: options.mcpConfigPath,
|
|
448
451
|
...(options.model === undefined ? {} : { model: options.model }),
|
|
449
452
|
...(options.effort === undefined ? {} : { effort: options.effort }),
|
|
@@ -482,12 +485,15 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
|
|
|
482
485
|
? (prompt) => applyCodexSkillInvocation(request.methods, prompt)
|
|
483
486
|
: (prompt) => prompt;
|
|
484
487
|
if (codex) {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
outputSchemaPath,
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
488
|
+
// #959: navigator is a prose-exit seat — no closed output-schema.
|
|
489
|
+
if (prepared.terminatingToolName !== NAVIGATOR_OUTPUT_TOOL_NAME) {
|
|
490
|
+
outputSchemaPath = join(request.runDirectory, "headless-output-schema.json");
|
|
491
|
+
await writeFile(
|
|
492
|
+
outputSchemaPath,
|
|
493
|
+
`${JSON.stringify(closeJsonSchemaForCodex(prepared.jsonSchema), null, 2)}\n`,
|
|
494
|
+
"utf8",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
491
497
|
} else {
|
|
492
498
|
mcpConfigPath = join(request.runDirectory, "headless-mcp-config.json");
|
|
493
499
|
await writeFile(
|
|
@@ -517,7 +523,10 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
|
|
|
517
523
|
args = buildTurnArgs({
|
|
518
524
|
description: config.description,
|
|
519
525
|
systemPromptPath,
|
|
520
|
-
|
|
526
|
+
// #959: navigator prose exit — no closed JSON schema on claude either.
|
|
527
|
+
...(prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME
|
|
528
|
+
? {}
|
|
529
|
+
: { jsonSchema: prepared.jsonSchema }),
|
|
521
530
|
mcpServers: prepared.mcpServers,
|
|
522
531
|
...(mcpConfigPath === undefined ? {} : { mcpConfigPath }),
|
|
523
532
|
...(outputSchemaPath === undefined ? {} : { outputSchemaPath }),
|
|
@@ -657,6 +666,20 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
|
|
|
657
666
|
});
|
|
658
667
|
}
|
|
659
668
|
|
|
669
|
+
// #959: navigator prose exit — final agent_message is presented as-is.
|
|
670
|
+
// Empty text is not a lawful accepted receipt (align pi no_receipt / loud empty).
|
|
671
|
+
if (prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME) {
|
|
672
|
+
if (observation.finalMessage.trim() === "") {
|
|
673
|
+
return terminalFromSpawned(spawned, {
|
|
674
|
+
cause: "output",
|
|
675
|
+
identity: { name: "HeadlessEmptyOutput", code: "empty-stdout" },
|
|
676
|
+
diagnostic: spawned.stderr.trim() || "codex exec produced empty agent_message",
|
|
677
|
+
details: { sessionId, exitCode: spawned.code },
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
await prepared.ingestStructuredOutput({ prose: observation.finalMessage });
|
|
681
|
+
return { status: "delivered", stderr: spawned.stderr };
|
|
682
|
+
}
|
|
660
683
|
let receipt: unknown;
|
|
661
684
|
try {
|
|
662
685
|
receipt = JSON.parse(observation.finalMessage);
|
|
@@ -716,6 +739,14 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
|
|
|
716
739
|
|
|
717
740
|
if (envelope.structured_output !== undefined) {
|
|
718
741
|
await prepared.ingestStructuredOutput(envelope.structured_output);
|
|
742
|
+
} else if (
|
|
743
|
+
prepared.terminatingToolName === NAVIGATOR_OUTPUT_TOOL_NAME
|
|
744
|
+
&& typeof envelope.result === "string"
|
|
745
|
+
&& envelope.result.trim() !== ""
|
|
746
|
+
) {
|
|
747
|
+
// #959: claude prose exit — free-form result text is the receipt body
|
|
748
|
+
// when structured_output is absent (json-schema omitted for navigator).
|
|
749
|
+
await prepared.ingestStructuredOutput({ prose: envelope.result });
|
|
719
750
|
}
|
|
720
751
|
return { status: "delivered", stderr: spawned.stderr };
|
|
721
752
|
},
|