@akagilnc/pi-workflow-roles 0.1.5212 → 0.1.5235
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 +1 -1
- package/dist/acp-host/production-host.js +580 -1031
- package/dist/engine-detour-usage.js +4 -16
- package/dist/headless-host/production-host.js +701 -1066
- package/dist/host-session-record.js +303 -0
- package/dist/migrate-book-topology.js +0 -1
- package/dist/pi/role-turn-host.js +1 -21
- package/dist/public-cli/main.js +153 -362
- package/dist/public-cli/post-admission.js +7 -43
- package/dist/sitian-appender.js +10 -146
- package/dist/ticket-provenance-contracts.js +4 -42
- package/dist/ticket-provenance.js +27 -456
- package/package.json +1 -1
- package/src/acp-host/role-turn-host.ts +36 -108
- package/src/diarist.ts +5 -5
- package/src/engine-detour-usage.ts +4 -16
- package/src/external-host-turn-loop.ts +2 -18
- package/src/headless-host/role-turn-host.ts +67 -31
- package/src/host-contracts.ts +0 -15
- package/src/host-session-record.ts +325 -25
- package/src/pi/role-turn-host.ts +1 -25
- package/src/public-cli/post-admission.ts +6 -51
- package/src/public-role-summons.ts +1 -2
- package/src/sitian-appender.ts +16 -191
- package/src/ticket-provenance-contracts.ts +10 -48
- package/src/ticket-provenance.ts +33 -499
- package/dist/host-transition-prior-native.js +0 -78
- package/src/host-transition-prior-native.ts +0 -80
- package/src/pi/pi-normalization.ts +0 -291
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { spawn } from "node:child_process";
|
|
2
3
|
import { createInterface } from "node:readline";
|
|
3
4
|
|
|
@@ -8,7 +9,10 @@ import {
|
|
|
8
9
|
raceAgainstHostAbort,
|
|
9
10
|
} from "../external-host-turn-loop.ts";
|
|
10
11
|
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
copyAndRecordHostDossier,
|
|
14
|
+
recordNativeSessionPointer,
|
|
15
|
+
} from "../host-session-record.ts";
|
|
12
16
|
import {
|
|
13
17
|
NAVIGATOR_OUTPUT_TOOL_NAME,
|
|
14
18
|
navigatorProseFromUnknown,
|
|
@@ -20,30 +24,6 @@ import {
|
|
|
20
24
|
} from "../prepared-role-turn.ts";
|
|
21
25
|
import { acpModelId, type AcpHostDescription } from "./description.ts";
|
|
22
26
|
|
|
23
|
-
/** #971: authorized host for usage + auto_compact ledgering only. */
|
|
24
|
-
const GROK_BUILD_HOST = "grok-build";
|
|
25
|
-
|
|
26
|
-
/** #971: grok-build prompt result carries host usage under `_meta.usage`. */
|
|
27
|
-
function acpPromptResultHasUsage(result: Readonly<Record<string, unknown>>): boolean {
|
|
28
|
-
const meta = result._meta;
|
|
29
|
-
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return false;
|
|
30
|
-
return Object.prototype.hasOwnProperty.call(meta, "usage");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* #971: ledger only the authorized vendor compaction notice on grok-build.
|
|
35
|
-
* Other `_x.ai/*` notifications stay out of host-session records.
|
|
36
|
-
*/
|
|
37
|
-
function isAcpAutoCompactCompletedNotification(
|
|
38
|
-
method: string,
|
|
39
|
-
params: Readonly<Record<string, unknown>>,
|
|
40
|
-
): boolean {
|
|
41
|
-
if (method !== "_x.ai/session_notification") return false;
|
|
42
|
-
const update = params.update;
|
|
43
|
-
if (typeof update !== "object" || update === null || Array.isArray(update)) return false;
|
|
44
|
-
return (update as { sessionUpdate?: unknown }).sessionUpdate === "auto_compact_completed";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
27
|
/**
|
|
48
28
|
* #959: collect free-form agent text from ACP session/update stream.
|
|
49
29
|
* Used only when the navigator seat spoke prose without calling the output tool.
|
|
@@ -278,7 +258,9 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
|
|
|
278
258
|
const systemPromptOverride = renderSystemPromptOverride(prepared.systemPrompt);
|
|
279
259
|
let connection: AcpConnection | undefined;
|
|
280
260
|
let sessionId: string | undefined;
|
|
261
|
+
let sessionOpened = false;
|
|
281
262
|
let accepted = false;
|
|
263
|
+
const sessionParent = config.sessionIdentity.resolveSessionFile(request.principal);
|
|
282
264
|
// Mutable so dispose failure can outrank a clean turn (headless withCleanupFailure face).
|
|
283
265
|
let outcome: RoleTurnResult = failure("session", "AcpNoOutcome", "no-outcome");
|
|
284
266
|
try {
|
|
@@ -286,66 +268,26 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
|
|
|
286
268
|
outcome = failure("activation", "UncontrolledAcpSession", "ak-config-missing");
|
|
287
269
|
} else {
|
|
288
270
|
connection = await config.connect(request);
|
|
289
|
-
// Live host-session records: ACP session/update → sitian sole entry (#811).
|
|
290
|
-
// One abort + one race helper + one outer projection — write failure ends
|
|
291
|
-
// any in-flight RPC as typed session infrastructure failure.
|
|
292
|
-
const sessionParent = config.sessionIdentity.resolveSessionFile(request.principal);
|
|
293
|
-
const recordAbort = new AbortController();
|
|
294
|
-
let hostSessionRecordFailure: RoleTurnKnownFailure | undefined;
|
|
295
|
-
const hostSessionRecordResult = (): RoleTurnResult => ({
|
|
296
|
-
code: null,
|
|
297
|
-
stderr: "",
|
|
298
|
-
timedOut: false,
|
|
299
|
-
knownFailure: hostSessionRecordFailure!,
|
|
300
|
-
});
|
|
301
|
-
const noteHostSessionRecordFailure = (error: unknown): void => {
|
|
302
|
-
if (hostSessionRecordFailure !== undefined) return;
|
|
303
|
-
hostSessionRecordFailure = {
|
|
304
|
-
cause: "session",
|
|
305
|
-
identity: { name: "HostSessionRecordFailure", code: "host-session-record-failed" },
|
|
306
|
-
diagnostic: error instanceof Error ? error.message : String(error),
|
|
307
|
-
};
|
|
308
|
-
try { recordAbort.abort(); } catch { /* already aborted */ }
|
|
309
|
-
};
|
|
310
271
|
const rpc = (
|
|
311
272
|
method: string,
|
|
312
273
|
params: Readonly<Record<string, unknown>>,
|
|
313
|
-
): Promise<Readonly<Record<string, unknown>>> =>
|
|
314
|
-
raceAgainstHostAbort(connection!.request(method, params), recordAbort.signal, "host-session-record-failed");
|
|
274
|
+
): Promise<Readonly<Record<string, unknown>>> => connection!.request(method, params);
|
|
315
275
|
|
|
316
276
|
// #959: navigator free-form agent text — only while session/prompt is in flight.
|
|
317
277
|
// session/load replays history via session/update; those must not enter the bucket
|
|
318
278
|
// (resume / set_model load would otherwise prepend prior turns as "this turn" prose).
|
|
319
279
|
const agentProseChunks: string[] = [];
|
|
320
280
|
let collectAgentProse = false;
|
|
321
|
-
// #971: usage + auto_compact are grok-build-only; same ACP adapter serves Hermes too.
|
|
322
|
-
const ledgerGrokBuildObservability = config.hostName === GROK_BUILD_HOST;
|
|
323
281
|
connection.onNotification?.((method, params) => {
|
|
324
|
-
if (hostSessionRecordFailure !== undefined) return;
|
|
325
282
|
const isSessionUpdate = method === "session/update";
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
// #971: session/update stays; auto_compact_completed is the only vendor notice.
|
|
329
|
-
if (!isSessionUpdate && !isAutoCompact) return;
|
|
330
|
-
if (isSessionUpdate && collectAgentProse) {
|
|
283
|
+
if (!isSessionUpdate) return;
|
|
284
|
+
if (collectAgentProse) {
|
|
331
285
|
const chunk = acpAgentTextChunk(params);
|
|
332
286
|
if (chunk !== undefined) agentProseChunks.push(chunk);
|
|
333
287
|
}
|
|
334
|
-
try {
|
|
335
|
-
reportHostSessionEvent({
|
|
336
|
-
host: config.hostName,
|
|
337
|
-
cwd: request.cwd,
|
|
338
|
-
sessionParent,
|
|
339
|
-
source: "acp-host",
|
|
340
|
-
event: { method, params },
|
|
341
|
-
});
|
|
342
|
-
} catch (error) {
|
|
343
|
-
noteHostSessionRecordFailure(error);
|
|
344
|
-
}
|
|
345
288
|
});
|
|
346
289
|
|
|
347
|
-
|
|
348
|
-
const initialized = await rpc("initialize", {
|
|
290
|
+
const initialized = await rpc("initialize", {
|
|
349
291
|
protocolVersion: 1,
|
|
350
292
|
clientCapabilities: {},
|
|
351
293
|
});
|
|
@@ -374,9 +316,13 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
|
|
|
374
316
|
sessionId: bindSessionId,
|
|
375
317
|
...sessionBindParams,
|
|
376
318
|
});
|
|
377
|
-
|
|
319
|
+
const loadedId = typeof loaded.sessionId === "string" && loaded.sessionId !== ""
|
|
378
320
|
? loaded.sessionId
|
|
379
321
|
: bindSessionId;
|
|
322
|
+
if (config.hostName !== "hermes") recordNativeSessionPointer({
|
|
323
|
+
host: config.hostName, sessionId: loadedId, cwd: request.cwd, sessionParent, home: request.home,
|
|
324
|
+
});
|
|
325
|
+
return loadedId;
|
|
380
326
|
};
|
|
381
327
|
if (request.continuation.kind === "resume" && config.boundResume === "session/load") {
|
|
382
328
|
const explicitHostSessionId = request.continuation.hostSessionId;
|
|
@@ -384,7 +330,9 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
|
|
|
384
330
|
? explicitHostSessionId
|
|
385
331
|
: await config.sessionIdentity.load(request.principal);
|
|
386
332
|
if (boundSessionId !== undefined && boundSessionId !== "") {
|
|
333
|
+
sessionId = boundSessionId;
|
|
387
334
|
sessionId = await loadSession(boundSessionId);
|
|
335
|
+
sessionOpened = true;
|
|
388
336
|
}
|
|
389
337
|
}
|
|
390
338
|
let sessionReady = true;
|
|
@@ -395,6 +343,10 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
|
|
|
395
343
|
outcome = failure("session", "AcpSessionFailure", "session-id-missing");
|
|
396
344
|
sessionReady = false;
|
|
397
345
|
} else {
|
|
346
|
+
if (config.hostName !== "hermes") recordNativeSessionPointer({
|
|
347
|
+
host: config.hostName, sessionId, cwd: request.cwd, sessionParent, home: request.home,
|
|
348
|
+
});
|
|
349
|
+
sessionOpened = true;
|
|
398
350
|
await config.sessionIdentity.bind(request.principal, sessionId);
|
|
399
351
|
}
|
|
400
352
|
}
|
|
@@ -415,12 +367,6 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
|
|
|
415
367
|
roundLimitName: "AcpRoundLimit",
|
|
416
368
|
currentSessionId: () => sessionId,
|
|
417
369
|
async runRound({ prompt, abortSignal }) {
|
|
418
|
-
if (hostSessionRecordFailure !== undefined) return { status: "terminal", result: hostSessionRecordResult() };
|
|
419
|
-
// Envelope infra abort, parent cancellation (#675), and host-session
|
|
420
|
-
// record abort (#811) share one race face for in-flight prompt.
|
|
421
|
-
const abortParts: AbortSignal[] = [recordAbort.signal];
|
|
422
|
-
if (abortSignal !== undefined) abortParts.push(abortSignal);
|
|
423
|
-
const combinedAbort = AbortSignal.any(abortParts);
|
|
424
370
|
let result: Readonly<Record<string, unknown>>;
|
|
425
371
|
// Open the prose gate only for this prompt round; clear any stale chunks first.
|
|
426
372
|
agentProseChunks.length = 0;
|
|
@@ -431,36 +377,15 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
|
|
|
431
377
|
sessionId: activeSessionId,
|
|
432
378
|
prompt: [{ type: "text", text: prompt }],
|
|
433
379
|
}),
|
|
434
|
-
|
|
380
|
+
abortSignal,
|
|
435
381
|
"ACP host aborted",
|
|
436
382
|
);
|
|
437
383
|
} catch (error) {
|
|
438
384
|
collectAgentProse = false;
|
|
439
385
|
agentProseChunks.length = 0;
|
|
440
|
-
if (hostSessionRecordFailure !== undefined) {
|
|
441
|
-
return { status: "terminal", result: hostSessionRecordResult() };
|
|
442
|
-
}
|
|
443
386
|
throw error;
|
|
444
387
|
}
|
|
445
388
|
collectAgentProse = false;
|
|
446
|
-
// #971: per-round grok-build usage is on the prompt JSON-RPC result, not a notice.
|
|
447
|
-
if (ledgerGrokBuildObservability && acpPromptResultHasUsage(result)) {
|
|
448
|
-
try {
|
|
449
|
-
reportHostSessionEvent({
|
|
450
|
-
host: config.hostName,
|
|
451
|
-
cwd: request.cwd,
|
|
452
|
-
sessionParent,
|
|
453
|
-
source: "acp-host",
|
|
454
|
-
event: { method: "session/prompt", result },
|
|
455
|
-
});
|
|
456
|
-
} catch (error) {
|
|
457
|
-
noteHostSessionRecordFailure(error);
|
|
458
|
-
return { status: "terminal", result: hostSessionRecordResult() };
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
if (hostSessionRecordFailure !== undefined) {
|
|
462
|
-
return { status: "terminal", result: hostSessionRecordResult() };
|
|
463
|
-
}
|
|
464
389
|
if (result.stopReason === "refusal") {
|
|
465
390
|
agentProseChunks.length = 0;
|
|
466
391
|
return {
|
|
@@ -487,18 +412,9 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
|
|
|
487
412
|
accepted = true;
|
|
488
413
|
},
|
|
489
414
|
});
|
|
490
|
-
outcome =
|
|
415
|
+
outcome = turnResult;
|
|
491
416
|
} // sessionReady
|
|
492
417
|
} // model match else
|
|
493
|
-
} catch (error) {
|
|
494
|
-
// recordAbort races setup RPCs via raceAgainstHostAbort (host-aborted code);
|
|
495
|
-
// noteHostSessionRecordFailure always sets the typed failure before aborting.
|
|
496
|
-
if (hostSessionRecordFailure !== undefined) {
|
|
497
|
-
outcome = hostSessionRecordResult();
|
|
498
|
-
} else {
|
|
499
|
-
throw error;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
418
|
} // mcpServers else
|
|
503
419
|
} finally {
|
|
504
420
|
if (connection !== undefined) {
|
|
@@ -509,6 +425,18 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
|
|
|
509
425
|
try { await connection.close(); }
|
|
510
426
|
catch { /* keep turn result */ }
|
|
511
427
|
}
|
|
428
|
+
if (config.hostName !== "hermes" && sessionOpened && sessionId !== undefined) {
|
|
429
|
+
copyAndRecordHostDossier({
|
|
430
|
+
host: config.hostName,
|
|
431
|
+
sessionId,
|
|
432
|
+
cwd: request.cwd,
|
|
433
|
+
sessionDirectory: join(request.runDirectory, "session"),
|
|
434
|
+
sessionParent,
|
|
435
|
+
continuation: request.continuation,
|
|
436
|
+
...(request.model !== undefined ? { model: request.model } : {}),
|
|
437
|
+
...(request.home !== undefined ? { home: request.home } : {}),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
512
440
|
try {
|
|
513
441
|
await prepared.dispose?.();
|
|
514
442
|
} catch (cleanupError) {
|
package/src/diarist.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 起居郎 volume commit — ADR 0075 `diarist-is-role` / #901.
|
|
3
|
-
* LLM submits bounds; mechanical layer
|
|
4
|
-
*
|
|
5
|
-
* (ADR 0018): the seat prepares, the role envelope commits.
|
|
2
|
+
* 起居郎 volume commit — ADR 0075 `diarist-is-role` / #901 / #1090.
|
|
3
|
+
* LLM submits bounds; mechanical layer appends a projection commit from
|
|
4
|
+
* session volumes (pure append, no cross-submit dedupe). No human view.
|
|
5
|
+
* No lifecycle here (ADR 0018): the seat prepares, the role envelope commits.
|
|
6
6
|
*/
|
|
7
7
|
import { reprojectTicketProvenance } from "./ticket-provenance.ts";
|
|
8
8
|
import type { TicketProvenanceSession } from "./ticket-provenance-contracts.ts";
|
|
@@ -19,7 +19,7 @@ export type DiaristCommitFacts = {
|
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
22
|
+
* Append a diary projection from LLM-submitted bounds (#1090).
|
|
23
23
|
* Shape projection of sessions is the caller's job (accept hook);
|
|
24
24
|
* this seam only copies source facts into the volume.
|
|
25
25
|
*/
|
|
@@ -222,7 +222,7 @@ export async function readEngineDetourToolUsage(input: {
|
|
|
222
222
|
});
|
|
223
223
|
|
|
224
224
|
const { records } = await readSitianRecords(recordFile);
|
|
225
|
-
const
|
|
225
|
+
const callsByToolCallId = new Map<string, EngineDetourCallFact>();
|
|
226
226
|
for (const record of records) {
|
|
227
227
|
if (record.kind !== ENGINE_DETOUR_CALL_KIND) continue;
|
|
228
228
|
const boundScope = invocationScopeIdOfRecord(record);
|
|
@@ -240,9 +240,11 @@ export async function readEngineDetourToolUsage(input: {
|
|
|
240
240
|
};
|
|
241
241
|
const fact = callFactFromSitianPayload(record.payload, pointer);
|
|
242
242
|
if (fact === undefined) continue;
|
|
243
|
-
|
|
243
|
+
// ADR 0086: Reader deduplicates by toolCallId within the invocation scope
|
|
244
|
+
callsByToolCallId.set(fact.toolCallId, fact);
|
|
244
245
|
}
|
|
245
246
|
|
|
247
|
+
const calls = Array.from(callsByToolCallId.values());
|
|
246
248
|
return { callCount: calls.length, calls };
|
|
247
249
|
}
|
|
248
250
|
|
|
@@ -300,20 +302,6 @@ export async function readInvocationEngineMounted(
|
|
|
300
302
|
return typeof raw.engine === "string" && raw.engine.trim() !== "";
|
|
301
303
|
}
|
|
302
304
|
|
|
303
|
-
/**
|
|
304
|
-
* Selected host from the admission invocation page — for post-admission
|
|
305
|
-
* host-transition classification only. In-turn tools take host from the shared
|
|
306
|
-
* Host envelope (RoleTurnRequest / HostContext), never this reader.
|
|
307
|
-
* Undefined when absent — callers must not invent "pi".
|
|
308
|
-
*/
|
|
309
|
-
export function readInvocationSelectedHost(runDirectory: string): string | undefined {
|
|
310
|
-
const raw = readInvocationRecord(runDirectory);
|
|
311
|
-
if (raw === undefined) return undefined;
|
|
312
|
-
return typeof raw.host === "string" && raw.host.trim() !== ""
|
|
313
|
-
? raw.host.trim()
|
|
314
|
-
: undefined;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
305
|
/** Merge usage into decisiveFacts without touching role payloads. */
|
|
318
306
|
export function withEngineDetourToolUsageFact<
|
|
319
307
|
T extends { readonly decisiveFacts?: Readonly<Record<string, unknown>> },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Middle external-host role-turn loop (#820 / ADR 0082).
|
|
3
|
-
* One copy: serial,
|
|
3
|
+
* One copy: serial, abort merge/race, 8-round closeRound retry,
|
|
4
4
|
* host-aborted, round-limit. Last hop = ExternalHostTurnDriver (four verbs).
|
|
5
5
|
*/
|
|
6
6
|
import type {
|
|
@@ -9,7 +9,6 @@ import type {
|
|
|
9
9
|
RoleTurnRequest,
|
|
10
10
|
RoleTurnResult,
|
|
11
11
|
} from "./host-contracts.ts";
|
|
12
|
-
import { isOfficerReviewSeat } from "./packaged-role-registry.ts";
|
|
13
12
|
|
|
14
13
|
export const EXTERNAL_ROLE_TURN_ROUND_LIMIT = 8 as const;
|
|
15
14
|
|
|
@@ -55,21 +54,6 @@ export function mergeRoleTurnAbortSignals(
|
|
|
55
54
|
return AbortSignal.any([prepared, request]);
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
/**
|
|
59
|
-
* Host-transition prior-native paths are machine handoff material (#617 DK-4).
|
|
60
|
-
* #879: station-child officer dialogue must not receive those paths spliced into
|
|
61
|
-
* the review prompt — officers already hold --source-run / binding pointers.
|
|
62
|
-
*/
|
|
63
|
-
export function promptWithPriorNativePaths(basePrompt: string, request: RoleTurnRequest): string {
|
|
64
|
-
if (request.continuation.kind !== "resume") return basePrompt;
|
|
65
|
-
if (request.stationChild === true && isOfficerReviewSeat(request.activation.role)) {
|
|
66
|
-
return basePrompt;
|
|
67
|
-
}
|
|
68
|
-
const paths = request.hostTransition?.priorNativePaths;
|
|
69
|
-
if (paths === undefined || paths.length === 0) return basePrompt;
|
|
70
|
-
return `${basePrompt}\n${paths.join("\n")}`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
57
|
export function isHostAbortedError(error: unknown): boolean {
|
|
74
58
|
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "host-aborted";
|
|
75
59
|
}
|
|
@@ -135,7 +119,7 @@ export async function driveExternalRoleTurnRounds(
|
|
|
135
119
|
request: RoleTurnRequest,
|
|
136
120
|
driver: ExternalHostTurnDriver,
|
|
137
121
|
): Promise<RoleTurnResult> {
|
|
138
|
-
let prompt =
|
|
122
|
+
let prompt = prepared.prompt;
|
|
139
123
|
const abortSignal = mergeRoleTurnAbortSignals(prepared.abortSignal, request.signal);
|
|
140
124
|
let stderr = "";
|
|
141
125
|
|
|
@@ -22,7 +22,10 @@ import {
|
|
|
22
22
|
type SessionIdentityAuthority,
|
|
23
23
|
} from "../prepared-role-turn.ts";
|
|
24
24
|
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
copyAndRecordHostDossier,
|
|
27
|
+
recordNativeSessionPointer,
|
|
28
|
+
} from "../host-session-record.ts";
|
|
26
29
|
import {
|
|
27
30
|
closeJsonSchemaForCodex,
|
|
28
31
|
codexTurnArgs,
|
|
@@ -261,6 +264,7 @@ function spawnHeadlessTurn(options: {
|
|
|
261
264
|
readonly stdin?: string;
|
|
262
265
|
/** Called for each complete stdout line as it arrives (live stream-json). */
|
|
263
266
|
readonly onStdoutLine?: (line: string) => void;
|
|
267
|
+
readonly onClose?: () => void;
|
|
264
268
|
}): Promise<{ code: number | null; stdout: string; stderr: string; timedOut: boolean }> {
|
|
265
269
|
return new Promise((resolve, reject) => {
|
|
266
270
|
if (options.signal?.aborted) {
|
|
@@ -325,8 +329,15 @@ function spawnHeadlessTurn(options: {
|
|
|
325
329
|
lineBuffer = "";
|
|
326
330
|
}
|
|
327
331
|
};
|
|
332
|
+
let aborted = false;
|
|
328
333
|
const settle = (code: number | null): void => {
|
|
329
334
|
if (settled) return;
|
|
335
|
+
if (aborted) {
|
|
336
|
+
settled = true;
|
|
337
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
338
|
+
reject(hostAbortedError("headless host aborted"));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
330
341
|
// Final flush first: onStdoutLine may failLine (reject + settled=true).
|
|
331
342
|
flushStdoutLines("", true);
|
|
332
343
|
if (settled) return;
|
|
@@ -340,11 +351,9 @@ function spawnHeadlessTurn(options: {
|
|
|
340
351
|
resolve({ code, stdout: resultStdout, stderr, timedOut });
|
|
341
352
|
};
|
|
342
353
|
const onAbort = (): void => {
|
|
343
|
-
child.kill("SIGTERM");
|
|
344
354
|
if (settled) return;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
reject(hostAbortedError("headless host aborted"));
|
|
355
|
+
aborted = true;
|
|
356
|
+
child.kill("SIGTERM");
|
|
348
357
|
};
|
|
349
358
|
child.stdout.setEncoding("utf8").on("data", (chunk: string) => { flushStdoutLines(chunk, false); });
|
|
350
359
|
child.stderr.setEncoding("utf8").on("data", (chunk: string) => {
|
|
@@ -357,7 +366,10 @@ function spawnHeadlessTurn(options: {
|
|
|
357
366
|
options.signal?.removeEventListener("abort", onAbort);
|
|
358
367
|
reject(error);
|
|
359
368
|
});
|
|
360
|
-
child.on("close", (code) =>
|
|
369
|
+
child.on("close", (code) => {
|
|
370
|
+
options.onClose?.();
|
|
371
|
+
settle(code);
|
|
372
|
+
});
|
|
361
373
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
362
374
|
if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
|
|
363
375
|
timer = setTimeout(() => {
|
|
@@ -498,6 +510,8 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
|
|
|
498
510
|
}
|
|
499
511
|
|
|
500
512
|
const sessionParent = config.sessionIdentity.resolveSessionFile(request.principal);
|
|
513
|
+
let exitedSessionId: string | undefined;
|
|
514
|
+
try {
|
|
501
515
|
outcome = await driveExternalRoleTurnRounds(prepared, request, {
|
|
502
516
|
roundLimitName: "HeadlessRoundLimit",
|
|
503
517
|
currentSessionId: () => sessionId,
|
|
@@ -532,9 +546,23 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
|
|
|
532
546
|
}
|
|
533
547
|
|
|
534
548
|
const codexObserver = codex ? createCodexExecTurnObserver() : undefined;
|
|
549
|
+
let pointerRecorded = false;
|
|
550
|
+
let codexIdObserved = false;
|
|
551
|
+
if (sessionId !== undefined && sessionId !== "") {
|
|
552
|
+
pointerRecorded = recordNativeSessionPointer({
|
|
553
|
+
host: config.hostName,
|
|
554
|
+
sessionId,
|
|
555
|
+
cwd: request.cwd,
|
|
556
|
+
sessionParent,
|
|
557
|
+
home: request.home,
|
|
558
|
+
}) !== undefined;
|
|
559
|
+
}
|
|
560
|
+
|
|
535
561
|
let spawned: { code: number | null; stdout: string; stderr: string; timedOut: boolean };
|
|
562
|
+
let childExited = false;
|
|
536
563
|
try {
|
|
537
564
|
spawned = await spawnHeadlessTurn({
|
|
565
|
+
onClose() { childExited = true; },
|
|
538
566
|
binary: config.binary,
|
|
539
567
|
args,
|
|
540
568
|
cwd: request.cwd,
|
|
@@ -554,16 +582,33 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
|
|
|
554
582
|
}
|
|
555
583
|
// One bounded live seam owns both recording and host-specific reduction.
|
|
556
584
|
codexObserver?.observe(event);
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
sessionParent,
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
585
|
+
if (!codex && typeof event === "object" && event !== null && "session_id" in event
|
|
586
|
+
&& typeof event.session_id === "string" && event.session_id !== "" && event.session_id !== sessionId) {
|
|
587
|
+
sessionId = event.session_id;
|
|
588
|
+
recordNativeSessionPointer({ host: config.hostName, sessionId, cwd: request.cwd, sessionParent, home: request.home });
|
|
589
|
+
}
|
|
590
|
+
if (codex && !codexIdObserved) {
|
|
591
|
+
const tid = codexObserver?.result().threadId;
|
|
592
|
+
if (tid !== undefined && tid !== "") {
|
|
593
|
+
codexIdObserved = true;
|
|
594
|
+
pointerRecorded = recordNativeSessionPointer({
|
|
595
|
+
host: config.hostName,
|
|
596
|
+
sessionId: tid,
|
|
597
|
+
cwd: request.cwd,
|
|
598
|
+
sessionParent,
|
|
599
|
+
home: request.home,
|
|
600
|
+
}) !== undefined;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
564
603
|
},
|
|
565
604
|
});
|
|
605
|
+
const roundSessionId = (codex ? codexObserver?.result().threadId : undefined) ?? sessionId;
|
|
606
|
+
exitedSessionId = roundSessionId;
|
|
607
|
+
if (codex && roundSessionId !== undefined && roundSessionId !== "" && !pointerRecorded) {
|
|
608
|
+
recordNativeSessionPointer({ host: config.hostName, sessionId: roundSessionId, cwd: request.cwd, sessionParent, home: request.home });
|
|
609
|
+
}
|
|
566
610
|
} catch (error) {
|
|
611
|
+
if (childExited) exitedSessionId = (codex ? codexObserver?.result().threadId : undefined) ?? sessionId;
|
|
567
612
|
if (isHostAbortedError(error)) throw error;
|
|
568
613
|
const message = error instanceof Error ? error.message : String(error);
|
|
569
614
|
const observedHostFailure = codexObserver?.result().failureDiagnostic;
|
|
@@ -577,24 +622,6 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
|
|
|
577
622
|
}, observedHostFailure),
|
|
578
623
|
};
|
|
579
624
|
}
|
|
580
|
-
// SitianInfrastructureError.knownCause is session; spawn errno stays activation.
|
|
581
|
-
const isRecordFailure =
|
|
582
|
-
typeof error === "object"
|
|
583
|
-
&& error !== null
|
|
584
|
-
&& ((error as { knownCause?: unknown }).knownCause === "session"
|
|
585
|
-
|| (error as { name?: unknown }).name === "SitianInfrastructureError");
|
|
586
|
-
if (isRecordFailure) {
|
|
587
|
-
return {
|
|
588
|
-
status: "terminal",
|
|
589
|
-
result: failure(
|
|
590
|
-
"session",
|
|
591
|
-
"HostSessionRecordFailure",
|
|
592
|
-
"host-session-record-failed",
|
|
593
|
-
{ diagnostic: message, sessionId },
|
|
594
|
-
message,
|
|
595
|
-
),
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
625
|
return {
|
|
599
626
|
status: "terminal",
|
|
600
627
|
result: failure("activation", "HeadlessSpawnFailure", "spawn-failed", {
|
|
@@ -773,6 +800,15 @@ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig):
|
|
|
773
800
|
return { status: "delivered", stderr: spawned.stderr };
|
|
774
801
|
},
|
|
775
802
|
});
|
|
803
|
+
} finally {
|
|
804
|
+
if (exitedSessionId !== undefined && exitedSessionId !== "") copyAndRecordHostDossier({
|
|
805
|
+
host: config.hostName, sessionId: exitedSessionId, cwd: request.cwd,
|
|
806
|
+
sessionDirectory: join(request.runDirectory, "session"), sessionParent,
|
|
807
|
+
continuation: request.continuation,
|
|
808
|
+
...(request.model !== undefined ? { model: request.model } : {}),
|
|
809
|
+
...(request.home !== undefined ? { home: request.home } : {}),
|
|
810
|
+
});
|
|
811
|
+
}
|
|
776
812
|
} finally {
|
|
777
813
|
try {
|
|
778
814
|
await prepared.dispose?.();
|
package/src/host-contracts.ts
CHANGED
|
@@ -158,19 +158,6 @@ export type RoleTurnModelConfig = {
|
|
|
158
158
|
readonly thinking?: string;
|
|
159
159
|
};
|
|
160
160
|
|
|
161
|
-
/**
|
|
162
|
-
* Typed cross-host resume handoff (#617 DK-4).
|
|
163
|
-
* Present only when post-admission projects a real host switch.
|
|
164
|
-
* priorNativeKind names the record family the paths belong to — Pi's own
|
|
165
|
-
* session file, or sitian run records (ADR 0077) — so a consuming adapter
|
|
166
|
-
* reads the handoff without knowing which host wrote it.
|
|
167
|
-
* Target host reads those files itself; projector never copies bytes.
|
|
168
|
-
*/
|
|
169
|
-
export type RoleTurnHostTransition = {
|
|
170
|
-
readonly priorNativeKind: "pi-native" | "sitian";
|
|
171
|
-
readonly priorNativePaths: readonly string[];
|
|
172
|
-
};
|
|
173
|
-
|
|
174
161
|
/** One main-session turn request over the host-neutral execution seam. */
|
|
175
162
|
export type RoleTurnRequest = {
|
|
176
163
|
readonly principal: DurablePrincipal;
|
|
@@ -193,8 +180,6 @@ export type RoleTurnRequest = {
|
|
|
193
180
|
* has no parent to observe and leaves it absent.
|
|
194
181
|
*/
|
|
195
182
|
readonly signal?: AbortSignal;
|
|
196
|
-
/** Set by post-admission only on a real host switch; never on same-host resume. */
|
|
197
|
-
readonly hostTransition?: RoleTurnHostTransition;
|
|
198
183
|
/**
|
|
199
184
|
* Court-turn attempt (#637 / #833): sole-final per attempt. Open court, summons,
|
|
200
185
|
* and resume-with-message set this; bare resume without an open court omits it.
|