@akagilnc/pi-workflow-roles 0.1.4759 → 0.1.4772

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.
@@ -10,11 +10,12 @@ import { writeFile } from "node:fs/promises";
10
10
  import { isAbsolute, join, resolve } from "node:path";
11
11
  import { buildAutoResumeContinuationPrompt, buildResumeContinuationPrompt, RESUME_TRANSPORT_ENVELOPE, } from "./run-lifecycle.js";
12
12
  import { CliUsageError } from "./cli-errors.js";
13
- import { bindAdmittedTicketNumber, buildInstructionTransportPrompt, freezeAttachmentsIntoRun, } from "./invocation.js";
13
+ import { bindAdmittedTicketNumber, buildInstructionTransportPrompt, freezeAttachmentsIntoRun, relocateAdmittedRunToTicket, } from "./invocation.js";
14
14
  import { readRecordedSubmissionRows } from "../submission-ledger.js";
15
15
  import { pathContainedIn } from "../activation-ledger-topology.js";
16
16
  import { pickEngineAxis } from "../package-resources/engine-material.js";
17
17
  import { resolveHostAwareSessionAvailability } from "../session-identity.js";
18
+ import { rewriteRunDirectoryPathValue } from "../role-run-relocation.js";
18
19
  import { isOfficerReviewSeat } from "../host-contracts.js";
19
20
  import { deliverCaseDossierAsAttachment } from "./case-dossier-delivery.js";
20
21
  /** Original error bytes, never relabeled — a secondary fact riding beside a classified cause. */
@@ -41,6 +42,25 @@ import { homeFromRunDirectory } from "../activation-ledger-topology.js";
41
42
  import { attemptProducedFreshSubmission, classifyPostAdmissionFailure, controlledFailureInputFromResolution, exitCodeForTerminalOutcome, explicitInternalKnownFailureClassificationInput, formatCliDiagnostic, formatTerminalResult, inspectJudgeSession, isLawfulTypedTerminalOutcome, presentFailureTerminal, presentStructuralRejection, resolveAuditedRunnerFailureResolution, resolveControlledFailureResumeObservation, settleFailureTerminalResult, settleHostEndedNoReceipt, attachRecordedSubmissions, } from "./settlement.js";
42
43
  import {} from "./terminal.js";
43
44
  import { ensureRealArtifactsDirectory, persistReturnedRunState, runWithAutoResumeLoop, TurnDispatchedFailure, } from "./auto-resume.js";
45
+ function projectRelocatedTurnIdentity(request, result, admitted, relocation) {
46
+ const rewrite = (value) => rewriteRunDirectoryPathValue(value, relocation.oldRunDirectory, relocation.newRunDirectory);
47
+ const mutableRequest = request;
48
+ mutableRequest.runDirectory = admitted.runDirectory;
49
+ mutableRequest.principal = admitted.principal;
50
+ const activation = mutableRequest.activation;
51
+ if (activation !== null && typeof activation === "object" && !Array.isArray(activation)) {
52
+ for (const field of ["taskPath", "packetPath", "prerequisitesPath", "inputPath", "requestManifestPath"]) {
53
+ const record = activation;
54
+ if (field in record)
55
+ record[field] = rewrite(record[field]);
56
+ }
57
+ }
58
+ if (result.terminal !== undefined) {
59
+ for (const artifact of result.terminal.artifacts) {
60
+ artifact.path = rewrite(artifact.path);
61
+ }
62
+ }
63
+ }
44
64
  /** #855: process-cancel settlement never re-enters auto-resume. */
