@akagilnc/pi-workflow-roles 0.1.5303 → 0.1.5312

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.
Files changed (35) hide show
  1. package/dist/acp-host/description.js +0 -11
  2. package/dist/acp-host/production-host.js +577 -604
  3. package/dist/analyst-ledger.js +16 -1
  4. package/dist/analyst-metric-families/acceptance-success-rework.js +16 -15
  5. package/dist/analyst-metric-families/b2-frame-buckets-actions.js +3 -8
  6. package/dist/analyst-metric-families/gate-cycles.js +2 -8
  7. package/dist/analyst-metric-families/leg-wall-clock.js +4 -13
  8. package/dist/analyst-metric-families/round-timeline.js +1 -4
  9. package/dist/analyst-model-groups.js +1 -7
  10. package/dist/analyst-page.js +4 -13
  11. package/dist/headless-host/description.js +0 -11
  12. package/dist/headless-host/production-host.js +679 -718
  13. package/dist/host-descriptions.js +13 -0
  14. package/dist/prepared-role-turn.js +4 -0
  15. package/dist/public-cli/machine-method-skills.js +5 -5
  16. package/dist/public-cli/main.js +291 -313
  17. package/package.json +1 -1
  18. package/src/acp-host/description.ts +2 -12
  19. package/src/acp-host/production-host.ts +3 -2
  20. package/src/acp-host/role-turn-host.ts +7 -42
  21. package/src/analyst-ledger.ts +20 -1
  22. package/src/analyst-metric-families/acceptance-success-rework.ts +5 -15
  23. package/src/analyst-metric-families/b2-frame-buckets-actions.ts +3 -7
  24. package/src/analyst-metric-families/gate-cycles.ts +2 -8
  25. package/src/analyst-metric-families/leg-wall-clock.ts +4 -16
  26. package/src/analyst-metric-families/round-timeline.ts +1 -5
  27. package/src/analyst-model-groups.ts +1 -10
  28. package/src/analyst-page.ts +4 -16
  29. package/src/external-host-turn-loop.ts +36 -16
  30. package/src/headless-host/description.ts +3 -18
  31. package/src/headless-host/production-host.ts +3 -2
  32. package/src/headless-host/role-turn-host.ts +10 -68
  33. package/src/host-descriptions.ts +18 -0
  34. package/src/prepared-role-turn.ts +8 -0
  35. package/src/public-cli/machine-method-skills.ts +5 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.5303",
3
+ "version": "0.1.5312",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -4,13 +4,10 @@
4
4
  * optional seat-profile soul — is data here; the lifecycle in role-turn-host.ts
