@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.
- package/dist/acp-host/production-host.js +238 -132
- package/dist/headless-host/production-host.js +223 -117
- package/dist/navigator-attendance.js +23 -7
- package/dist/navigator-public-session.js +74 -75
- package/package.json +1 -1
- package/src/host-native-method.ts +128 -10
- package/src/navigator-attendance.ts +30 -4
- package/src/navigator-public-session.ts +26 -15
- package/src/role-runtime.ts +42 -23
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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,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
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
};
|
package/src/role-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|