45
65
  function withProcessCancelSkipAutoResume(result, signal) {
46
66
  if (processCancelSignalName(signal) === undefined) {
@@ -333,6 +353,12 @@ export async function dispatchPostAdmissionTurn(input) {
333
353
  if (ticketNumber !== undefined)
334
354
  await bindAdmittedTicketNumber(admitted, ticketNumber);
335
355
  }
356
+ // #863: shared post-admission bind must relocate unbound→ticket in-home
357
+ // before lease release (work-seat self-report and any prior board bind).
358
+ const relocation = await relocateAdmittedRunToTicket(admitted, env.principalAuthority, lease);
359
+ if (relocation !== undefined) {
360
+ projectRelocatedTurnIdentity(request, result, admitted, relocation);
361
+ }
336
362
  if (adapters.afterDispatch !== undefined)
337
363
  await adapters.afterDispatch(admitted, lease);
338
364
  return result;
@@ -15,6 +15,7 @@ import { readLatestTypedProviderHttpObservation, } from "../typed-provider-http.
15
15
  export { clearTypedProviderHttpObservation, recordTypedProviderHttpStatus, readLatestTypedProviderHttpObservation, } from "../typed-provider-http.js";
16
16
  import { parseCollectorRepository } from "../collector-config.js";
17
17
  import { interpretDurableCourtTicketNumbers, sameCourtTicketNumbers, } from "../diarist-contracts.js";
18
+ import { rewriteAdmittedRoleRunPage, rewriteRunDirectoryPathValue, } from "../role-run-relocation.js";
18
19
  import { appendEngineSessionMaterial, engineSessionMaterialFromOptions, pickEngineAxis, } from "../package-resources/engine-material.js";
19
20
  import { recordEffectiveInvocationModel, requireAuthorityRef, isReviewerLens, } from "./invocation.js";
20
21
  /** Providers eligible for v1 typed-429 resume (Codex / xAI only).
@@ -209,14 +210,15 @@ async function readRoleRunStateDisk(runDirectory) {
209
210
  return undefined;
210
211
  if (typeof record.admittedRequestPath !== "string")
211
212
  return undefined;
212
- const runDir = typeof record.runDirectory === "string" && record.runDirectory.trim() !== ""
213
+ const storedRunDirectory = typeof record.runDirectory === "string" && record.runDirectory.trim() !== ""
213
214
  ? record.runDirectory
214
215
  : runDirectory;
216
+ const runDir = runDirectory;
215
217
  // Principal wire stays uninterpreted — authority owns legacy sessionFile fallback.
216
218
  const principalWire = {
217
- sessionDirectory: record.sessionDirectory,
219
+ sessionDirectory: rewriteRunDirectoryPathValue(record.sessionDirectory, storedRunDirectory, runDirectory),
218
220
  ...(typeof record.sessionFile === "string"
219
- ? { sessionFile: record.sessionFile }
221
+ ? { sessionFile: rewriteRunDirectoryPathValue(record.sessionFile, storedRunDirectory, runDirectory) }
220
222
  : {}),
221
223
  };
222
224
  let resumable;
@@ -242,7 +244,7 @@ async function readRoleRunStateDisk(runDirectory) {
242
244
  bookKey: record.bookKey,
243
245
  projectRoot: record.projectRoot,
244
246
  runDirectory: runDir,
245
- admittedRequestPath: record.admittedRequestPath,
247
+ admittedRequestPath: rewriteRunDirectoryPathValue(record.admittedRequestPath, storedRunDirectory, runDirectory),
246
248
  principalWire,
247
249
  ...(phase === undefined ? {} : { phase }),
248
250
  ...(resumable === undefined ? {} : { resumable }),
@@ -1017,6 +1019,13 @@ async function loadResumableRunRecord(home, runId, authority) {
1017
1019
  const raw = JSON.parse(await readFile(run.admittedRequestPath, "utf8"));
1018
1020
  if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
1019
1021
  const record = raw;
1022
+ const storedRunDirectory = typeof record.runDirectory === "string" && record.runDirectory.trim() !== ""
1023
+ ? record.runDirectory
1024
+ : run.runDirectory;
1025
+ rewriteAdmittedRoleRunPage(record, [{
1026
+ oldRunDirectory: storedRunDirectory,
1027
+ newRunDirectory: run.runDirectory,
1028
+ }]);
1020
1029
  if (typeof record.instruction === "string") {
1021
1030
  instruction = record.instruction;
1022
1031
  }
@@ -1201,6 +1210,18 @@ async function loadResumableRunRecord(home, runId, authority) {
1201
1210
  else if (mergedSet.tickets !== undefined) {
1202
1211
  courtTicketNumbers = mergedSet.tickets;
1203
1212
  }
1213
+ const sourceRunLeaf = basename(sourceRunPath ?? "").split("@");
1214
+ const referencedRunId = sourceRun?.runId ?? sourceRunLeaf[0];
1215
+ const referencedRole = sourceRun?.role ?? sourceRunLeaf[1];
1216
+ if (referencedRunId !== undefined && referencedRunId !== "") {
1217
+ const currentSourceRunDirectory = await findRunDirectoryById(home, referencedRunId, run.bookKey, referencedRole);
1218
+ if (currentSourceRunDirectory !== undefined) {
1219
+ sourceRunPath = currentSourceRunDirectory;
1220
+ if (sourceRun !== undefined) {
1221
+ sourceRun = { ...sourceRun, runDirectory: currentSourceRunDirectory };
1222
+ }
1223
+ }
1224
+ }
1204
1225
  return {
1205
1226
  run,
1206
1227
  principal,
@@ -132,6 +132,21 @@ function rewriteSummonsMaterials(value, rewrites) {
132
132
  value.attachmentPaths = value.attachmentPaths.map((path) => rewriteRunDirectoryPathValueAgainstRewrites(path, rewrites));
133
133
  }
134
134
  }
135
+ /** Project one admitted page in memory after its containing run moved. */
136
+ export function rewriteAdmittedRoleRunPage(page, rewrites) {
137
+ rewriteRunDirectoryPathFieldsAgainstRewrites(page, ADMITTED_PAGE_FIELDS, rewrites);
138
+ rewriteSourceRunLocator(page.sourceRun, rewrites);
139
+ if (Array.isArray(page.attachments)) {
140
+ for (const attachment of page.attachments) {
141
+ if (isPlainObject(attachment)) {
142
+ rewriteRunDirectoryPathFieldsAgainstRewrites(attachment, ["frozenPath"], rewrites);
143
+ }
144
+ }
145
+ }
146
+ if (isPlainObject(page.principal)) {
147
+ rewriteRunDirectoryPathFieldsAgainstRewrites(page.principal, ["sessionDirectory", "sessionFile"], rewrites);
148
+ }
149
+ }
135
150
  async function rewriteJsonObjectFile(path, fields, rewrites) {
136
151
  if (!existsSync(path))
137
152
  return;
@@ -307,9 +322,9 @@ async function rewriteNestedMachinePathPages(pagesDirectory, rewrites) {
307
322
  /**
308
323
  * Rewrite admitted-request / invocation / run-state path fields (attachment
309
324
  * frozenPath / summons.attachmentPaths pointers only), then nested package-owned
310
- * session seams, after the run directory has already been moved or copied.
311
- * `pagesDirectory` is where the pages now live; path strings still naming an
312
- * old run directory become the matching new directory. `crossRunRewrites`
325
+ * session seams. Callers may rewrite before or after the filesystem move/copy:
326
+ * `pagesDirectory` is where the pages currently live on disk; path strings that
327
+ * still name `oldRunDirectory` become `newRunDirectory`. `crossRunRewrites`
313
328
  * covers officer/parent pointers that landed under a different final placement.
314
329
  */
315
330
  export async function rewriteRoleRunDurablePages(input) {
@@ -318,21 +333,7 @@ export async function rewriteRoleRunDurablePages(input) {
318
333
  const admittedPath = join(pagesDirectory, "admitted-request.json");
319
334
  if (existsSync(admittedPath)) {
320
335
  const page = JSON.parse(await readFile(admittedPath, "utf8"));
321
- rewriteRunDirectoryPathFieldsAgainstRewrites(page, ADMITTED_PAGE_FIELDS, rewrites);
322
- // Nested notary typed locator — same value family as top-level sourceRunPath.
323
- rewriteSourceRunLocator(page.sourceRun, rewrites);
324
- if (Array.isArray(page.attachments)) {
325
- for (const attachment of page.attachments) {
326
- if (isPlainObject(attachment)) {
327
- // Pointer only — never open or mutate frozen attachment bytes.
328
- rewriteRunDirectoryPathFieldsAgainstRewrites(attachment, ["frozenPath"], rewrites);
329
- }
330
- }
331
- }
332
- // Nested principal wire (sessionDirectory/sessionFile) when present.
333
- if (isPlainObject(page.principal)) {
334
- rewriteRunDirectoryPathFieldsAgainstRewrites(page.principal, ["sessionDirectory", "sessionFile"], rewrites);
335
- }
336
+ rewriteAdmittedRoleRunPage(page, rewrites);
336
337
  await writeFile(admittedPath, `${JSON.stringify(page, null, 2)}\n`, "utf8");
337
338
  }
338
339
  const invocationPath = join(pagesDirectory, "invocation.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.4759",
3
+ "version": "0.1.4772",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -397,7 +397,15 @@ export async function findPlacedMigratingRun(
397
397
  const preferred = destPathFromSourceRelative(booksDirectory, bookKey, sourceRelative);
398
398
  if (preferred !== undefined) {
399
399
  const hit = matches.find((path) => path === preferred);
400
- return hit === undefined ? undefined : placedRunFromPath(hit);
400
+ if (hit !== undefined) return placedRunFromPath(hit);
401
+ // T9 may have used the board ticket to move an historical unbound leaf
402
+ // under `<ticket>/runs`. The complete leaf is the placement identity;
403
+ // when it remains unique, downstream record/mixed/T11 consumers must
404
+ // follow that authoritative T9 result rather than re-derive unbound.
405
+ if (sourceRelative.replaceAll("\\", "/").startsWith("unbound/runs/")) {
406
+ return uniquePlacedRun(leafName, matches);
407
+ }
408
+ return undefined;
401
409
  }
402
410
  // Historical flat `runs/<leaf>` alias after T9 nested the unique complete leaf.
403
411
  if (isFlatRunsRelative(sourceRelative, leafName)) {
@@ -2,9 +2,10 @@
2
2
  * #865 T9: migrate retained runs into ticket-scoped or unbound placement.
3
3
  * Inventory covers legacy flat `runs/`, existing `<ticket>/runs/`, and
4
4
  * `unbound/runs/`. Attribution reuses the shared migrating-run helper;
5
- * already-canonical trees copy as-is.
5
+ * already-canonical ticket trees copy as-is. Unbound leaves that already
6
+ * hold a board typed ticketNumber place under that ticket (#863 stock).
6
7
  */
7
- import { cp, mkdir, stat, writeFile } from "node:fs/promises";
8
+ import { cp, mkdir, rename, readdir, stat, writeFile } from "node:fs/promises";
8
9
  import { basename, dirname, join, relative, sep } from "node:path";
9
10
 
10
11
  import {
@@ -16,16 +17,21 @@ import {
16
17
  resolveMigratingRunTicket,
17
18
  } from "./book-topology-migration-placement.ts";
18
19
  import {
20
+ assertBookTopologyMigrationPrerequisites,
19
21
  reconcileMigrationPartition,
20
22
  type BookTopologyMigrationContext,
21
23
  type BookTopologyPartitionMigrator,
22
24
  type MigrationItemOutcome,
23
25
  } from "./book-topology-migration.ts";
26
+ import { listBookRunDirectories } from "./role-run-placement.ts";
24
27
  import {
25
28
  rewriteRoleRunDurablePages,
26
29
  type RunDirectoryPathRewrite,
27
30
  } from "./role-run-relocation.ts";
28
- import { MIGRATION_TICKET_DERIVATION_PAGE } from "./run-ticket-number.ts";
31
+ import {
32
+ MIGRATION_TICKET_DERIVATION_PAGE,
33
+ readBoardTicketNumber,
34
+ } from "./run-ticket-number.ts";
29
35
 
30
36
  const RUNS_PARTITION = "runs";
31
37
 
@@ -141,9 +147,38 @@ async function planBookMoves(
141
147
  historicalRunDirectory = join(booksDirectory, bookKey, leaf.relativePath);
142
148
  }
143
149
  derivation = undefined;
150
+ } else if (leaf.layout === "unbound") {
151
+ // #863: board typed ticket on an unbound leaf → place under that ticket.
152
+ // No board ticket → stay unbound. Never invent from prose or derivation.
153
+ const boardTicket = leaf.isDirectory
154
+ ? await readBoardTicketNumber(leaf.sourcePath)
155
+ : undefined;
156
+ if (parsed !== undefined && boardTicket !== undefined) {
157
+ targetPath = destinationRunDirectory(
158
+ booksDirectory,
159
+ bookKey,
160
+ boardTicket,
161
+ parsed.runId,
162
+ parsed.role,
163
+ );
164
+ disposition = "placed";
165
+ historicalRunDirectory = join(
166
+ booksDirectory,
167
+ bookKey,
168
+ "unbound",
169
+ "runs",
170
+ leaf.leafName,
171
+ );
172
+ } else {
173
+ targetPath = join(booksDirectory, bookKey, leaf.relativePath);
174
+ disposition = "unbound";
175
+ historicalRunDirectory = undefined;
176
+ }
177
+ derivation = undefined;
144
178
  } else if (leaf.layout !== "flat") {
179
+ // Already-canonical `<ticket>/runs/` — copy as-is.
145
180
  targetPath = join(booksDirectory, bookKey, leaf.relativePath);
146
- disposition = leaf.layout === "unbound" ? "unbound" : "placed";
181
+ disposition = "placed";
147
182
  historicalRunDirectory = undefined;
148
183
  derivation = undefined;
149
184
  } else if (parsed === undefined) {
@@ -242,3 +277,120 @@ export const bookTopologyRunsMigrator: BookTopologyPartitionMigrator = {
242
277
  return reconcileMigrationPartition(RUNS_PARTITION, "entries", outcomes);
243
278
  },
244
279
  };
280
+
281
+ export type BoardBoundUnboundRelocation = {
282
+ readonly from: string;
283
+ readonly to: string;
284
+ readonly ticketNumber: number;
285
+ };
286
+
287
+ type PlannedBoardBoundMove = {
288
+ readonly sourcePath: string;
289
+ readonly targetPath: string;
290
+ readonly ticketNumber: number;
291
+ };
292
+
293
+ /**
294
+ * Live in-place repair for already-migrated trees (#863 stock): plan the full
295
+ * board-bound unbound→ticket batch first, refuse any overwrite before mutation,
296
+ * rewrite durable pages (including batch `crossRunRewrites`) while sources still
297
+ * sit at unbound, then rename. Parse/write failures therefore leave sources in
298
+ * place so a retry can finish the same closure; rename-only retries still find
299
+ * remaining unbound sources. Leaves without a board ticket stay unbound.
300
+ */
301
+ export async function relocateBoardBoundUnboundRunsInBook(
302
+ bookDirectory: string,
303
+ ): Promise<readonly BoardBoundUnboundRelocation[]> {
304
+ const bookKey = basename(bookDirectory);
305
+ const booksDirectory = dirname(bookDirectory);
306
+ const unboundRuns = join(bookDirectory, "unbound", "runs");
307
+ let entries;
308
+ try {
309
+ entries = await readdir(unboundRuns, { withFileTypes: true });
310
+ } catch (error) {
311
+ if (isMigrationEnoent(error)) return [];
312
+ throw error;
313
+ }
314
+
315
+ const planned: PlannedBoardBoundMove[] = [];
316
+ for (const entry of entries) {
317
+ if (!entry.isDirectory()) continue;
318
+ const parsed = parseRunLeaf(entry.name);
319
+ if (parsed === undefined) continue;
320
+ const sourcePath = join(unboundRuns, entry.name);
321
+ const boardTicket = await readBoardTicketNumber(sourcePath);
322
+ if (boardTicket === undefined) continue;
323
+ const targetPath = destinationRunDirectory(
324
+ booksDirectory,
325
+ bookKey,
326
+ boardTicket,
327
+ parsed.runId,
328
+ parsed.role,
329
+ );
330
+ planned.push({
331
+ sourcePath,
332
+ targetPath,
333
+ ticketNumber: boardTicket,
334
+ });
335
+ }
336
+
337
+ if (planned.length === 0) return [];
338
+
339
+ for (const move of planned) {
340
+ if (await pathExists(move.targetPath)) {
341
+ throw new Error(
342
+ `board-bound unbound relocate refuses to overwrite ${move.targetPath} with ${move.sourcePath}`,
343
+ );
344
+ }
345
+ }
346
+
347
+ const crossRunRewrites: RunDirectoryPathRewrite[] = planned.map((move) => ({
348
+ oldRunDirectory: move.sourcePath,
349
+ newRunDirectory: move.targetPath,
350
+ }));
351
+ const relocatedBySource = new Map(
352
+ planned.map((move) => [move.sourcePath, move] as const),
353
+ );
354
+
355
+ // Rewrite before any rename so durable-page failures remain retryable from
356
+ // the same unbound sources (failure-honesty: no relocatedCount=0 whitewash).
357
+ for (const runDir of await listBookRunDirectories(bookDirectory)) {
358
+ const move = relocatedBySource.get(runDir);
359
+ await rewriteRoleRunDurablePages({
360
+ pagesDirectory: runDir,
361
+ oldRunDirectory: move?.sourcePath ?? runDir,
362
+ newRunDirectory: move?.targetPath ?? runDir,
363
+ crossRunRewrites,
364
+ });
365
+ }
366
+
367
+ for (const move of planned) {
368
+ await mkdir(dirname(move.targetPath), { recursive: true });
369
+ await rename(move.sourcePath, move.targetPath);
370
+ }
371
+
372
+ return planned.map((move) => ({
373
+ from: move.sourcePath,
374
+ to: move.targetPath,
375
+ ticketNumber: move.ticketNumber,
376
+ }));
377
+ }
378
+
379
+ /**
380
+ * Walk every book under `booksDirectory` and relocate board-bound unbound
381
+ * runs in place. Requires zero live writer locks (same gate as topology migrate).
382
+ */
383
+ export async function relocateBoardBoundUnboundRunsInBooks(
384
+ booksDirectory: string,
385
+ env: NodeJS.ProcessEnv = process.env,
386
+ ): Promise<readonly BoardBoundUnboundRelocation[]> {
387
+ await assertBookTopologyMigrationPrerequisites(booksDirectory, env);
388
+ const relocated: BoardBoundUnboundRelocation[] = [];
389
+ for (const bookKey of await listMigrationBookKeys(booksDirectory)) {
390
+ const batch = await relocateBoardBoundUnboundRunsInBook(
391
+ join(booksDirectory, bookKey),
392
+ );
393
+ relocated.push(...batch);
394
+ }
395
+ return relocated;
396
+ }
@@ -87,7 +87,8 @@ export async function resolveNotarySourceRunLocator(options: {
87
87
 
88
88
  const ledgerHome = resolveActivationLedgerHome(options.home);
89
89
  const bookKey = resolveBookKeyFromGit(options.projectRoot);
90
- const bookRunsRoot = join(activationBookDirectory(ledgerHome, bookKey), "runs");
90
+ const bookDirectory = activationBookDirectory(ledgerHome, bookKey);
91
+ const bookRunsRoot = join(bookDirectory, "runs");
91
92
 
92
93
  let candidate: string;
93
94
  const bare = parseRunDirectoryName(raw);
@@ -96,12 +97,27 @@ export async function resolveNotarySourceRunLocator(options: {
96
97
  ?? join(bookRunsRoot, `${bare.runId}@${bare.role}`);
97
98
  } else {
98
99
  candidate = isAbsolute(raw) ? raw : resolve(options.projectRoot, raw);
100
+ const identity = parseRunDirectoryName(basename(candidate));
101
+ const subjectDirectory = dirname(dirname(candidate));
102
+ const isLegacyUnboundLocator =
103
+ identity !== undefined &&
104
+ basename(dirname(candidate)) === "runs" &&
105
+ basename(subjectDirectory) === "unbound" &&
106
+ resolve(dirname(subjectDirectory)) === resolve(bookDirectory);
107
+ if (isLegacyUnboundLocator) {
108
+ candidate = (await findRunDirectoryById(
109
+ options.home,
110
+ identity.runId,
111
+ bookKey,
112
+ identity.role,
113
+ )) ?? candidate;
114
+ }
99
115
  }
100
116
 
101
117
  const real = await requireRunDirectory(candidate, raw);
102
118
  const identity = parseRunDirectoryName(basename(real))!;
103
119
 
104
- const bookIdentity = physicalPathIdentity(activationBookDirectory(ledgerHome, bookKey));
120
+ const bookIdentity = physicalPathIdentity(bookDirectory);
105
121
  const parentIdentity = physicalPathIdentity(dirname(real));
106
122
  const subjectBookIdentity = physicalPathIdentity(dirname(dirname(dirname(real))));
107
123
  if (
@@ -41,7 +41,6 @@ import {
41
41
  requireSafePositiveTicketNumber,
42
42
  } from "../run-ticket-number.ts";
43
43
  import {
44
- rewriteRoleRunDurablePages,
45
44
  rewriteRunDirectoryPathFields,
46
45
  rewriteRunDirectoryPathValue,
47
46
  } from "../role-run-relocation.ts";
@@ -628,8 +627,8 @@ export async function relocateAdmittedRunToTicket(
628
627
  admitted: AdmittedRoleInvocation,
629
628
  authority: DurablePrincipalAuthority,
630
629
  heldLease?: { relocate(runDirectory: string): void },
631
- ): Promise<void> {
632
- if (admitted.ticketNumber === undefined || !admitted.runDirectory.includes(`${sep}unbound${sep}runs${sep}`)) return;
630
+ ): Promise<{ oldRunDirectory: string; newRunDirectory: string } | undefined> {
631
+ if (admitted.ticketNumber === undefined || !admitted.runDirectory.includes(`${sep}unbound${sep}runs${sep}`)) return undefined;
633
632
  const oldRunDirectory = admitted.runDirectory;
634
633
  const ledgerHome = resolveActivationLedgerHome(homeFromRunDirectory(oldRunDirectory));
635
634
  const target = roleRunPlacement(ledgerHome, {
@@ -639,7 +638,17 @@ export async function relocateAdmittedRunToTicket(
639
638
  role: admitted.role,
640
639
  });
641
640
  ensureRoleRunDirectory(ledgerHome, dirname(target.runDirectory));
641
+ // Host sealing is pure identity projection, but may reject the coordinates.
642
+ // Keep that failure before the filesystem commit point.
643
+ const principal = authority.seal(target);
644
+
645
+ // Rename is the only durable commit. Persisted paths are resolved from typed
646
+ // run identity on read, so online relocation never writes unleased peers or
647
+ // pretends a directory rename plus page rewrites form one transaction.
642
648
  await rename(oldRunDirectory, target.runDirectory);
649
+
650
+ // rename moved the open lock inode with the directory. Transfer cleanup
651
+ // ownership immediately after the commit.
643
652
  heldLease?.relocate(target.runDirectory);
644
653
 
645
654
  const admittedRecord = admitted as unknown as Record<string, unknown>;
@@ -664,14 +673,9 @@ export async function relocateAdmittedRunToTicket(
664
673
  target.runDirectory,
665
674
  ) as string;
666
675
  }
667
- const principal = authority.seal(target);
668
676
  (admitted as { principal: DurablePrincipal }).principal = principal;
669
677
 
670
- await rewriteRoleRunDurablePages({
671
- pagesDirectory: target.runDirectory,
672
- oldRunDirectory,
673
- newRunDirectory: target.runDirectory,
674
- });
678
+ return { oldRunDirectory, newRunDirectory: target.runDirectory };
675
679
  }
676
680
 
677
681
  /**
@@ -22,11 +22,13 @@ import {
22
22
  bindAdmittedTicketNumber,
23
23
  buildInstructionTransportPrompt,
24
24
  freezeAttachmentsIntoRun,
25
+ relocateAdmittedRunToTicket,
25
26
  } from "./invocation.ts";
26
27
  import { readRecordedSubmissionRows } from "../submission-ledger.ts";
27
28
  import { pathContainedIn } from "../activation-ledger-topology.ts";
28
29
  import { pickEngineAxis } from "../package-resources/engine-material.ts";
29
30
  import { resolveHostAwareSessionAvailability } from "../session-identity.ts";
31
+ import { rewriteRunDirectoryPathValue } from "../role-run-relocation.ts";
30
32
 
31
33
  import type {
32
34
  ControlledFailureCause,
@@ -117,6 +119,34 @@ import {
117
119
  TurnDispatchedFailure,
118
120
  } from "./auto-resume.ts";
119
121
 
122
+ function projectRelocatedTurnIdentity(
123
+ request: RoleTurnRequest,
124
+ result: { readonly terminal?: TerminalResult },
125
+ admitted: AdmittedRoleInvocation,
126
+ relocation: { readonly oldRunDirectory: string; readonly newRunDirectory: string },
127
+ ): void {
128
+ const rewrite = (value: unknown) => rewriteRunDirectoryPathValue(
129
+ value,
130
+ relocation.oldRunDirectory,
131
+ relocation.newRunDirectory,
132
+ );
133
+ const mutableRequest = request as unknown as Record<string, unknown>;
134
+ mutableRequest.runDirectory = admitted.runDirectory;
135
+ mutableRequest.principal = admitted.principal;
136
+ const activation = mutableRequest.activation;
137
+ if (activation !== null && typeof activation === "object" && !Array.isArray(activation)) {
138
+ for (const field of ["taskPath", "packetPath", "prerequisitesPath", "inputPath", "requestManifestPath"] as const) {
139
+ const record = activation as Record<string, unknown>;
140
+ if (field in record) record[field] = rewrite(record[field]);
141
+ }
142
+ }
143
+ if (result.terminal !== undefined) {
144
+ for (const artifact of result.terminal.artifacts) {
145
+ artifact.path = rewrite(artifact.path) as string;
146
+ }
147
+ }
148
+ }
149
+
120
150
  /** #855: process-cancel settlement never re-enters auto-resume. */
121
151
  function withProcessCancelSkipAutoResume<T extends object>(
122
152
  result: T,
@@ -703,6 +733,12 @@ export async function dispatchPostAdmissionTurn<
703
733
  : (asserted as { ticketNumber: number }).ticketNumber;
704
734
  if (ticketNumber !== undefined) await bindAdmittedTicketNumber(admitted, ticketNumber);
705
735
  }
736
+ // #863: shared post-admission bind must relocate unbound→ticket in-home
737
+ // before lease release (work-seat self-report and any prior board bind).
738
+ const relocation = await relocateAdmittedRunToTicket(admitted, env.principalAuthority, lease);
739
+ if (relocation !== undefined) {
740
+ projectRelocatedTurnIdentity(request, result, admitted, relocation);
741
+ }
706
742
  if (adapters.afterDispatch !== undefined) await adapters.afterDispatch(admitted, lease);
707
743
  return result;
708
744
  } catch (error) {
@@ -40,6 +40,10 @@ import {
40
40
  } from "../diarist-contracts.ts";
41
41
  import type { DoctorCaseIdentity } from "../doctor-contracts.ts";
42
42
  import type { NotarySourceRunLocator } from "../notary-contracts.ts";
43
+ import {
44
+ rewriteAdmittedRoleRunPage,
45
+ rewriteRunDirectoryPathValue,
46
+ } from "../role-run-relocation.ts";
43
47
  import {
44
48
  appendEngineSessionMaterial,
45
49
  engineSessionMaterialFromOptions,
@@ -419,15 +423,20 @@ async function readRoleRunStateDisk(
419
423
  if (typeof record.projectRoot !== "string") return undefined;
420
424
  if (typeof record.sessionDirectory !== "string") return undefined;
421
425
  if (typeof record.admittedRequestPath !== "string") return undefined;
422
- const runDir =
426
+ const storedRunDirectory =
423
427
  typeof record.runDirectory === "string" && record.runDirectory.trim() !== ""
424
428
  ? record.runDirectory
425
429
  : runDirectory;
430
+ const runDir = runDirectory;
426
431
  // Principal wire stays uninterpreted — authority owns legacy sessionFile fallback.
427
432
  const principalWire: RoleRunPrincipalWire = {
428
- sessionDirectory: record.sessionDirectory,
433
+ sessionDirectory: rewriteRunDirectoryPathValue(
434
+ record.sessionDirectory,
435
+ storedRunDirectory,
436
+ runDirectory,
437
+ ) as string,
429
438
  ...(typeof record.sessionFile === "string"
430
- ? { sessionFile: record.sessionFile }
439
+ ? { sessionFile: rewriteRunDirectoryPathValue(record.sessionFile, storedRunDirectory, runDirectory) as string }
431
440
  : {}),
432
441
  };
433
442
  let resumable: TypedHttp429Observation | undefined;
@@ -458,7 +467,11 @@ async function readRoleRunStateDisk(
458
467
  bookKey: record.bookKey,
459
468
  projectRoot: record.projectRoot,
460
469
  runDirectory: runDir,
461
- admittedRequestPath: record.admittedRequestPath,
470
+ admittedRequestPath: rewriteRunDirectoryPathValue(
471
+ record.admittedRequestPath,
472
+ storedRunDirectory,
473
+ runDirectory,
474
+ ) as string,
462
475
  principalWire,
463
476
  ...(phase === undefined ? {} : { phase }),
464
477
  ...(resumable === undefined ? {} : { resumable }),
@@ -1433,6 +1446,14 @@ async function loadResumableRunRecord(
1433
1446
  );
1434
1447
  if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
1435
1448
  const record = raw as Record<string, unknown>;
1449
+ const storedRunDirectory =
1450
+ typeof record.runDirectory === "string" && record.runDirectory.trim() !== ""
1451
+ ? record.runDirectory
1452
+ : run.runDirectory;
1453
+ rewriteAdmittedRoleRunPage(record, [{
1454
+ oldRunDirectory: storedRunDirectory,
1455
+ newRunDirectory: run.runDirectory,
1456
+ }]);
1436
1457
  if (typeof record.instruction === "string") {
1437
1458
  instruction = record.instruction;
1438
1459
  }
@@ -1649,6 +1670,23 @@ async function loadResumableRunRecord(
1649
1670
  } else if (mergedSet.tickets !== undefined) {
1650
1671
  courtTicketNumbers = mergedSet.tickets;
1651
1672
  }
1673
+ const sourceRunLeaf = basename(sourceRunPath ?? "").split("@");
1674
+ const referencedRunId = sourceRun?.runId ?? sourceRunLeaf[0];
1675
+ const referencedRole = sourceRun?.role ?? sourceRunLeaf[1];
1676
+ if (referencedRunId !== undefined && referencedRunId !== "") {
1677
+ const currentSourceRunDirectory = await findRunDirectoryById(
1678
+ home,
1679
+ referencedRunId,
1680
+ run.bookKey,
1681
+ referencedRole,
1682
+ );
1683
+ if (currentSourceRunDirectory !== undefined) {
1684
+ sourceRunPath = currentSourceRunDirectory;
1685
+ if (sourceRun !== undefined) {
1686
+ sourceRun = { ...sourceRun, runDirectory: currentSourceRunDirectory };
1687
+ }
1688
+ }
1689
+ }
1652
1690
  return {
1653
1691
  run,
1654
1692
  principal,