5
5
  * stays one copy (#732).
6
6
  */
7
- import { join } from "node:path";
8
-
7
+ import type { HostIdentityDescription } from "../host-descriptions.ts";
9
8
  import type { SeatProfileSoul } from "./seat-profile-soul.ts";
10
9
 
11
- export type AcpHostDescription = Readonly<{
12
- /** Binary path segments relative to the operator home. */
13
- binaryFromHome: readonly string[];
10
+ export type AcpHostDescription = HostIdentityDescription & Readonly<{
14
11
  argv: Readonly<{
15
12
  prefix: readonly string[];
16
13
  suffix: readonly string[];
@@ -26,8 +23,6 @@ export type AcpHostDescription = Readonly<{
26
23
  * `provider:model` (hermes).
27
24
  */
28
25
  modelPassing: "argv" | "set_model";
29
- /** Durable ACP binding filename written beside the session principal. */
30
- sessionBindingFile: string;
31
26
  /**
32
27
  * When set, the production factory ensures a seat profile whose SOUL.md is a
33
28
  * symlink to the packaged role soul, and prefixes argv with `flag <name>`.
@@ -36,11 +31,6 @@ export type AcpHostDescription = Readonly<{
36
31
  seatProfileSoul?: SeatProfileSoul;
37
32
  }>;
38
33
 
39
- /** Absolute agent binary for one operator home. */
40
- export function resolveAcpBinary(description: AcpHostDescription, operatorHome: string): string {
41
- return join(operatorHome, ...description.binaryFromHome);
42
- }
43
-
44
34
  /** Stdio argv: optional profile flag, thinking flag, prefix, model, suffix. */
45
35
  export function acpStdioArgs(
46
36
  description: AcpHostDescription,
@@ -9,11 +9,12 @@
9
9
  import { randomUUID } from "node:crypto";
10
10
 
11
11
  import type { DurablePrincipalAuthority, RoleTurnHost, RoleTurnRequest } from "../host-contracts.ts";
12
+ import { resolveHostBinary } from "../host-descriptions.ts";
12
13
  import { prepareRoleEnvelope } from "../role-envelope.ts";
13
14
  import type { RoleRuntimeDependencies } from "../role-runtime.ts";
14
15
  import { createRoleRuntimeDependencies } from "../role-runtime-dependencies.ts";
15
16
  import { createSessionIdentityAuthority } from "../session-identity.ts";
16
- import { acpStdioArgs, resolveAcpBinary, type AcpHostDescription } from "./description.ts";
17
+ import { acpStdioArgs, type AcpHostDescription } from "./description.ts";
17
18
  import {
18
19
  connectAcpStdio,
19
20
  createAcpRoleTurnHost,
@@ -74,7 +75,7 @@ export function createProductionAcpRoleTurnHost(options: ProductionAcpHostOption
74
75
  role: request.activation.role,
75
76
  });
76
77
  return connectAcpStdio({
77
- binary: resolveAcpBinary(description, request.home),
78
+ binary: resolveHostBinary(description, request.home),
78
79
  args: acpStdioArgs(
79
80
  description,
80
81
  request.model,
@@ -2,11 +2,13 @@ import { join } from "node:path";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createInterface } from "node:readline";
4
4
 
5
- import type { RoleTurnHost, RoleTurnKnownFailure, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
5
+ import type { RoleTurnHost, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
6
6
  import {
7
7
  createSerializedRoleTurnHost,
8
8
  driveExternalRoleTurnRounds,
9
+ externalHostFailure as failure,
9
10
  raceAgainstHostAbort,
11
+ withExternalHostCleanupFailure,
10
12
  } from "../external-host-turn-loop.ts";
11
13
 
12
14
  import {
@@ -19,6 +21,7 @@ import {
19
21
  } from "../package-contracts/navigator-output.ts";
20
22
  import {
21
23
  renderSystemPromptOverride,
24
+ resolveBoundHostSessionId,
22
25
  type PreparedRoleTurn,
23
26
  type SessionIdentityAuthority,
24
27
  } from "../prepared-role-turn.ts";
@@ -96,41 +99,6 @@ export type AcpRoleTurnHostConfig = Readonly<{
96
99
  prepare(request: RoleTurnRequest): Promise<PreparedRoleTurn>;
97
100
  }>;
98
101
 
99
- function failure(
100
- cause: "activation" | "session" | "output",
101
- name: string,
102
- code: string,
103
- details?: Readonly<Record<string, unknown>>,
104
- diagnostic?: string,
105
- ): RoleTurnResult {
106
- return {
107
- code: null,
108
- stderr: "",
109
- timedOut: false,
110
- knownFailure: {
111
- cause,
112
- identity: { name, code },
113
- ...(diagnostic === undefined ? {} : { diagnostic }),
114
- ...(details === undefined ? {} : { details }),
115
- },
116
- };
117
- }
118
-
119
- /** Success→dispose failure; existing failure keeps primary cause + cleanup detail. */
120
- function withCleanupFailure(outcome: RoleTurnResult, cleanupError: unknown): RoleTurnResult {
121
- const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
122
- if (outcome.knownFailure === undefined) {
123
- return failure("session", "AcpDisposeFailure", "dispose-failed", { cleanupError: message }, message);
124
- }
125
- return {
126
- ...outcome,
127
- knownFailure: {
128
- ...outcome.knownFailure,
129
- details: { ...(outcome.knownFailure.details ?? {}), cleanupError: message },
130
- },
131
- };
132
- }
133
-
134
102
  type RpcReply = { readonly id?: unknown; readonly method?: unknown; readonly params?: unknown; readonly result?: unknown; readonly error?: unknown };
135
103
 
136
104
  function acpError(code: string, message: string, cause?: unknown): Error & { readonly code: string } {
@@ -259,7 +227,7 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
259
227
  let sessionOpened = false;
260
228
  let accepted = false;
261
229
  const sessionParent = config.sessionIdentity.resolveSessionFile(request.principal);
262
- // Mutable so dispose failure can outrank a clean turn (headless withCleanupFailure face).
230
+ // Mutable so dispose failure can outrank a clean turn.
263
231
  let outcome: RoleTurnResult = failure("session", "AcpNoOutcome", "no-outcome");
264
232
  try {
265
233
  if (prepared.mcpServers.length === 0) {
@@ -324,10 +292,7 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
324
292
  };
325
293
  let sessionReady = true;
326
294
  if (request.continuation.kind === "resume") {
327
- const explicitHostSessionId = request.continuation.hostSessionId;
328
- const boundSessionId = explicitHostSessionId !== undefined && explicitHostSessionId !== ""
329
- ? explicitHostSessionId
330
- : await config.sessionIdentity.load(request.principal);
295
+ const boundSessionId = await resolveBoundHostSessionId(request, config.sessionIdentity);
331
296
  if (boundSessionId !== undefined && boundSessionId !== "") {
332
297
  sessionId = boundSessionId;
333
298
  sessionId = await loadSession(boundSessionId);
@@ -446,7 +411,7 @@ export function createAcpRoleTurnHost(config: AcpRoleTurnHostConfig): RoleTurnHo
446
411
  try {
447
412
  await prepared.dispose?.();
448
413
  } catch (cleanupError) {
449
- outcome = withCleanupFailure(outcome, cleanupError);
414
+ outcome = withExternalHostCleanupFailure(outcome, cleanupError, "AcpDisposeFailure");
450
415
  }
451
416
  }
452
417
  return outcome;
@@ -319,7 +319,7 @@ function admitFrameSpanFromRows(rows: readonly LedgerSessionRow[]): {
319
319
  }
320
320
  return {
321
321
  models,
322
- frameSpan: { startedAt: span.startedAt, endedAt: span.endedAt },
322
+ frameSpan: { startedAt: span.startedAt, endedAt: span.endedAt, wallMs: endedMs - startedMs },
323
323
  partialFirstFrameAt: { status: "present", at: span.startedAt },
324
324
  partialLastFrameAt: { status: "present", at: span.endedAt },
325
325
  };
@@ -329,6 +329,8 @@ function admitFrameSpanFromRows(rows: readonly LedgerSessionRow[]): {
329
329
  export type AnalystRunFrameSpan = {
330
330
  readonly startedAt: string;
331
331
  readonly endedAt: string;
332
+ /** Validated wall clock, computed once when the session span is admitted. */
333
+ readonly wallMs: number;
332
334
  };
333
335
 
334
336
  /**
@@ -371,6 +373,23 @@ export type AnalystReadableRunFacts = {
371
373
  readonly gateCycles: readonly AnalystGateCycleRound[];
372
374
  };
373
375
 
376
+ /** Total wall clock for exactly the readable runs supplied by the caller. */
377
+ export function sumAnalystRunWallMs(runs: readonly AnalystReadableRunFacts[]): number {
378
+ let total = 0;
379
+ for (const run of runs) total += run.frameSpan.wallMs;
380
+ return total;
381
+ }
382
+
383
+ /** Shared identity order for readable legs and their page/metric projections. */
384
+ export function compareAnalystLegs(
385
+ a: Pick<AnalystReadableRunFacts, "book" | "role" | "runId">,
386
+ b: Pick<AnalystReadableRunFacts, "book" | "role" | "runId">,
387
+ ): number {
388
+ if (a.book !== b.book) return a.book.localeCompare(b.book);
389
+ if (a.role !== b.role) return a.role.localeCompare(b.role);
390
+ return a.runId.localeCompare(b.runId);
391
+ }
392
+
374
393
  export type AnalystScopedRunScan = {
375
394
  /** Readable in-scope runs with retained typed facts (A2). */
376
395
  readonly runs: readonly AnalystReadableRunFacts[];
@@ -9,7 +9,7 @@
9
9
  * - success rate den = success-eligible accepted legs (no-receipt out; planned out)
10
10
  * - planned = plan-duty acceptance; never success numerator or denominator
11
11
  */
12
- import type { AnalystReadableRunFacts, AnalystRunTerminalFace } from "../analyst-ledger.ts";
12
+ import { compareAnalystLegs, sumAnalystRunWallMs, type AnalystReadableRunFacts, type AnalystRunTerminalFace } from "../analyst-ledger.ts";
13
13
  import { medianNumber } from "../analyst-median.ts";
14
14
  import type { AnalystMetricFamilyModule } from "../analyst-metric-family.ts";
15
15
  import { packagedAnalystTerminal } from "../packaged-role-registry.ts";
@@ -99,10 +99,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
99
99
  return typeof value === "object" && value !== null && !Array.isArray(value);
100
100
  }
101
101
 
102
- function wallMsFromSpan(span: { readonly startedAt: string; readonly endedAt: string }): number {
103
- return Date.parse(span.endedAt) - Date.parse(span.startedAt);
104
- }
105
-
106
102
  /** Locate collector groups array: receipt.groups or top-level groups. */
107
103
  function findCollectorGroups(body: Record<string, unknown>): unknown {
108
104
  if (Array.isArray(body.groups)) return body.groups;
@@ -235,7 +231,7 @@ function projectLegs(runs: readonly AnalystReadableRunFacts[]): AnalystAcceptanc
235
231
  book: run.book,
236
232
  role: run.role,
237
233
  startedAt: run.frameSpan.startedAt,
238
- wallMs: wallMsFromSpan(run.frameSpan),
234
+ wallMs: run.frameSpan.wallMs,
239
235
  terminalLabel: mapped.terminalLabel,
240
236
  accepted: mapped.accepted,
241
237
  success: mapped.success,
@@ -247,11 +243,7 @@ function projectLegs(runs: readonly AnalystReadableRunFacts[]): AnalystAcceptanc
247
243
  }
248
244
 
249
245
  // Stable page order: book, role, runId (match A1 leg sort).
250
- return legs.sort((a, b) => {
251
- if (a.book !== b.book) return a.book.localeCompare(b.book);
252
- if (a.role !== b.role) return a.role.localeCompare(b.role);
253
- return a.runId.localeCompare(b.runId);
254
- });
246
+ return legs.sort(compareAnalystLegs);
255
247
  }
256
248
 
257
249
  function aggregateByRole(legs: readonly AnalystAcceptanceLeg[]): AnalystRoleAcceptanceStats[] {
@@ -302,12 +294,10 @@ function aggregateByRole(legs: readonly AnalystAcceptanceLeg[]): AnalystRoleAcce
302
294
  });
303
295
  }
304
296
 
305
- function reworkLens(legs: readonly AnalystAcceptanceLeg[]): AnalystReworkLens {
297
+ function reworkLens(legs: readonly AnalystAcceptanceLeg[], totalWallMs: number): AnalystReworkLens {
306
298
  let reworkWallMs = 0;
307
- let totalWallMs = 0;
308
299
  let reworkLegCount = 0;
309
300
  for (const leg of legs) {
310
- totalWallMs += leg.wallMs;
311
301
  if (leg.rework) {
312
302
  reworkWallMs += leg.wallMs;
313
303
  reworkLegCount += 1;
@@ -331,7 +321,7 @@ export function buildAcceptanceSuccessReworkSection(
331
321
  kind: "analyst-acceptance-success-rework",
332
322
  legs,
333
323
  byRole: aggregateByRole(legs),
334
- rework: reworkLens(legs),
324
+ rework: reworkLens(legs, sumAnalystRunWallMs(runs)),
335
325
  };
336
326
  }
337
327
 
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import type { SessionToolInterval } from "../ledger-session-read.ts";
18
18
  import { medianNumber } from "../analyst-median.ts";
19
- import type { AnalystReadableRunFacts } from "../analyst-ledger.ts";
19
+ import { compareAnalystLegs, type AnalystReadableRunFacts } from "../analyst-ledger.ts";
20
20
  import type { AnalystMetricFamilyModule } from "../analyst-metric-family.ts";
21
21
 
22
22
  export type AnalystB2ToolAction = {
@@ -221,7 +221,7 @@ export function computeAnalystB2RunMetrics(
221
221
  ): AnalystB2RunMetrics {
222
222
  const frameStartMs = timestampMs(facts.frameSpan.startedAt);
223
223
  const frameEndMs = timestampMs(facts.frameSpan.endedAt);
224
- const wallMs = Math.max(0, frameEndMs - frameStartMs);
224
+ const wallMs = facts.frameSpan.wallMs;
225
225
 
226
226
  // Single frame-bounded core: clip → union once → bucket/complement/actions.
227
227
  const tools = clipToolsToFrame(closedTools(facts.toolIntervals), frameStartMs, frameEndMs);
@@ -258,11 +258,7 @@ const b2FrameBucketsActionsFamily: AnalystMetricFamilyModule = {
258
258
  if (input.runs.length === 0) return undefined;
259
259
  const runs = [...input.runs]
260
260
  .map(computeAnalystB2RunMetrics)
261
- .sort((a, b) => {
262
- if (a.book !== b.book) return a.book.localeCompare(b.book);
263
- if (a.role !== b.role) return a.role.localeCompare(b.role);
264
- return a.runId.localeCompare(b.runId);
265
- });
261
+ .sort(compareAnalystLegs);
266
262
  const section: AnalystB2FrameBucketsActionsSection = {
267
263
  kind: "analyst-b2-frame-buckets-actions",
268
264
  runs,
@@ -9,7 +9,7 @@ import type {
9
9
  AnalystGateCycleOrigin,
10
10
  AnalystGateCycleRound,
11
11
  } from "../analyst-gate-cycles-read.ts";
12
- import type { AnalystReadableRunFacts } from "../analyst-ledger.ts";
12
+ import { compareAnalystLegs, type AnalystReadableRunFacts } from "../analyst-ledger.ts";
13
13
  import type { AnalystMetricFamilyModule } from "../analyst-metric-family.ts";
14
14
 
15
15
  export type AnalystGateCyclesRoundRow = {
@@ -73,12 +73,6 @@ function projectLeg(facts: AnalystReadableRunFacts): AnalystGateCyclesLeg {
73
73
  };
74
74
  }
75
75
 
76
- function compareLegs(a: AnalystGateCyclesLeg, b: AnalystGateCyclesLeg): number {
77
- if (a.book !== b.book) return a.book.localeCompare(b.book);
78
- if (a.role !== b.role) return a.role.localeCompare(b.role);
79
- return a.runId.localeCompare(b.runId);
80
- }
81
-
82
76
  type OfficerAccum = {
83
77
  rounds: number;
84
78
  bounceCount: number;
@@ -132,7 +126,7 @@ const gateCyclesFamily: AnalystMetricFamilyModule = {
132
126
  id: "gate-cycles",
133
127
  contribute(input) {
134
128
  if (input.runs.length === 0) return undefined;
135
- const legs = input.runs.map(projectLeg).sort(compareLegs);
129
+ const legs = input.runs.map(projectLeg).sort(compareAnalystLegs);
136
130
  const section: AnalystGateCyclesSection = {
137
131
  kind: "analyst-gate-cycles",
138
132
  legs,
@@ -6,7 +6,7 @@
6
6
  * Registers by file drop under analyst-metric-families/ (A2 discovery).
7
7
  */
8
8
  import { medianNumber } from "../analyst-median.ts";
9
- import type { AnalystReadableRunFacts } from "../analyst-ledger.ts";
9
+ import { compareAnalystLegs, sumAnalystRunWallMs, type AnalystReadableRunFacts } from "../analyst-ledger.ts";
10
10
  import type { AnalystMetricFamilyModule } from "../analyst-metric-family.ts";
11
11
 
12
12
  /** One readable leg's session-frame wall clock (first usable → last usable). */
@@ -31,19 +31,12 @@ export type AnalystLegWallClockSection = {
31
31
  readonly totalElapsedMs: number;
32
32
  };
33
33
 
34
- function frameSpanWallMs(span: {
35
- readonly startedAt: string;
36
- readonly endedAt: string;
37
- }): number {
38
- return Date.parse(span.endedAt) - Date.parse(span.startedAt);
39
- }
40
-
41
34
  function projectEntry(facts: AnalystReadableRunFacts): AnalystLegWallClockEntry {
42
35
  return {
43
36
  runId: facts.runId,
44
37
  book: facts.book,
45
38
  role: facts.role,
46
- wallMs: frameSpanWallMs(facts.frameSpan),
39
+ wallMs: facts.frameSpan.wallMs,
47
40
  };
48
41
  }
49
42
 
@@ -52,9 +45,7 @@ function compareRankingDesc(
52
45
  b: AnalystLegWallClockEntry,
53
46
  ): number {
54
47
  if (b.wallMs !== a.wallMs) return b.wallMs - a.wallMs;
55
- if (a.book !== b.book) return a.book.localeCompare(b.book);
56
- if (a.role !== b.role) return a.role.localeCompare(b.role);
57
- return a.runId.localeCompare(b.runId);
48
+ return compareAnalystLegs(a, b);
58
49
  }
59
50
 
60
51
  /** Discovered by analyst-metric-families loader (default export). */
@@ -74,14 +65,11 @@ const legWallClockFamily: AnalystMetricFamilyModule = {
74
65
  return undefined;
75
66
  }
76
67
 
77
- let totalElapsedMs = 0;
78
- for (const wallMs of walls) totalElapsedMs += wallMs;
79
-
80
68
  const section: AnalystLegWallClockSection = {
81
69
  kind: "analyst-leg-wall-clock",
82
70
  ranking,
83
71
  medianWallMs,
84
- totalElapsedMs,
72
+ totalElapsedMs: sumAnalystRunWallMs(input.runs),
85
73
  };
86
74
  return { legWallClock: section };
87
75
  },
@@ -70,10 +70,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
70
70
  return typeof value === "object" && value !== null && !Array.isArray(value);
71
71
  }
72
72
 
73
- function wallMsFromSpan(startedAt: string, endedAt: string): number {
74
- return Date.parse(endedAt) - Date.parse(startedAt);
75
- }
76
-
77
73
  function readClassCount(body: Record<string, unknown>): number | undefined {
78
74
  if (!isRecord(body.outcome)) return undefined;
79
75
  if (!isRecord(body.outcome.decisiveFacts)) return undefined;
@@ -113,7 +109,7 @@ function projectRunRow(facts: AnalystReadableRunFacts): AnalystRoundTimelineRunR
113
109
  role: facts.role,
114
110
  startedAt,
115
111
  endedAt,
116
- wallMs: wallMsFromSpan(startedAt, endedAt),
112
+ wallMs: facts.frameSpan.wallMs,
117
113
  terminal: projectTerminal(facts),
118
114
  };
119
115
  }
@@ -11,7 +11,7 @@
11
11
  import { buildAcceptanceSuccessReworkSection } from "./analyst-metric-families/acceptance-success-rework.ts";
12
12
  import type { AnalystReadableRunFacts } from "./analyst-ledger.ts";
13
13
  import { medianNumber } from "./analyst-median.ts";
14
- import type { AnalystUnreadableRun } from "./analyst-page.ts";
14
+ import { sortUnreadable, type AnalystUnreadableRun } from "./analyst-page.ts";
15
15
 
16
16
  /** One raw model group with rates over legs in that group. */
17
17
  export type AnalystModelGroupRow = {
@@ -74,15 +74,6 @@ function displayNameFor(
74
74
  return aliased === undefined ? rawGroupKey : aliased;
75
75
  }
76
76
 
77
- function sortUnreadable(
78
- unreadable: readonly AnalystUnreadableRun[],
79
- ): AnalystUnreadableRun[] {
80
- return [...unreadable].sort((a, b) => {
81
- if (a.book !== b.book) return a.book.localeCompare(b.book);
82
- return a.runId.localeCompare(b.runId);
83
- });
84
- }
85
-
86
77
  /**
87
78
  * Legs with no usable session model identity cannot join any model group.
88
79
  * Reuse the typed unreadable path (locatable missing-source) — not an empty-key group.
@@ -15,7 +15,7 @@ import {
15
15
  ensureRealDirectoryTree,
16
16
  physicalPathIdentity,
17
17
  } from "./activation-ledger-topology.ts";
18
- import type { AnalystReadableRunFacts } from "./analyst-ledger.ts";
18
+ import { compareAnalystLegs, sumAnalystRunWallMs, type AnalystReadableRunFacts } from "./analyst-ledger.ts";
19
19
  import { loadAnalystIssueMetricFamilies } from "./analyst-metric-families.ts";
20
20
  import { composeAnalystMetricFamilySections } from "./analyst-metric-family.ts";
21
21
 
@@ -187,14 +187,10 @@ export function analystIssuePageAddressFromPage(
187
187
  }
188
188
 
189
189
  function sortLegs(legs: readonly AnalystLegEntry[]): AnalystLegEntry[] {
190
- return [...legs].sort((a, b) => {
191
- if (a.book !== b.book) return a.book.localeCompare(b.book);
192
- if (a.role !== b.role) return a.role.localeCompare(b.role);
193
- return a.runId.localeCompare(b.runId);
194
- });
190
+ return [...legs].sort(compareAnalystLegs);
195
191
  }
196
192
 
197
- function sortUnreadable(
193
+ export function sortUnreadable(
198
194
  unreadable: readonly AnalystUnreadableRun[],
199
195
  ): AnalystUnreadableRun[] {
200
196
  return [...unreadable].sort((a, b) => {
@@ -260,13 +256,6 @@ export function computeAnalystMsPerKLines(
260
256
  };
261
257
  }
262
258
 
263
- function frameSpanWallMs(span: {
264
- readonly startedAt: string;
265
- readonly endedAt: string;
266
- }): number {
267
- return Date.parse(span.endedAt) - Date.parse(span.startedAt);
268
- }
269
-
270
259
  /**
271
260
  * Σ readable wall clocks + max end-frame across ALL runs (C1 efficiency / index).
272
261
  * Unreadable runs never enter totalElapsedMs; their available lastFrameAt still
@@ -279,10 +268,9 @@ export function summarizeAnalystRunEfficiency(
279
268
  readonly totalElapsedMs: number;
280
269
  readonly lastActivityAt: AnalystOptionalTimestamp;
281
270
  } {
282
- let totalElapsedMs = 0;
271
+ const totalElapsedMs = sumAnalystRunWallMs(runs);
283
272
  let latestEndedAt: string | undefined;
284
273
  for (const run of runs) {
285
- totalElapsedMs += frameSpanWallMs(run.frameSpan);
286
274
  const endedAt = run.frameSpan.endedAt;
287
275
  if (latestEndedAt === undefined || endedAt > latestEndedAt) {
288
276
  latestEndedAt = endedAt;
@@ -9,25 +9,11 @@ import type {
9
9
  RoleTurnRequest,
10
10
  RoleTurnResult,
11
11
  } from "./host-contracts.ts";
12
+ import type { PreparedRoleTurn } from "./prepared-role-turn.ts";
12
13
 
13
14
  export const EXTERNAL_ROLE_TURN_ROUND_LIMIT = 8 as const;
14
15
 
15
- export type ExternalPreparedTurn = Readonly<{
16
- readonly prompt: string;
17
- readonly abortSignal?: AbortSignal;
18
- closeRound(): Promise<
19
- | { readonly accepted: true }
20
- | {
21
- readonly accepted: false;
22
- readonly retry: {
23
- readonly code: string;
24
- readonly toolCallIds: readonly string[];
25
- readonly message: string;
26
- };
27
- }
28
- | { readonly accepted: false; readonly failure: RoleTurnKnownFailure }
29
- >;
30
- }>;
16
+ export type ExternalPreparedTurn = Pick<PreparedRoleTurn, "prompt" | "abortSignal" | "closeRound">;
31
17
 
32
18
  export type ExternalHostRoundOutcome =
33
19
  | { readonly status: "delivered"; readonly stderr?: string }
@@ -96,6 +82,40 @@ export function raceAgainstHostAbort<T>(
96
82
  });
97
83
  }
98
84
 
85
+ export function externalHostFailure(
86
+ cause: NonNullable<RoleTurnKnownFailure["cause"]>,
87
+ name: string,
88
+ code: string,
89
+ details?: Readonly<Record<string, unknown>>,
90
+ diagnostic?: string,
91
+ ): RoleTurnResult {
92
+ return asFailure({
93
+ cause,
94
+ identity: { name, code },
95
+ ...(diagnostic === undefined ? {} : { diagnostic }),
96
+ ...(details === undefined ? {} : { details }),
97
+ });
98
+ }
99
+
100
+ /** A dispose failure replaces success; a primary failure retains its cause. */
101
+ export function withExternalHostCleanupFailure(
102
+ outcome: RoleTurnResult,
103
+ cleanupError: unknown,
104
+ name: string,
105
+ ): RoleTurnResult {
106
+ const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
107
+ if (outcome.knownFailure === undefined) {
108
+ return externalHostFailure("session", name, "dispose-failed", { cleanupError: message }, message);
109
+ }
110
+ return {
111
+ ...outcome,
112
+ knownFailure: {
113
+ ...outcome.knownFailure,
114
+ details: { ...(outcome.knownFailure.details ?? {}), cleanupError: message },
115
+ },
116
+ };
117
+ }
118
+
99
119
  function asFailure(knownFailure: RoleTurnKnownFailure, stderr = ""): RoleTurnResult {
100
120
  return { code: null, stderr, timedOut: false, knownFailure };
101
121
  }
@@ -4,17 +4,10 @@
4
4
  * Claude print-mode and codex exec differ enough that #752 host-specific
5
5
  * assembly lives here as sibling helpers — not a third unified abstraction.
6
6
  */
7
- import { join } from "node:path";
8
-
9
- type HeadlessHostBase = Readonly<{
10
- /** Binary path segments relative to the operator home. */
11
- binaryFromHome: readonly string[];
12
- /** Durable session-id binding filename beside the session principal. */
13
- sessionBindingFile: string;
14
- }>;
7
+ import type { HostIdentityDescription } from "../host-descriptions.ts";
15
8
 
16
9
  /** Claude Code print-mode (#645). */
17
- export type ClaudePrintHostDescription = HeadlessHostBase & Readonly<{
10
+ export type ClaudePrintHostDescription = HostIdentityDescription & Readonly<{
18
11
  protocol: "claude-print";
19
12
  /**
20
13
  * Host-native print-mode flags that never change per turn (no prompt).
@@ -48,7 +41,7 @@ export type ClaudePrintHostDescription = HeadlessHostBase & Readonly<{
48
41
  * Protocol differences (JSONL, resume subcommand, schema file, `-c` MCP) stay
49
42
  * in codex-specific helpers — description only carries identity + binary path.
50
43
  */
51
- export type CodexExecHostDescription = HeadlessHostBase & Readonly<{
44
+ export type CodexExecHostDescription = HostIdentityDescription & Readonly<{
52
45
  protocol: "codex-exec";
53
46
  }>;
54
47
 
@@ -66,14 +59,6 @@ export function isCodexExecDescription(
66
59
  return description.protocol === "codex-exec";
67
60
  }
68
61
 
69
- /** Absolute agent binary for one operator home. */
70
- export function resolveHeadlessBinary(
71
- description: HeadlessHostDescription,
72
- operatorHome: string,
73
- ): string {
74
- return join(operatorHome, ...description.binaryFromHome);
75
- }
76
-
77
62
  /**
78
63
  * Build one Claude print-mode argv for a single process turn.
79
64
  * Shape: `<promptFlag> <fixedArgs…> <system/schema/mcp/model/effort/session…>`.
@@ -12,10 +12,11 @@
12
12
  import { randomUUID } from "node:crypto";
13
13
 
14
14
  import type { DurablePrincipalAuthority, RoleTurnHost } from "../host-contracts.ts";
15
+ import { resolveHostBinary } from "../host-descriptions.ts";
15
16
  import { createRoleRuntimeDependencies } from "../role-runtime-dependencies.ts";
16
17
  import { prepareRoleEnvelope } from "../role-envelope.ts";
17
18
  import { createSessionIdentityAuthority } from "../session-identity.ts";
18
- import { resolveHeadlessBinary, type HeadlessHostDescription } from "./description.ts";
19
+ import type { HeadlessHostDescription } from "./description.ts";
19
20
  import { createHeadlessRoleTurnHost } from "./role-turn-host.ts";
20
21
 
21
22
  export type ProductionHeadlessHostOptions = Readonly<{
@@ -46,7 +47,7 @@ export function createProductionHeadlessRoleTurnHost(
46
47
  description,
47
48
  hostName,
48
49
  sessionIdentity,
49
- binary: resolveHeadlessBinary(description, operatorHome),
50
+ binary: resolveHostBinary(description, operatorHome),
50
51
  env: {
51
52
  ...process.env,
52
53
  AK_PACKAGE_ROOT: packageRoot,