@akagilnc/pi-workflow-roles 0.1.5216 → 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.
@@ -69,10 +69,8 @@ function isStationChildOfficerDialogue(
69
69
  }
70
70
  import {
71
71
  mintEngineDetourInvocationScope,
72
- readInvocationSelectedHost,
73
72
  withEngineDetourInvocationScope,
74
73
  } from "../engine-detour-usage.ts";
75
- import { projectHostTransitionPriorNative } from "../host-transition-prior-native.ts";
76
74
  import type { CredentialProviders, SeatModelConfig } from "./config.ts";
77
75
  import {
78
76
  clearCurrentCourt,
@@ -92,6 +90,7 @@ import {
92
90
  type CatchableProcessSignal,
93
91
  } from "./process-cancel.ts";
94
92
  import { homeFromRunDirectory } from "../activation-ledger-topology.ts";
93
+ import { recordRunStart } from "../host-session-record.ts";
95
94
  import {
96
95
  attemptProducedFreshSubmission,
97
96
  classifyPostAdmissionFailure,
@@ -808,53 +807,11 @@ export async function dispatchPostAdmissionTurn<
808
807
  try {
809
808
  // #987: do not pre-block or classify host dispatch from AK credential
810
809
  // catalog facts. Per-invocation runner/host evidence owns provider identity.
811
- // #617 DK-4: capture previous invocation host before markRunRunning overwrites it.
812
- // Single authority projectHostTransitionPriorNative classifies the prior native volume.
813
- // Same-run resume (#637) keeps host identity on the run's invocation page.
814
- let previousHost: string | undefined;
815
- const liveHost = env.host;
816
- const principalCoordinates =
817
- admitted.principal === undefined
818
- ? undefined
819
- : env.principalAuthority.decode(admitted.principal);
820
- let hostTransition: RoleTurnRequest["hostTransition"];
821
- try {
822
- previousHost = readInvocationSelectedHost(admitted.runDirectory);
823
- hostTransition =
824
- previousHost !== undefined && liveHost !== undefined && principalCoordinates !== undefined
825
- ? await projectHostTransitionPriorNative({
826
- previousHost,
827
- liveHost,
828
- piSessionFile: principalCoordinates.sessionFile,
829
- })
830
- : undefined;
831
- } catch (error) {
832
- // prior-native IO is on the public one-shot path — controlled failure, not bare throw.
833
- return {
834
- ...(await presentControlledFailure(
835
- admitted,
836
- withEngineDetourInvocationScope({
837
- timedOut: false,
838
- code: null,
839
- stderr: "",
840
- thrown: error,
841
- }, request.invocationScopeId),
842
- adapters,
843
- env.principalAuthority,
844
- io,
845
- persistRunState,
846
- )) as { exitCode: number; admitted: A; terminal: T },
847
- ...deferredPersist,
848
- };
849
- }
850
-
851
810
  await clearTypedProviderHttpObservation(admitted.runDirectory);
852
811
  // The authoritative host write (markRunRunning) is delayed to just before
853
812
  // executeTurn — not merely past beforeDispatch (#840 r9 判词 class 2). Any
854
- // pre-turn retry (beforeDispatch, dossier projection, continuation
855
- // assembly) must still see the prior invocation host on its next attempt;
856
- // committing env.host earlier would make readInvocationSelectedHost read back the
857
- // new host on the retry and silently drop hostTransition.
813
+ // pre-turn retry (beforeDispatch, continuation assembly) must not commit a host
814
+ // identity until the turn is ready.
858
815
  // Failures settle the run (presentControlledFailure); they must not leave it
859
816
  // permanently running. Call-local auto-resume retries this hook until it
860
817
  // succeeds; only an exhausted nested station child skips the parent loop
@@ -903,13 +860,10 @@ export async function dispatchPostAdmissionTurn<
903
860
  if (env.stationChild !== undefined) {
904
861
  turnRequest = { ...turnRequest, stationChild: env.stationChild };
905
862
  }
906
- if (hostTransition !== undefined) {
907
- turnRequest = { ...turnRequest, hostTransition };
908
- }
909
863
  // Selected host axis rides the shared Host envelope for in-turn tools
910
864
  // (detour usage ledger) — never a pre-spawn invocation.json reread.
911
- if (typeof liveHost === "string" && liveHost.trim() !== "") {
912
- turnRequest = { ...turnRequest, host: liveHost.trim() };
865
+ if (typeof env.host === "string" && env.host.trim() !== "") {
866
+ turnRequest = { ...turnRequest, host: env.host.trim() };
913
867
  }
914
868
  // 0081 non-dialogue face: freeze pointer section under run/attachments/.
915
869
  // Seat consumes via loadCaseDossierReadingMaterial → existing agent-start
@@ -939,6 +893,7 @@ export async function dispatchPostAdmissionTurn<
939
893
 
940
894
  let result: RoleTurnResult;
941
895
  try {
896
+ recordRunStart(admitted.runDirectory);
942
897
  result = await env.roleTurnHost.executeTurn(turnRequest);
943
898
  } catch (error) {
944
899
  const processCancelName = processCancelSignalName(env.signal);
@@ -796,8 +796,7 @@ export async function summonGateOfficer(options: {
796
796
  // Hard path resolve: fail loud — never fall through to packageMachineHome (#604 / #675).
797
797
  home = homeFromRunDirectory(options.sourceRunDirectory);
798
798
  }
799
- // Officer host is seat-owned only (#821). Parent invocation.json.host stays a
800
- // hostTransition consumer in post-admission — not an officer override channel.
799
+ // Officer host is seat-owned only (#821), not a parent override channel.
801
800
  // #879: binding pointer = parent run directory; dialogue content = submission body.
802
801
  // Conclusion re-ask keeps sole ownership of reviewReask when present.
803
802
  let gateReviewInstruction: string | undefined;
@@ -1,16 +1,10 @@
1
1
  /**
2
- * Sitian Appender kernel (ADR 0065).
2
+ * Sitian Appender kernel (ADR 0065 / ADR 0086).
3
3
  * Computes destination automatically from ledger topology without destination parameters.
4
- * Owns volume open, torn-tail recovery, entry-level idempotency, and commit boundary.
4
+ * Log4j-style append-only record sink: appends one row, no deduplication, no read-back, no torn-tail repair.
5
5
  */
6
- import { createHash, randomUUID } from "node:crypto";
7
- import {
8
- appendFileSync,
9
- existsSync,
10
- readFileSync,
11
- unlinkSync,
12
- writeFileSync,
13
- } from "node:fs";
6
+ import { randomUUID } from "node:crypto";
7
+ import { appendFileSync } from "node:fs";
14
8
  import { dirname, join } from "node:path";
15
9
 
16
10
  import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
@@ -29,59 +23,13 @@ import {
29
23
  type SitianRecordInput,
30
24
  } from "./sitian-contracts.ts";
31
25
 
32
- function isRecord(value: unknown): value is Record<string, unknown> {
33
- return typeof value === "object" && value !== null && !Array.isArray(value);
34
- }
35
-
36
- function errorCodeOf(error: unknown): unknown {
37
- return (error as { code?: unknown }).code;
38
- }
39
-
40
- function identityClaimPath(recordFile: string, identity: string): string {
41
- const digest = createHash("sha256").update(identity, "utf8").digest("hex");
42
- return `${recordFile}.id-${digest}`;
43
- }
44
-
45
- /**
46
- * Exclusive create at `path` exactly once across processes.
47
- * Reuses the package's existing `wx` / O_EXCL zero-content occupancy primitive
48
- * (same shape as activation-ledger-session / archivist-record-entry).
49
- */
50
- function createExclusiveFile(path: string, contents: string): void {
51
- writeFileSync(path, contents, { encoding: "utf8", flag: "wx" });
52
- }
53
-
54
- function sealTornTail(recordFile: string): void {
55
- if (!existsSync(recordFile)) return;
56
- const buffer = readFileSync(recordFile);
57
- if (buffer.length > 0 && buffer[buffer.length - 1] !== 0x0a) {
58
- appendFileSync(recordFile, "\n", "utf8");
59
- }
60
- }
61
-
62
26
  /** Assign already-recorded lines, preserving their original text and malformed lines. */
63
27
  export function appendSitianRecordBlock(input: SitianRecordInput, block: string): void {
64
28
  if (block === "") return;
65
29
  try {
66
30
  const { sessionDir, recordFile, ledgerHome } = resolveSitianRecordPath(input);
67
31
  ensureRealDirectoryTree(ledgerHome, sessionDir);
68
- appendFileSync(recordFile, "", "utf8");
69
- sealTornTail(recordFile);
70
- const identityOf = (line: string): string | undefined => {
71
- try {
72
- const row: unknown = JSON.parse(line);
73
- return isRecord(row) && typeof row.identity === "string" ? row.identity : undefined;
74
- } catch {
75
- return undefined;
76
- }
77
- };
78
- const identities = new Set(readFileSync(recordFile, "utf8").split("\n").map(identityOf).filter((id): id is string => id !== undefined));
79
- for (const line of block.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
80
- const identity = identityOf(line);
81
- if (identity !== undefined && identities.has(identity)) continue;
82
- appendFileSync(recordFile, line, "utf8");
83
- if (identity !== undefined) identities.add(identity);
84
- }
32
+ appendFileSync(recordFile, block, "utf8");
85
33
  } catch (error) {
86
34
  if (error instanceof SitianInfrastructureError) throw error;
87
35
  throw new SitianInfrastructureError(
@@ -91,134 +39,6 @@ export function appendSitianRecordBlock(input: SitianRecordInput, block: string)
91
39
  }
92
40
  }
93
41
 
94
- function findIdentityPointer(
95
- recordFile: string,
96
- identity: string,
97
- kind: string,
98
- level: SitianRecord["level"],
99
- ): RecordPointer | undefined {
100
- if (!existsSync(recordFile)) return undefined;
101
- const text = readFileSync(recordFile, "utf8");
102
- for (const line of text.split("\n")) {
103
- const trimmed = line.trim();
104
- if (!trimmed) continue;
105
- try {
106
- const parsed = JSON.parse(trimmed);
107
- if (isRecord(parsed) && parsed.identity === identity) {
108
- return { identity, recordFile, kind, level };
109
- }
110
- } catch {
111
- // Malformed lines (including substate b preserved bad lines) are ignored during self-check
112
- }
113
- }
114
- return undefined;
115
- }
116
-
117
- function unlinkAbsentOk(path: string): Error | undefined {
118
- try {
119
- unlinkSync(path);
120
- return undefined;
121
- } catch (error) {
122
- if (errorCodeOf(error) === "ENOENT") return undefined;
123
- return error instanceof Error ? error : new Error(errorText(error));
124
- }
125
- }
126
-
127
- /**
128
- * Same-identity uniqueness under normal concurrent writers.
129
- *
130
- * Exclusive per-identity claim (`wx`) makes check+append atomic for one
131
- * identity. The winner appends under the claim and always releases it on both
132
- * normal and throwing exits. A loser that already sees the published row
133
- * returns that pointer; a loser that hits claim EEXIST without a published row
134
- * fails as SitianInfrastructureError (knownCause session). Crash residue can
135
- * leave that one identity blocked — identity-scoped, never a volume lock. No
136
- * wait loop, write-ahead recovery, PID reclaim, or compare-and-unlink.
137
- */
138
- function appendWithIdentityClaim(
139
- recordFile: string,
140
- record: SitianRecord,
141
- row: string,
142
- ): RecordPointer {
143
- sealTornTail(recordFile);
144
- const claimPath = identityClaimPath(recordFile, record.identity);
145
- const existing = findIdentityPointer(
146
- recordFile,
147
- record.identity,
148
- record.kind,
149
- record.level,
150
- );
151
- if (existing !== undefined) {
152
- // Best-effort: drop leftover claim after a prior crash-after-append.
153
- unlinkAbsentOk(claimPath);
154
- return existing;
155
- }
156
-
157
- try {
158
- createExclusiveFile(claimPath, "");
159
- } catch (error) {
160
- if (errorCodeOf(error) !== "EEXIST") throw error;
161
- sealTornTail(recordFile);
162
- const published = findIdentityPointer(
163
- recordFile,
164
- record.identity,
165
- record.kind,
166
- record.level,
167
- );
168
- if (published !== undefined) {
169
- unlinkAbsentOk(claimPath);
170
- return published;
171
- }
172
- throw new SitianInfrastructureError(
173
- `Sitian identity claim at ${claimPath} already exists for identity ${record.identity}`,
174
- { cause: error },
175
- );
176
- }
177
-
178
- let primaryFailure: unknown;
179
- let result: RecordPointer | undefined;
180
- let cleanupFailure: Error | undefined;
181
- try {
182
- sealTornTail(recordFile);
183
- const raced = findIdentityPointer(
184
- recordFile,
185
- record.identity,
186
- record.kind,
187
- record.level,
188
- );
189
- if (raced !== undefined) {
190
- result = raced;
191
- } else {
192
- appendFileSync(recordFile, row, "utf8");
193
- result = {
194
- identity: record.identity,
195
- recordFile,
196
- kind: record.kind,
197
- level: record.level,
198
- };
199
- }
200
- } catch (error) {
201
- primaryFailure = error;
202
- } finally {
203
- cleanupFailure = unlinkAbsentOk(claimPath);
204
- }
205
-
206
- if (primaryFailure !== undefined) {
207
- if (primaryFailure instanceof SitianInfrastructureError) throw primaryFailure;
208
- throw new SitianInfrastructureError(
209
- `Sitian appender persistence failure: ${errorText(primaryFailure)}`,
210
- { cause: primaryFailure },
211
- );
212
- }
213
- if (cleanupFailure !== undefined) {
214
- throw new SitianInfrastructureError(
215
- `Sitian identity claim at ${claimPath} could not be released after append for identity ${record.identity}: ${errorText(cleanupFailure)}`,
216
- { cause: cleanupFailure },
217
- );
218
- }
219
- return result as RecordPointer;
220
- }
221
-
222
42
  /** Authorized S4 submission ledger kinds that share a common run submission volume. */
223
43
  export const S4_SUBMISSION_LEDGER_KINDS = new Set([
224
44
  "candidate",
@@ -331,11 +151,10 @@ export function resolveSitianRecordPath(input: SitianRecordInput): SitianRecordP
331
151
  }
332
152
 
333
153
  /**
334
- * Appends a canonical record to its self-computed volume under the Sitian contract.
335
- * - Idempotency: checks volume by deterministic canonical identity; returns existing pointer on hit.
336
- * - Torn-tail recovery: checks file tail; un-terminated trailing bytes are sealed with a newline and re-parsed.
337
- * Substate a (valid JSON): committed on recovery, returns existing pointer.
338
- * Substate b (malformed): preserved as bad line, check misses, appends new row.
154
+ * Appends a canonical record to its self-computed volume under the Sitian contract (ADR 0086).
155
+ * - Log4j-style append-only record sink: O(1) append, unconditionally writes a new line.
156
+ * - Zero read-back, zero per-append identity check / idempotency deduplication.
157
+ * - Zero torn-tail repair.
339
158
  * - Commit point: full JSON string ending with newline.
340
159
  */
341
160
  export function appendSitianRecord(input: SitianRecordInput): RecordPointer {
@@ -362,7 +181,13 @@ export function appendSitianRecord(input: SitianRecordInput): RecordPointer {
362
181
  };
363
182
 
364
183
  const row = `${JSON.stringify(record)}\n`;
365
- return appendWithIdentityClaim(recordFile, record, row);
184
+ appendFileSync(recordFile, row, "utf8");
185
+ return {
186
+ identity: record.identity,
187
+ recordFile,
188
+ kind: record.kind,
189
+ level: record.level,
190
+ };
366
191
  } catch (error) {
367
192
  if (error instanceof SitianInfrastructureError) throw error;
368
193
  throw new SitianInfrastructureError(
@@ -1,78 +0,0 @@
1
- /**
2
- * Single authority for #617 DK-4 cross-host prior-native projection.
3
- * Classifies the prior volume into the two record families that exist
4
- * (Pi native session file / sitian run records), never by host name.
5
- */
6
- import { access, readdir } from "node:fs/promises";
7
- import { dirname, join } from "node:path";
8
- function isEnoent(error) {
9
- return typeof error === "object" && error !== null && error.code === "ENOENT";
10
- }
11
- /** Present Pi native session path, or empty when ENOENT. */
12
- async function listPiNativeRecordPaths(sessionFile) {
13
- try {
14
- await access(sessionFile);
15
- return [sessionFile];
16
- }
17
- catch (error) {
18
- if (isEnoent(error))
19
- return [];
20
- throw error;
21
- }
22
- }
23
- /**
24
- * Present sitian records.jsonl paths under sessionParent topology (#717).
25
- * resolveSitianRecordPathInLedger writes dirname(sessionParent)/<category>/records.jsonl
26
- * when sessionParent is inside ledger home — never session.jsonl itself.
27
- */
28
- async function listSitianRecordPaths(sessionParent) {
29
- const sessionRoot = dirname(sessionParent);
30
- let entries;
31
- try {
32
- entries = await readdir(sessionRoot, { withFileTypes: true });
33
- }
34
- catch (error) {
35
- if (isEnoent(error))
36
- return [];
37
- throw error;
38
- }
39
- const recordPaths = [];
40
- for (const entry of entries) {
41
- if (!entry.isDirectory())
42
- continue;
43
- const recordFile = join(sessionRoot, entry.name, "records.jsonl");
44
- try {
45
- await access(recordFile);
46
- recordPaths.push(recordFile);
47
- }
48
- catch (error) {
49
- if (!isEnoent(error))
50
- throw error;
51
- }
52
- }
53
- recordPaths.sort();
54
- return recordPaths;
55
- }
56
- /**
57
- * Project one hostTransition only for a real host switch. Empty native volume
58
- * still yields a typed switch (empty path list).
59
- *
60
- * Pi wrote its own session.jsonl; every other host's run volume is the sitian
61
- * record set on the live run (ADR 0077 全宿主 session 卷宗统一直写, #717) — the CLI's
62
- * own journals stay in the operator home and are never copied here.
63
- */
64
- export async function projectHostTransitionPriorNative(input) {
65
- if (input.previousHost === input.liveHost)
66
- return undefined;
67
- if (input.previousHost === "pi") {
68
- return {
69
- priorNativeKind: "pi-native",
70
- priorNativePaths: await listPiNativeRecordPaths(input.piSessionFile),
71
- };
72
- }
73
- // Sitian path handoff only — do not read bytes.
74
- return {
75
- priorNativeKind: "sitian",
76
- priorNativePaths: await listSitianRecordPaths(input.piSessionFile),
77
- };
78
- }
@@ -1,80 +0,0 @@
1
- /**
2
- * Single authority for #617 DK-4 cross-host prior-native projection.
3
- * Classifies the prior volume into the two record families that exist
4
- * (Pi native session file / sitian run records), never by host name.
5
- */
6
- import { access, readdir } from "node:fs/promises";
7
- import { dirname, join } from "node:path";
8
-
9
- import type { RoleTurnHostTransition } from "./host-contracts.ts";
10
-
11
- function isEnoent(error: unknown): boolean {
12
- return typeof error === "object" && error !== null && (error as NodeJS.ErrnoException).code === "ENOENT";
13
- }
14
-
15
- /** Present Pi native session path, or empty when ENOENT. */
16
- async function listPiNativeRecordPaths(sessionFile: string): Promise<string[]> {
17
- try {
18
- await access(sessionFile);
19
- return [sessionFile];
20
- } catch (error) {
21
- if (isEnoent(error)) return [];
22
- throw error;
23
- }
24
- }
25
-
26
- /**
27
- * Present sitian records.jsonl paths under sessionParent topology (#717).
28
- * resolveSitianRecordPathInLedger writes dirname(sessionParent)/<category>/records.jsonl
29
- * when sessionParent is inside ledger home — never session.jsonl itself.
30
- */
31
- async function listSitianRecordPaths(sessionParent: string): Promise<string[]> {
32
- const sessionRoot = dirname(sessionParent);
33
- let entries;
34
- try {
35
- entries = await readdir(sessionRoot, { withFileTypes: true });
36
- } catch (error) {
37
- if (isEnoent(error)) return [];
38
- throw error;
39
- }
40
- const recordPaths: string[] = [];
41
- for (const entry of entries) {
42
- if (!entry.isDirectory()) continue;
43
- const recordFile = join(sessionRoot, entry.name, "records.jsonl");
44
- try {
45
- await access(recordFile);
46
- recordPaths.push(recordFile);
47
- } catch (error) {
48
- if (!isEnoent(error)) throw error;
49
- }
50
- }
51
- recordPaths.sort();
52
- return recordPaths;
53
- }
54
-
55
- /**
56
- * Project one hostTransition only for a real host switch. Empty native volume
57
- * still yields a typed switch (empty path list).
58
- *
59
- * Pi wrote its own session.jsonl; every other host's run volume is the sitian
60
- * record set on the live run (ADR 0077 全宿主 session 卷宗统一直写, #717) — the CLI's
61
- * own journals stay in the operator home and are never copied here.
62
- */
63
- export async function projectHostTransitionPriorNative(input: {
64
- readonly previousHost: string;
65
- readonly liveHost: string;
66
- readonly piSessionFile: string;
67
- }): Promise<RoleTurnHostTransition | undefined> {
68
- if (input.previousHost === input.liveHost) return undefined;
69
- if (input.previousHost === "pi") {
70
- return {
71
- priorNativeKind: "pi-native",
72
- priorNativePaths: await listPiNativeRecordPaths(input.piSessionFile),
73
- };
74
- }
75
- // Sitian path handoff only — do not read bytes.
76
- return {
77
- priorNativeKind: "sitian",
78
- priorNativePaths: await listSitianRecordPaths(input.piSessionFile),
79
- };
80
- }