@akagilnc/pi-workflow-roles 0.1.4659 → 0.1.4673

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.
@@ -73,7 +73,6 @@ function createNativeNavigatorSessionFactory(deps) {
73
73
  let providerFailure;
74
74
  let noReceipt;
75
75
  let disposed = false;
76
- let inFlightPrompt;
77
76
  let hostRunId = readNavigatorHostRunPointer(sessionManager.getEntries());
78
77
  const summon = deps?.summonPublicRole ?? (async (options) => {
79
78
  const { summonPublicRole } = await import("./public-role-summons.js");
@@ -86,85 +85,87 @@ function createNativeNavigatorSessionFactory(deps) {
86
85
  }
87
86
  providerFailure = void 0;
88
87
  noReceipt = void 0;
89
- const run = (async () => {
90
- try {
91
- const summonHome = await resolveNavigatorLedgerHome(context);
92
- const resumeRunId = hostRunId;
93
- const baseSummon = {
94
- role: "navigator",
95
- argv: [text],
96
- cwd: context.cwd,
97
- ...summonHome === void 0 ? {} : { home: summonHome }
98
- };
99
- const resumable = deps?.hostRunResumable ?? navigatorHostRunResumable;
100
- let summoned;
101
- if (resumeRunId === void 0 || summonHome === void 0) {
102
- summoned = await summon(baseSummon);
103
- } else if (await resumable(summonHome, resumeRunId)) {
88
+ try {
89
+ const summonHome = await resolveNavigatorLedgerHome(context);
90
+ if (disposed) return;
91
+ const resumeRunId = hostRunId;
92
+ const baseSummon = {
93
+ role: "navigator",
94
+ argv: [text],
95
+ cwd: context.cwd,
96
+ ...summonHome === void 0 ? {} : { home: summonHome },
97
+ ...context.signal === void 0 ? {} : { signal: context.signal }
98
+ };
99
+ const resumable = deps?.hostRunResumable ?? navigatorHostRunResumable;
100
+ let summoned;
101
+ if (resumeRunId === void 0 || summonHome === void 0) {
102
+ if (disposed) return;
103
+ summoned = await summon(baseSummon);
104
+ } else {
105
+ const canResume = await resumable(summonHome, resumeRunId);
106
+ if (disposed) return;
107
+ if (canResume) {
104
108
  summoned = await summon({ ...baseSummon, resumeRunId });
105
109
  } else {
106
110
  hostRunId = void 0;
107
111
  summoned = await summon(baseSummon);
108
112
  }
109
- const outcome = summoned.terminal?.roleOutcome;
110
- if (outcome === void 0) {
111
- const detail = summoned.stderr?.trim() || `exit ${summoned.exitCode}`;
112
- providerFailure = { source: "transport", cause: "unknown" };
113
- throw navigatorUnavailableError(
114
- providerFailure.source,
115
- new Error(`Navigator public summon produced no terminal (${detail})`),
116
- providerFailure.cause
117
- );
118
- }
119
- if (outcome.kind === "failure") {
120
- providerFailure = navigatorProviderFailureFromPublicTerminal(outcome);
121
- throw navigatorUnavailableError(
122
- providerFailure.source,
123
- new Error(outcome.diagnostic),
124
- providerFailure.cause
125
- );
126
- }
127
- if (outcome.kind === "no_receipt") {
128
- noReceipt = outcome;
129
- return;
130
- }
131
- const runDirectory = summoned.runDirectory ?? (typeof summoned.admitted?.runDirectory === "string" ? summoned.admitted.runDirectory : void 0);
132
- if (typeof runDirectory === "string" && runDirectory.trim() !== "") {
133
- const nextRunId = runIdFromNavigatorDirectory(runDirectory);
134
- if (nextRunId !== void 0) {
135
- hostRunId = nextRunId;
136
- sessionManager.appendCustomEntry(NAVIGATOR_HOST_RUN_POINTER_ENTRY, { runId: nextRunId });
137
- }
138
- }
139
- if (outcome.kind !== "accepted") {
140
- return;
141
- }
142
- const { navigatorProseFromUnknown } = await import("./package-contracts/navigator-output.js");
143
- const proseParts = [];
144
- for (const payload of outcome.payloads ?? []) {
145
- const prose = navigatorProseFromUnknown(payload);
146
- if (prose !== void 0) proseParts.push(prose);
147
- }
148
- if (proseParts.length === 0) return;
149
- await tool.execute(
150
- "navigator-public-prepare",
151
- { prose: proseParts.join("\n\n") },
152
- void 0,
153
- void 0,
154
- context
113
+ }
114
+ if (disposed) return;
115
+ const outcome = summoned.terminal?.roleOutcome;
116
+ if (outcome === void 0) {
117
+ const detail = summoned.stderr?.trim() || `exit ${summoned.exitCode}`;
118
+ providerFailure = { source: "transport", cause: "unknown" };
119
+ throw navigatorUnavailableError(
120
+ providerFailure.source,
121
+ new Error(`Navigator public summon produced no terminal (${detail})`),
122
+ providerFailure.cause
155
123
  );
156
- } catch (error) {
157
- if (error instanceof NavigatorUnavailableError) throw error;
158
- const fact = navigatorProviderFailureFromError(error);
159
- providerFailure = fact ?? { source: "transport", cause: "unknown" };
160
- throw navigatorUnavailableError(providerFailure.source, error, providerFailure.cause);
161
124
  }
162
- })();
163
- inFlightPrompt = run;
164
- try {
165
- await run;
166
- } finally {
167
- if (inFlightPrompt === run) inFlightPrompt = void 0;
125
+ if (outcome.kind === "failure") {
126
+ providerFailure = navigatorProviderFailureFromPublicTerminal(outcome);
127
+ throw navigatorUnavailableError(
128
+ providerFailure.source,
129
+ new Error(outcome.diagnostic),
130
+ providerFailure.cause
131
+ );
132
+ }
133
+ if (outcome.kind === "no_receipt") {
134
+ noReceipt = outcome;
135
+ return;
136
+ }
137
+ const runDirectory = summoned.runDirectory ?? (typeof summoned.admitted?.runDirectory === "string" ? summoned.admitted.runDirectory : void 0);
138
+ if (typeof runDirectory === "string" && runDirectory.trim() !== "") {
139
+ const nextRunId = runIdFromNavigatorDirectory(runDirectory);
140
+ if (nextRunId !== void 0) {
141
+ hostRunId = nextRunId;
142
+ sessionManager.appendCustomEntry(NAVIGATOR_HOST_RUN_POINTER_ENTRY, { runId: nextRunId });
143
+ }
144
+ }
145
+ if (outcome.kind !== "accepted") {
146
+ return;
147
+ }
148
+ const { navigatorProseFromUnknown } = await import("./package-contracts/navigator-output.js");
149
+ const proseParts = [];
150
+ for (const payload of outcome.payloads ?? []) {
151
+ const prose = navigatorProseFromUnknown(payload);
152
+ if (prose !== void 0) proseParts.push(prose);
153
+ }
154
+ if (proseParts.length === 0) return;
155
+ if (disposed) return;
156
+ await tool.execute(
157
+ "navigator-public-prepare",
158
+ { prose: proseParts.join("\n\n") },
159
+ void 0,
160
+ void 0,
161
+ context
162
+ );
163
+ } catch (error) {
164
+ if (disposed) return;
165
+ if (error instanceof NavigatorUnavailableError) throw error;
166
+ const fact = navigatorProviderFailureFromError(error);
167
+ providerFailure = fact ?? { source: "transport", cause: "unknown" };
168
+ throw navigatorUnavailableError(providerFailure.source, error, providerFailure.cause);
168
169
  }
169
170
  },
170
171
  providerFailure: () => providerFailure,
@@ -197,8 +198,6 @@ function createNativeNavigatorSessionFactory(deps) {
197
198
  recordPointer: () => sessionManager.getSessionDir(),
198
199
  dispose: async () => {
199
200
  disposed = true;
200
- const pending = inFlightPrompt;
201
- if (pending !== void 0) await pending.catch(() => void 0);
202
201
  }
203
202
  };
204
203
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.4659",
3
+ "version": "0.1.4673",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
- /** #922 host-native packaged method delivery. */
2
- import { lstat, mkdir, readlink, realpath, symlink } from "node:fs/promises";
1
+ /** #922 / #980 host-native packaged method delivery. */
2
+ import { access, lstat, mkdir, readdir, readFile, readlink, realpath, symlink } from "node:fs/promises";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import type { MethodBinding } from "./host-contracts.ts";
5
5
 
@@ -41,26 +41,144 @@ export function applyCodexSkillInvocation(methods: readonly MethodBinding[], pro
41
41
  );
42
42
  }
43
43
 
44
+ const isEnoent = (error: unknown): boolean =>
45
+ (error as NodeJS.ErrnoException).code === "ENOENT";
46
+
47
+ /** Packaged method skill directory names (child has SKILL.md). I/O errors propagate. */
48
+ async function packagedMethodSkillNames(packagedMethodsRealpath: string): Promise<readonly string[]> {
49
+ const entries = await readdir(packagedMethodsRealpath, { withFileTypes: true });
50
+ const names: string[] = [];
51
+ for (const entry of entries) {
52
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
53
+ try {
54
+ await access(join(packagedMethodsRealpath, entry.name, "SKILL.md"));
55
+ } catch (error) {
56
+ if (isEnoent(error)) continue;
57
+ throw error;
58
+ }
59
+ names.push(entry.name);
60
+ }
61
+ return names;
62
+ }
63
+
64
+ /**
65
+ * Byte map of every regular file under a packaged skill directory
66
+ * (relative path → bytes). Undefined when SKILL.md is missing. Other I/O
67
+ * errors propagate. Integrity only — no free-text parsing of Skill prose.
68
+ */
69
+ async function packagedSkillFileBytes(
70
+ skillDir: string,
71
+ ): Promise<ReadonlyMap<string, Buffer> | undefined> {
72
+ try {
73
+ await access(join(skillDir, "SKILL.md"));
74
+ } catch (error) {
75
+ if (isEnoent(error)) return undefined;
76
+ throw error;
77
+ }
78
+ const files = new Map<string, Buffer>();
79
+ async function walk(dir: string, prefix: string): Promise<void> {
80
+ const entries = await readdir(dir, { withFileTypes: true });
81
+ for (const entry of entries) {
82
+ const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
83
+ const full = join(dir, entry.name);
84
+ if (entry.isDirectory()) {
85
+ await walk(full, rel);
86
+ continue;
87
+ }
88
+ if (entry.isFile()) {
89
+ files.set(rel, await readFile(full));
90
+ }
91
+ }
92
+ }
93
+ await walk(skillDir, "");
94
+ return files;
95
+ }
96
+
97
+ /**
98
+ * True when catalog skill already carries identical bytes for every required
99
+ * packaged relative path. Only those paths are read — extra catalog files are
100
+ * out of scope for the compatibility proof.
101
+ */
102
+ async function catalogPublishesPackagedSkill(
103
+ catalogSkillDir: string,
104
+ required: ReadonlyMap<string, Buffer>,
105
+ ): Promise<boolean> {
106
+ for (const [rel, bytes] of required) {
107
+ let other: Buffer;
108
+ try {
109
+ other = await readFile(join(catalogSkillDir, rel));
110
+ } catch (error) {
111
+ if (isEnoent(error)) return false;
112
+ throw error;
113
+ }
114
+ if (!bytes.equals(other)) return false;
115
+ }
116
+ return true;
117
+ }
118
+
119
+ /**
120
+ * Existing project catalog is usable when it already publishes every packaged
121
+ * method skill with identical file bytes. Path identity is sufficient but not
122
+ * required (#980): a worktree catalog at another realpath that still carries
123
+ * the packaged method bytes must stay put. Name-only / foreign / stale content
124
+ * remains a true conflict. Incomplete, broken, or non-symlink entries too.
125
+ *
126
+ * Caller must only invoke this when `link` is known present; link-missing
127
+ * ENOENT is classified solely by the existence probe in
128
+ * `installWorkspaceMethodSkills`, not here.
129
+ */
130
+ async function isCompatibleMethodCatalog(
131
+ link: string,
132
+ packagedMethodsRealpath: string,
133
+ ): Promise<boolean> {
134
+ const stat = await lstat(link);
135
+ if (!stat.isSymbolicLink()) return false;
136
+ let resolved: string;
137
+ try {
138
+ resolved = await realpath(link);
139
+ } catch (error) {
140
+ if (isEnoent(error)) return false;
141
+ throw error;
142
+ }
143
+ if (resolved === packagedMethodsRealpath) return true;
144
+ const names = await packagedMethodSkillNames(packagedMethodsRealpath);
145
+ if (names.length === 0) return false;
146
+ for (const name of names) {
147
+ const required = await packagedSkillFileBytes(join(packagedMethodsRealpath, name));
148
+ if (required === undefined) return false;
149
+ if (!(await catalogPublishesPackagedSkill(join(resolved, name), required))) return false;
150
+ }
151
+ return true;
152
+ }
153
+
44
154
  /** Install the documented project Skill catalog once and leave it in place. */
45
155
  export async function installWorkspaceMethodSkills(cwd: string, packageRoot: string): Promise<void> {
46
156
  const target = await realpath(packagedMethodsDir(packageRoot));
47
157
  const link = join(cwd, ".agents", "skills");
158
+
159
+ // Existence probe only — this seam alone classifies link-missing ENOENT.
160
+ let present: boolean;
48
161
  try {
49
- const stat = await lstat(link);
50
- if (!stat.isSymbolicLink() || await realpath(link) !== target) {
51
- const detail = stat.isSymbolicLink() ? `symlink to ${await readlink(link)}` : "non-symlink entry";
52
- throw new Error(`workspace method catalog conflict at ${link}: ${detail}`);
53
- }
54
- return;
162
+ await lstat(link);
163
+ present = true;
55
164
  } catch (error) {
56
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
165
+ if (!isEnoent(error)) throw error;
166
+ present = false;
57
167
  }
168
+
169
+ if (present) {
170
+ if (await isCompatibleMethodCatalog(link, target)) return;
171
+ const stat = await lstat(link);
172
+ const detail = stat.isSymbolicLink() ? `symlink to ${await readlink(link)}` : "non-symlink entry";
173
+ throw new Error(`workspace method catalog conflict at ${link}: ${detail}`);
174
+ }
175
+
58
176
  await mkdir(dirname(link), { recursive: true });
59
177
  try {
60
178
  await symlink(target, link);
61
179
  } catch (error) {
62
180
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
63
- if ((await realpath(link).catch(() => "")) !== target) {
181
+ if (!(await isCompatibleMethodCatalog(link, target))) {
64
182
  throw new Error(`workspace method catalog conflict at ${link}`);
65
183
  }
66
184
  }
@@ -302,9 +302,22 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
302
302
  let preparationFailure: unknown;
303
303
  let routePlaybookReadFailure: string | undefined;
304
304
  let disposed = false;
305
+ /** In-flight session teardown; repeat dispose returns the same promise (#959 mutation proof). */
306
+ let closing: Promise<void> | undefined;
307
+ // Shared attendance seam owns nested summon cancel (ADR 0018 / #959).
308
+ // Role session factory only forwards HostContext.signal — never its own controller.
309
+ const nestCancel = new AbortController();
305
310
  /** One-shot live-help warm; consumed by the next prepare so later prepares reread live help. */
306
311
  let warmedHelp: Promise<Array<{ role: NavigatorTargetRole; help: string }>> | undefined;
307
312
 
313
+ const sessionHostContext = (): HostContext => {
314
+ const parentSignal = options.context.signal;
315
+ const signal = parentSignal === undefined
316
+ ? nestCancel.signal
317
+ : AbortSignal.any([nestCancel.signal, parentSignal]);
318
+ return { ...options.context, signal };
319
+ };
320
+
308
321
  const loadLiveHelp = async (): Promise<Array<{ role: NavigatorTargetRole; help: string }>> => {
309
322
  try {
310
323
  return await Promise.all(
@@ -408,7 +421,7 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
408
421
  let created: NavigatorPreparationSession;
409
422
  try {
410
423
  created = await options.createSession({
411
- context: options.context,
424
+ context: sessionHostContext(),
412
425
  subject: subjectKey,
413
426
  ...(options.modelSettingPath === undefined ? {} : { modelSettingPath: options.modelSettingPath }),
414
427
  tool,
@@ -660,12 +673,25 @@ export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
660
673
  },
661
674
  dispose(): void | Promise<void> {
662
675
  disposed = true;
676
+ // Abort nested public summon via shared signal; callers may still await session
677
+ // close. Parent grace/shutdown must choose not to await (#959 reopen).
678
+ if (!nestCancel.signal.aborted) {
679
+ nestCancel.abort(navigatorUnavailableError(
680
+ "session",
681
+ new Error("Navigator attendance was disposed"),
682
+ ));
683
+ }
663
684
  // Leave sessionReady so an in-flight createSession observes disposed and drains exactly once.
664
685
  // Late settleOnce completion observes disposed and skips onEvent.
665
- const current = session;
666
- session = undefined;
667
686
  activeInvocationId = undefined;
668
- return current?.dispose();
687
+ if (closing === undefined) {
688
+ const current = session;
689
+ session = undefined;
690
+ // Preserve rejection for non-blocking recordDisposeFailure; resolve void on success.
691
+ closing = Promise.resolve(current?.dispose()).then(() => undefined);
692
+ }
693
+ // Same in-flight teardown on repeat dispose — awaiting here re-blocks the parent court.
694
+ return closing;
669
695
  },
670
696
  };
671
697
 
@@ -98,6 +98,8 @@ export type NavigatorPublicSummon = (options: {
98
98
  readonly cwd: string;
99
99
  readonly home?: string;
100
100
  readonly resumeRunId?: string;
101
+ /** Shared-lifecycle cancel forwarded from HostContext.signal (#675 / #959). */
102
+ readonly signal?: AbortSignal;
101
103
  }) => Promise<PublicSummonResult>;
102
104
 
103
105
  export function createNativeNavigatorSessionFactory(deps?: {
@@ -128,7 +130,6 @@ export function createNativeNavigatorSessionFactory(deps?: {
128
130
  let providerFailure: NavigatorProviderFailureFact | undefined;
129
131
  let noReceipt: NoReceiptLifecycleFacts | undefined;
130
132
  let disposed = false;
131
- let inFlightPrompt: Promise<unknown> | undefined;
132
133
  /** In-factory host run id for CLI resume; durable pointer also lives on the nest. */
133
134
  let hostRunId = readNavigatorHostRunPointer(sessionManager.getEntries() as readonly unknown[]);
134
135
 
@@ -145,16 +146,21 @@ export function createNativeNavigatorSessionFactory(deps?: {
145
146
  }
146
147
  providerFailure = undefined;
147
148
  noReceipt = undefined;
148
- const run = (async () => {
149
149
  try {
150
150
  const summonHome = await resolveNavigatorLedgerHome(context);
151
+ // Admission after every await: dispose during preflight must not start summon
152
+ // (ADR 0018 / #959 — legal HostContext may omit signal).
153
+ if (disposed) return;
151
154
  const resumeRunId = hostRunId;
152
155
 
156
+ // Call contract only: forward shared-lifecycle signal; do not own AbortController here
157
+ // (ADR 0018 / #959 — cancel ownership stays on the attendance/envelope seam).
153
158
  const baseSummon = {
154
159
  role: "navigator" as const,
155
160
  argv: [text] as const,
156
161
  cwd: context.cwd,
157
162
  ...(summonHome === undefined ? {} : { home: summonHome }),
163
+ ...(context.signal === undefined ? {} : { signal: context.signal }),
158
164
  };
159
165
 
160
166
  // Prefer host CLI resume so prior advice stays on the host session.
@@ -162,14 +168,23 @@ export function createNativeNavigatorSessionFactory(deps?: {
162
168
  const resumable = deps?.hostRunResumable ?? navigatorHostRunResumable;
163
169
  let summoned: PublicSummonResult;
164
170
  if (resumeRunId === undefined || summonHome === undefined) {
171
+ if (disposed) return;
165
172
  summoned = await summon(baseSummon);
166
- } else if (await resumable(summonHome, resumeRunId)) {
167
- summoned = await summon({ ...baseSummon, resumeRunId });
168
173
  } else {
169
- hostRunId = undefined;
170
- summoned = await summon(baseSummon);
174
+ const canResume = await resumable(summonHome, resumeRunId);
175
+ if (disposed) return;
176
+ if (canResume) {
177
+ summoned = await summon({ ...baseSummon, resumeRunId });
178
+ } else {
179
+ hostRunId = undefined;
180
+ summoned = await summon(baseSummon);
181
+ }
171
182
  }
172
183
 
184
+ // Dispose won the race (with or without HostContext.signal): no late pointer/prepare.
185
+ // Cancel ownership stays on attendance/envelope; this only closes side effects (#959).
186
+ if (disposed) return;
187
+
173
188
  const outcome = summoned.terminal?.roleOutcome;
174
189
  if (outcome === undefined) {
175
190
  const detail = summoned.stderr?.trim() || `exit ${summoned.exitCode}`;
@@ -222,6 +237,7 @@ export function createNativeNavigatorSessionFactory(deps?: {
222
237
  if (prose !== undefined) proseParts.push(prose);
223
238
  }
224
239
  if (proseParts.length === 0) return;
240
+ if (disposed) return;
225
241
  await tool.execute(
226
242
  "navigator-public-prepare",
227
243
  { prose: proseParts.join("\n\n") },
@@ -230,19 +246,14 @@ export function createNativeNavigatorSessionFactory(deps?: {
230
246
  context as never,
231
247
  );
232
248
  } catch (error) {
249
+ // Closed session: drain late nest errors without providerFailure side effects.
250
+ if (disposed) return;
233
251
  if (error instanceof NavigatorUnavailableError) throw error;
234
252
  const fact = navigatorProviderFailureFromError(error);
235
253
  // Catch path is the public-summon seam (source transport); untyped cause stays unknown.
236
254
  providerFailure = fact ?? { source: "transport", cause: "unknown" };
237
255
  throw navigatorUnavailableError(providerFailure.source, error, providerFailure.cause);
238
256
  }
239
- })();
240
- inFlightPrompt = run;
241
- try {
242
- await run;
243
- } finally {
244
- if (inFlightPrompt === run) inFlightPrompt = undefined;
245
- }
246
257
  },
247
258
  providerFailure: () => providerFailure,
248
259
  noReceipt: () => noReceipt,
@@ -276,9 +287,9 @@ export function createNativeNavigatorSessionFactory(deps?: {
276
287
  getThinkingLevel: () => thinkingLevel,
277
288
  recordPointer: () => sessionManager.getSessionDir(),
278
289
  dispose: async () => {
290
+ // Marker only — nested cancel and non-blocking teardown are owned by
291
+ // navigator-attendance / role-runtime (ADR 0018 / #959).
279
292
  disposed = true;
280
- const pending = inFlightPrompt;
281
- if (pending !== undefined) await pending.catch(() => undefined);
282
293
  },
283
294
  };
284
295
  };
@@ -1417,6 +1417,43 @@ export function createRoleRuntimeExtension(
1417
1417
  // Public-run fetch observation (in-process-session statusAwareFetch face).
1418
1418
  let priorFetch: typeof globalThis.fetch | undefined;
1419
1419
  let fetchWrapped = false;
1420
+ /** Envelope-owned: abort/teardown attendance without re-blocking the parent court (#959). */
1421
+ const disposeNavigatorAttendanceNonBlocking = (
1422
+ attendance: NavigatorAttendanceDependency | undefined,
1423
+ ): void => {
1424
+ if (attendance === undefined) return;
1425
+ const recordDisposeFailure = (error: unknown): void => {
1426
+ const diagnostic = error instanceof Error ? error.message : String(error);
1427
+ try {
1428
+ sitianReport({
1429
+ level: "event",
1430
+ kind: "navigator-dispose-failure",
1431
+ cwd: navigatorCwd,
1432
+ sessionParent: navigatorSessionParent,
1433
+ payload: { diagnostic },
1434
+ source: "role-runtime",
1435
+ });
1436
+ } catch (recordError) {
1437
+ try {
1438
+ envelopeHost.appendEntry?.("ak-navigator-dispose-failure", {
1439
+ diagnostic,
1440
+ recordFailure: recordError instanceof Error ? recordError.message : String(recordError),
1441
+ });
1442
+ } catch {
1443
+ // Failure already diagnosed; recording must not create unhandled rejection (#959).
1444
+ }
1445
+ }
1446
+ };
1447
+ // Evaluate dispose inside try: Promise.resolve(dispose()) throws sync before .then attaches.
1448
+ let pending: void | Promise<void>;
1449
+ try {
1450
+ pending = attendance.dispose();
1451
+ } catch (error) {
1452
+ recordDisposeFailure(error);
1453
+ return;
1454
+ }
1455
+ void Promise.resolve(pending).then(undefined, recordDisposeFailure);
1456
+ };
1420
1457
  const settleNavigatorProjection = async (settlement: NavigatorSettlement | undefined) => {
1421
1458
  const attendance = navigatorAttendance;
1422
1459
  if (settlement === undefined || attendance === undefined) return;
@@ -1455,28 +1492,7 @@ export function createRoleRuntimeExtension(
1455
1492
  pendingNavigatorPresentation = { event, report };
1456
1493
  }
1457
1494
  // 过时不候: do not await sidecar teardown — parent court must close.
1458
- // Dispose failure is recorded, not washed; it must not re-block the court.
1459
- void Promise.resolve(attendance.dispose()).then(
1460
- undefined,
1461
- (error) => {
1462
- const diagnostic = error instanceof Error ? error.message : String(error);
1463
- try {
1464
- sitianReport({
1465
- level: "event",
1466
- kind: "navigator-dispose-failure",
1467
- cwd: navigatorCwd,
1468
- sessionParent: navigatorSessionParent,
1469
- payload: { diagnostic },
1470
- source: "role-runtime",
1471
- });
1472
- } catch (recordError) {
1473
- envelopeHost.appendEntry?.("ak-navigator-dispose-failure", {
1474
- diagnostic,
1475
- recordFailure: recordError instanceof Error ? recordError.message : String(recordError),
1476
- });
1477
- }
1478
- },
1479
- );
1495
+ disposeNavigatorAttendanceNonBlocking(attendance);
1480
1496
  })();
1481
1497
  pendingNavigatorSettlement = pending;
1482
1498
  await pending;
@@ -1833,9 +1849,12 @@ export function createRoleRuntimeExtension(
1833
1849
  // Teardown must not mask the original role failure cause.
1834
1850
  }
1835
1851
  }
1836
- await navigatorAttendance?.dispose();
1852
+ // Same non-blocking dispose as post-role grace: awaiting here re-blocked the
1853
+ // parent court for 43–270s after unavailable was already projected (#959 reopen).
1854
+ const attendanceToDispose = navigatorAttendance;
1837
1855
  navigatorAttendance = undefined;
1838
1856
  pendingNavigatorSettlement = undefined;
1857
+ disposeNavigatorAttendanceNonBlocking(attendanceToDispose);
1839
1858
  pendingInfrastructureFailures.clear();
1840
1859
  pendingSubmissionNonPassByToolCallId.clear();
1841
1860
  observationFace.reset();