@nowcrew/daemon 0.6.19 → 0.6.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/atomic-no-replace-rename.js +91 -0
  2. package/dist/control-plane-url.js +4 -2
  3. package/dist/directory-projection-publication.js +105 -0
  4. package/dist/directory-projection.js +20 -4
  5. package/dist/execution-journal.js +40 -4
  6. package/dist/execution-posix-stop-proof.js +82 -0
  7. package/dist/execution-runner.js +68 -8
  8. package/dist/local-executor.js +62 -47
  9. package/dist/machine-info.js +8 -5
  10. package/dist/main.js +0 -0
  11. package/dist/project-skills/capability.js +109 -0
  12. package/dist/project-skills/controller-convergence.js +57 -0
  13. package/dist/project-skills/controller.js +80 -24
  14. package/dist/project-skills/initialized-reconciler.js +4 -4
  15. package/dist/project-skills/projection-state-domain.js +19 -2
  16. package/dist/project-skills/projection-state-store.js +3 -2
  17. package/dist/project-skills/projection-state-transaction.js +5 -1
  18. package/dist/project-skills/projection-state.js +1 -1
  19. package/dist/project-skills/reconciler.js +275 -102
  20. package/dist/project-skills/runtime-launch.js +102 -0
  21. package/dist/project-skills/runtime-root-bootstrap.js +47 -0
  22. package/dist/project-skills/runtime-root-domain.js +268 -0
  23. package/dist/project-skills/runtime-root-gc.js +293 -0
  24. package/dist/project-skills/runtime-root-lease-artifact.js +46 -0
  25. package/dist/project-skills/runtime-root-leases.js +487 -0
  26. package/dist/project-skills/runtime-root-source-identity.js +60 -0
  27. package/dist/project-skills/runtime-root-startup.js +49 -0
  28. package/dist/project-skills/runtime-root-state-artifact-domain.js +143 -0
  29. package/dist/project-skills/runtime-root-state-index.js +356 -0
  30. package/dist/project-skills/runtime-root-store.js +722 -0
  31. package/dist/project-skills/serve-capability.js +28 -0
  32. package/dist/project-skills/serve-startup.js +22 -0
  33. package/dist/project-skills/types.js +1 -0
  34. package/dist/serve.js +58 -73
  35. package/dist/supervised-runtime.js +1 -5
  36. package/package.json +9 -8
@@ -0,0 +1,91 @@
1
+ import { createRequire } from "node:module";
2
+ export class AtomicNoReplaceRenameError extends Error {
3
+ code;
4
+ constructor(code) {
5
+ super(code);
6
+ this.code = code;
7
+ this.name = "AtomicNoReplaceRenameError";
8
+ }
9
+ }
10
+ const RENAME_NOREPLACE = 1;
11
+ const RENAME_EXCL = 0x0000_0004;
12
+ const MOVEFILE_WRITE_THROUGH = 0x0000_0008;
13
+ const ERROR_FILE_EXISTS = 80;
14
+ const ERROR_ALREADY_EXISTS = 183;
15
+ const AT_FDCWD = -100;
16
+ let cached = null;
17
+ let unavailable = false;
18
+ const loadKoffi = () => {
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- optional koffi has no bundled TS declarations.
20
+ const candidate = createRequire(import.meta.url)("koffi");
21
+ return candidate;
22
+ };
23
+ const posixApi = (koffi, library, declaration, invoke) => {
24
+ const native = koffi.load(library).func(declaration);
25
+ return Object.freeze({
26
+ rename: (source, destination) => invoke(native, source, destination) === 0,
27
+ error: () => koffi.errno(),
28
+ destinationExists: (code) => code === koffi.os.errno.EEXIST || code === koffi.os.errno.ENOTEMPTY,
29
+ });
30
+ };
31
+ const bindNativeApi = () => {
32
+ const koffi = loadKoffi();
33
+ if (process.platform === "darwin") {
34
+ return posixApi(koffi, "libSystem.B.dylib", "int renamex_np(const char *source, const char *destination, uint32_t flags)", (native, source, destination) => native(source, destination, RENAME_EXCL));
35
+ }
36
+ if (process.platform === "linux") {
37
+ return posixApi(koffi, "libc.so.6", "int renameat2(int olddirfd, const char *source, int newdirfd, const char *destination, uint32_t flags)", (native, source, destination) => native(AT_FDCWD, source, AT_FDCWD, destination, RENAME_NOREPLACE));
38
+ }
39
+ if (process.platform === "win32") {
40
+ const library = koffi.load("kernel32.dll");
41
+ const moveFileEx = library.func("bool __stdcall MoveFileExW(str16 source, str16 destination, uint32_t flags)");
42
+ const getLastError = library.func("uint32_t __stdcall GetLastError()");
43
+ return Object.freeze({
44
+ rename: (source, destination) => Boolean(moveFileEx(source, destination, MOVEFILE_WRITE_THROUGH)),
45
+ error: () => getLastError(),
46
+ destinationExists: (code) => code === ERROR_FILE_EXISTS || code === ERROR_ALREADY_EXISTS,
47
+ });
48
+ }
49
+ throw new AtomicNoReplaceRenameError("atomic_no_replace_rename_unavailable");
50
+ };
51
+ const nativeApi = () => {
52
+ if (cached !== null)
53
+ return cached;
54
+ if (unavailable)
55
+ throw new AtomicNoReplaceRenameError("atomic_no_replace_rename_unavailable");
56
+ try {
57
+ cached = bindNativeApi();
58
+ return cached;
59
+ }
60
+ catch (error) {
61
+ unavailable = true;
62
+ if (error instanceof AtomicNoReplaceRenameError)
63
+ throw error;
64
+ throw new AtomicNoReplaceRenameError("atomic_no_replace_rename_unavailable");
65
+ }
66
+ };
67
+ /** Loads the mandatory native binding without mutating the filesystem. */
68
+ export function probeAtomicNoReplaceRenameReadiness() {
69
+ try {
70
+ nativeApi();
71
+ return true;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ /**
78
+ * Atomically renames one filesystem object without replacing any destination occupant. macOS uses
79
+ * `renamex_np(RENAME_EXCL)`, Linux uses `renameat2(RENAME_NOREPLACE)`, and Windows uses MoveFileExW
80
+ * without `MOVEFILE_REPLACE_EXISTING`. Missing native support fails closed.
81
+ */
82
+ export async function atomicRenameNoReplace(source, destination) {
83
+ const api = nativeApi();
84
+ if (!api.rename(source, destination)) {
85
+ const code = api.error();
86
+ if (api.destinationExists(code))
87
+ return "destination_exists";
88
+ throw new AtomicNoReplaceRenameError("atomic_no_replace_rename_failed");
89
+ }
90
+ return "published";
91
+ }
@@ -1,6 +1,6 @@
1
1
  import { executionBackendCapability } from "./execution-backend.js";
2
2
  import { daemonCapabilities, EXECUTION_PROTOCOL } from "./machine-info.js";
3
- import { PROJECT_SKILLS_CAPABILITY } from "./project-skills/types.js";
3
+ import { PROJECT_SKILLS_CAPABILITY, PROJECT_SKILL_PROJECTION_V2_CAPABILITY, } from "./project-skills/types.js";
4
4
  export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform = process.platform, jobObjectProbe, projectSkillsAvailable = true, capabilities = daemonCapabilities(runtimePlatform)) {
5
5
  const query = new URLSearchParams({ key: machineToken });
6
6
  if (executionBackendCapability(runtimePlatform, jobObjectProbe).supported) {
@@ -8,7 +8,9 @@ export function buildControlPlaneUrl(serverUrl, machineToken, runtimePlatform =
8
8
  query.set("execution_max", String(EXECUTION_PROTOCOL.max));
9
9
  }
10
10
  for (const capability of capabilities) {
11
- if (capability !== PROJECT_SKILLS_CAPABILITY || projectSkillsAvailable) {
11
+ if (projectSkillsAvailable
12
+ || (capability !== PROJECT_SKILLS_CAPABILITY
13
+ && capability !== PROJECT_SKILL_PROJECTION_V2_CAPABILITY)) {
12
14
  query.append("capability", capability);
13
15
  }
14
16
  }
@@ -0,0 +1,105 @@
1
+ import { posix, win32 } from "node:path";
2
+ export class DirectoryProjectionPublicationError extends Error {
3
+ code;
4
+ constructor(code) {
5
+ super(code);
6
+ this.code = code;
7
+ this.name = "DirectoryProjectionPublicationError";
8
+ }
9
+ }
10
+ const fail = (code) => {
11
+ throw new DirectoryProjectionPublicationError(code);
12
+ };
13
+ const hasTraversal = (value, platform) => (platform === "win32" ? value.split(/[\\/]/u) : value.split("/"))
14
+ .some((component) => component === "..");
15
+ const isFullyQualifiedWindowsPath = (value) => {
16
+ if (/^\\\\[?.]\\/u.test(value))
17
+ return false;
18
+ if (/^[A-Za-z]:[\\/]/u.test(value))
19
+ return true;
20
+ return /^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/u.test(value);
21
+ };
22
+ const normalizeAbsolute = (value, platform, path) => {
23
+ if (value.length === 0 || value.includes("\0") || hasTraversal(value, platform)) {
24
+ return fail("directory_projection_path_invalid");
25
+ }
26
+ if (platform === "win32" ? !isFullyQualifiedWindowsPath(value) : !path.isAbsolute(value)) {
27
+ return fail("directory_projection_path_invalid");
28
+ }
29
+ return path.normalize(value);
30
+ };
31
+ const samePath = (left, right, path) => path.relative(left, right) === "";
32
+ const isWithin = (parent, candidate, path) => {
33
+ const relative = path.relative(parent, candidate);
34
+ return relative !== ""
35
+ && relative !== ".."
36
+ && !relative.startsWith(`..${path.sep}`)
37
+ && !path.isAbsolute(relative);
38
+ };
39
+ const sameComponent = (left, right, path) => (path === win32 ? left.toLowerCase() : left) === (path === win32 ? right.toLowerCase() : right);
40
+ const publicationRootNamesMatch = (stagingRoot, finalRoot, path) => {
41
+ const finalName = path.basename(finalRoot);
42
+ const stagingName = path.basename(stagingRoot);
43
+ const expectedPrefix = `.${finalName}-next-`;
44
+ const comparableStaging = path === win32 ? stagingName.toLowerCase() : stagingName;
45
+ const comparablePrefix = path === win32 ? expectedPrefix.toLowerCase() : expectedPrefix;
46
+ return finalName.length > 0
47
+ && comparableStaging.startsWith(comparablePrefix)
48
+ && /^[A-Za-z0-9-]{1,128}$/u.test(stagingName.slice(expectedPrefix.length));
49
+ };
50
+ /**
51
+ * Maps a projection target through one immutable whole-root publication. Both roots must be direct
52
+ * siblings, the staging name must bind the exact final basename and a bounded nonce, and the target
53
+ * must be strictly below staging. No filesystem access or path canonicalization is performed.
54
+ */
55
+ export function mapDirectoryProjectionFinalTarget(target, publication, platform) {
56
+ const path = platform === "win32" ? win32 : posix;
57
+ const normalizedTarget = normalizeAbsolute(target, platform, path);
58
+ const stagingRoot = normalizeAbsolute(publication.stagingRoot, platform, path);
59
+ const finalRoot = normalizeAbsolute(publication.finalRoot, platform, path);
60
+ if (!samePath(path.parse(stagingRoot).root, path.parse(finalRoot).root, path)) {
61
+ return fail("directory_projection_cross_volume");
62
+ }
63
+ if (!samePath(path.dirname(stagingRoot), path.dirname(finalRoot), path)
64
+ || samePath(stagingRoot, finalRoot, path)
65
+ || isWithin(stagingRoot, finalRoot, path)
66
+ || isWithin(finalRoot, stagingRoot, path)
67
+ || !publicationRootNamesMatch(stagingRoot, finalRoot, path)
68
+ || !isWithin(stagingRoot, normalizedTarget, path)) {
69
+ return fail("directory_projection_path_overlap");
70
+ }
71
+ const relative = path.relative(stagingRoot, normalizedTarget);
72
+ const mapped = path.join(finalRoot, relative);
73
+ if (!isWithin(finalRoot, mapped, path))
74
+ return fail("directory_projection_path_overlap");
75
+ return mapped;
76
+ }
77
+ /** Exact marker lineage check used after the whole staging root has moved to its final UUID path. */
78
+ export function hasDirectoryProjectionPublicationLineage(operationTarget, finalTarget, platform) {
79
+ const path = platform === "win32" ? win32 : posix;
80
+ let operation;
81
+ let final;
82
+ try {
83
+ operation = normalizeAbsolute(operationTarget, platform, path);
84
+ final = normalizeAbsolute(finalTarget, platform, path);
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ let stagingRoot = operation;
90
+ let finalRoot = final;
91
+ while (sameComponent(path.basename(stagingRoot), path.basename(finalRoot), path)) {
92
+ const nextStaging = path.dirname(stagingRoot);
93
+ const nextFinal = path.dirname(finalRoot);
94
+ if (samePath(nextStaging, stagingRoot, path) || samePath(nextFinal, finalRoot, path))
95
+ return false;
96
+ stagingRoot = nextStaging;
97
+ finalRoot = nextFinal;
98
+ }
99
+ try {
100
+ return samePath(mapDirectoryProjectionFinalTarget(operation, { stagingRoot, finalRoot }, platform), final, path);
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
@@ -3,6 +3,7 @@ import { chmod, cp, lstat, mkdir, open, readFile, readlink, realpath, readdir, r
3
3
  import { posix, win32 } from "node:path";
4
4
  import { durableAtomicPrivateWrite, durableDirectorySync, durablePrivateUnlink, } from "./atomic-private-write.js";
5
5
  import { readExactDirectoryIdentity, sameExactDirectoryIdentity, validExactDirectoryIdentity, } from "./directory-projection-identity.js";
6
+ import { hasDirectoryProjectionPublicationLineage } from "./directory-projection-publication.js";
6
7
  export class DirectoryProjectionError extends Error {
7
8
  code;
8
9
  constructor(code) {
@@ -148,6 +149,9 @@ const normalizedMarker = (marker, path) => {
148
149
  const hasPermittedFinalTarget = (operationTarget, finalTarget, path) => {
149
150
  if (samePath(operationTarget, finalTarget, path))
150
151
  return true;
152
+ const platform = path === win32 ? "win32" : "linux";
153
+ if (hasDirectoryProjectionPublicationLineage(operationTarget, finalTarget, platform))
154
+ return true;
151
155
  if (!sameComponent(path.basename(operationTarget), path.basename(finalTarget), path))
152
156
  return false;
153
157
  const operationRoot = path.dirname(operationTarget);
@@ -824,18 +828,29 @@ export async function isManagedDirectoryProjectionCopy(target, platform, options
824
828
  return false;
825
829
  const path = platform === "win32" ? win32 : posix;
826
830
  let normalizedTarget;
831
+ let normalizedFinalTarget;
827
832
  try {
828
833
  normalizedTarget = normalizeAbsolute(target, platform, path);
834
+ normalizedFinalTarget = options.finalTarget === undefined
835
+ ? undefined
836
+ : normalizeAbsolute(options.finalTarget, platform, path);
829
837
  }
830
838
  catch {
831
839
  return false;
832
840
  }
833
841
  const projectionFs = resolveFileSystem(options);
834
842
  const info = await tryLstat(projectionFs, normalizedTarget);
843
+ const marker = await readMarker(projectionFs, normalizedTarget, path);
844
+ const normalized = normalizedMarker(marker, path);
835
845
  return info !== null
836
846
  && info.isDirectory()
837
847
  && !info.isSymbolicLink()
838
- && markerMatchesFinalTarget(await readMarker(projectionFs, normalizedTarget, path), normalizedTarget, path);
848
+ && normalized !== null
849
+ && (normalizedFinalTarget === undefined
850
+ ? markerMatchesFinalTarget(marker, normalizedTarget, path)
851
+ : samePath(normalized.target, normalizedTarget, path)
852
+ && samePath(normalized.finalTarget, normalizedFinalTarget, path)
853
+ && hasPermittedFinalTarget(normalizedTarget, normalizedFinalTarget, path));
839
854
  }
840
855
  /**
841
856
  * Projects one absolute directory without exposing host paths in errors.
@@ -847,9 +862,10 @@ export async function isManagedDirectoryProjectionCopy(target, platform, options
847
862
  * rejected before IO.
848
863
  *
849
864
  * Windows copy ownership requires a version-2 marker whose normalized `finalTarget` exactly equals the
850
- * inspected target. A distinct operation target is accepted only for the complete sibling
851
- * `.skills-next-<id>/<name>` to `skills/<name>` lineage used by Project Skills reconciliation. Legacy,
852
- * incomplete, POSIX, or mismatched markers never authorize refresh or deletion.
865
+ * inspected target. A distinct operation target is accepted only when the exact relative suffix maps
866
+ * through a bounded same-parent staging root: either the legacy `.skills-next-<id>` switch or an immutable
867
+ * Runtime root `.<rootId>-next-<id>` publication. Legacy, incomplete, POSIX, or mismatched markers never
868
+ * authorize refresh or deletion.
853
869
  *
854
870
  * Copy fallback never follows source links, rejects every absolute link form, and permits a relative link
855
871
  * only when its completed staging target is both lexically and physically contained by that staging tree.
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { ExecutionCompletedSchema, EffectivePermissionSchema, } from "./execution-protocol.js";
6
6
  import { JournalLockedError, createJournalLease, } from "./execution-journal-lock.js";
7
+ import { provePosixProcessGroupStopped } from "./execution-posix-stop-proof.js";
7
8
  export { JournalLockedError, JournalLockCorruptionError } from "./execution-journal-lock.js";
8
9
  const ExecutionIdSchema = z.string().uuid();
9
10
  const TimestampSchema = z.string().datetime({ offset: true });
@@ -239,6 +240,22 @@ export function createProcessController(dependencies = {}) {
239
240
  };
240
241
  return {
241
242
  inspectIdentity,
243
+ ownedTreeExists: dependencies.ownedTreeExists ?? (async (pid) => {
244
+ if (!supervisorTree || platform === "win32")
245
+ return (await inspectIdentity(pid)) !== null;
246
+ try {
247
+ process.kill(-pid, 0);
248
+ return true;
249
+ }
250
+ catch (error) {
251
+ const code = errorCode(error);
252
+ if (code === "ESRCH")
253
+ return false;
254
+ if (code === "EPERM")
255
+ return true;
256
+ throw error;
257
+ }
258
+ }),
242
259
  signal: dependencies.signal ?? (async (pid, signal) => {
243
260
  if (!supervisorTree) {
244
261
  process.kill(pid, signal);
@@ -470,6 +487,7 @@ export function createExecutionJournal(agentsRoot, options = {}) {
470
487
  }
471
488
  };
472
489
  const pruneInternal = async () => {
490
+ const protectedExecutionIds = new Set(options.protectedExecutionIds?.() ?? []);
473
491
  const entries = await readAll();
474
492
  const acknowledged = entries
475
493
  .filter((entry) => (entry.state === "completed" || entry.state === "interrupted")
@@ -477,9 +495,12 @@ export function createExecutionJournal(agentsRoot, options = {}) {
477
495
  .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)
478
496
  || left.executionId.localeCompare(right.executionId));
479
497
  const cutoff = now().valueOf() - retentionDays * 24 * 60 * 60 * 1_000;
480
- const expired = acknowledged.filter((entry) => Date.parse(entry.updatedAt) < cutoff);
481
- const retained = acknowledged.filter((entry) => Date.parse(entry.updatedAt) >= cutoff);
482
- const overLimit = retained.slice(0, Math.max(0, retained.length - maxEntries));
498
+ const unprotected = acknowledged.filter((entry) => !protectedExecutionIds.has(entry.executionId));
499
+ const protectedCount = acknowledged.length - unprotected.length;
500
+ const expired = unprotected.filter((entry) => Date.parse(entry.updatedAt) < cutoff);
501
+ const retained = unprotected.filter((entry) => Date.parse(entry.updatedAt) >= cutoff);
502
+ const unprotectedLimit = Math.max(0, maxEntries - protectedCount);
503
+ const overLimit = retained.slice(0, Math.max(0, retained.length - unprotectedLimit));
483
504
  const toDelete = new Set([...expired, ...overLimit].map((entry) => entry.executionId));
484
505
  for (const executionId of toDelete) {
485
506
  await unlink(recordPath(executionId));
@@ -546,12 +567,16 @@ export function createExecutionJournal(agentsRoot, options = {}) {
546
567
  });
547
568
  };
548
569
  const inspectForRecovery = async (pid) => {
570
+ let identity;
549
571
  try {
550
- return await processController.inspectIdentity(pid);
572
+ identity = await processController.inspectIdentity(pid);
551
573
  }
552
574
  catch (error) {
553
575
  throw new JournalRecoveryError(`Failed to inspect running process ${pid}`, error);
554
576
  }
577
+ if (identity?.trim() === "")
578
+ throw new JournalRecoveryError(`Process ${pid} has no identity`);
579
+ return identity;
555
580
  };
556
581
  const waitForRecovery = async (milliseconds, phase) => {
557
582
  try {
@@ -566,6 +591,17 @@ export function createExecutionJournal(agentsRoot, options = {}) {
566
591
  throw new JournalRecoveryError(`Execution ${entry.executionId} lacks process identity`);
567
592
  }
568
593
  const { pid, processIdentity } = entry;
594
+ if (platform !== "win32") {
595
+ await provePosixProcessGroupStopped({
596
+ pid,
597
+ expectedIdentity: processIdentity,
598
+ controller: processController,
599
+ terminationGraceMs,
600
+ killVerificationDelayMs,
601
+ failure: (message, cause) => new JournalRecoveryError(message, cause),
602
+ });
603
+ return;
604
+ }
569
605
  const initialIdentity = await inspectForRecovery(pid);
570
606
  if (initialIdentity === null || initialIdentity !== processIdentity)
571
607
  return;
@@ -0,0 +1,82 @@
1
+ function errorCode(error) {
2
+ return error instanceof Error && "code" in error && (typeof error.code === "string"
3
+ || typeof error.code === "number")
4
+ ? error.code
5
+ : undefined;
6
+ }
7
+ /**
8
+ * Proves a POSIX supervisor process group is gone before its journal is made terminal.
9
+ * POSIX does not reuse a PGID while that group still exists. A live leader with a
10
+ * different identity therefore proves the journal-owned group has already ended. A
11
+ * missing leader while the PGID remains live is not an ownership proof: there is no
12
+ * portable primitive that atomically binds a later group signal to the journal owner,
13
+ * so recovery fails closed without signaling that group.
14
+ */
15
+ export async function provePosixProcessGroupStopped(options) {
16
+ const { controller, expectedIdentity, failure, pid } = options;
17
+ const inspect = async () => {
18
+ let identity;
19
+ try {
20
+ identity = await controller.inspectIdentity(pid);
21
+ }
22
+ catch (error) {
23
+ throw failure(`Failed to inspect running process ${pid}`, error);
24
+ }
25
+ if (identity !== null && identity.trim().length === 0) {
26
+ throw failure(`Running process ${pid} has an unverifiable identity`);
27
+ }
28
+ return identity;
29
+ };
30
+ const treeExists = async () => {
31
+ try {
32
+ return await controller.ownedTreeExists(pid);
33
+ }
34
+ catch (error) {
35
+ throw failure(`Failed to inspect owned process tree ${pid}`, error);
36
+ }
37
+ };
38
+ const wait = async (milliseconds, phase) => {
39
+ try {
40
+ await controller.wait(milliseconds);
41
+ }
42
+ catch (error) {
43
+ throw failure(`Failed while waiting ${phase}`, error);
44
+ }
45
+ };
46
+ const ownedGroupExists = async () => {
47
+ const currentIdentity = await inspect();
48
+ if (currentIdentity !== null && currentIdentity !== expectedIdentity)
49
+ return false;
50
+ const exists = await treeExists();
51
+ if (!exists)
52
+ return false;
53
+ if (currentIdentity === null) {
54
+ throw failure(`Cannot prove ownership of live process group ${pid} without its leader`);
55
+ }
56
+ return true;
57
+ };
58
+ const signal = async (requestedSignal) => {
59
+ try {
60
+ await controller.signal(pid, requestedSignal);
61
+ return false;
62
+ }
63
+ catch (error) {
64
+ if (errorCode(error) === "ESRCH" && !(await ownedGroupExists()))
65
+ return true;
66
+ throw failure(`Failed to ${requestedSignal === "SIGTERM" ? "terminate" : "kill"} process ${pid}`, error);
67
+ }
68
+ };
69
+ if (!(await ownedGroupExists()))
70
+ return;
71
+ if (await signal("SIGTERM"))
72
+ return;
73
+ await wait(options.terminationGraceMs, "for process-group termination");
74
+ if (!(await ownedGroupExists()))
75
+ return;
76
+ if (await signal("SIGKILL"))
77
+ return;
78
+ await wait(options.killVerificationDelayMs, "to verify process-group kill");
79
+ if (!(await ownedGroupExists()))
80
+ return;
81
+ throw failure(`Process group ${pid} still exists after SIGKILL`);
82
+ }
@@ -14,10 +14,13 @@ import { readBoundImDecisionFile, resetBoundImDecisionFile } from "./bound-im-de
14
14
  import { RuntimeCancelledError } from "./runtime-cancellation.js";
15
15
  import { supervisorLaunch } from "./supervised-runtime.js";
16
16
  import { appendAgentMemoryContext } from "./agent-memory/policy.js";
17
+ import { ProjectSkillRuntimeOwnershipUnverifiedError, } from "./project-skills/reconciler.js";
17
18
  import { projectSkillExecutionProjection, projectSkillProjectionErrorCode } from "./project-skills/execution-adapter.js";
19
+ import { redactProjectSkillRuntimeRootError, } from "./project-skills/runtime-launch.js";
18
20
  import { createProjectRegistry } from "./project-skills/registry.js";
19
21
  import { ProjectContextUnavailableError, resolveProjectContext } from "./project-workspaces/resolver.js";
20
22
  import { PROJECT_WORKSPACES_CAPABILITY } from "./machine-info.js";
23
+ import { PROJECT_SKILL_PROJECTION_V2_CAPABILITY } from "./project-skills/types.js";
21
24
  export { supervisorLaunch } from "./supervised-runtime.js";
22
25
  const ACTIVITY_KIND = {
23
26
  init: "working",
@@ -217,6 +220,10 @@ function rejection(executionId, reason, message, at) {
217
220
  export function projectWorkspaceCapabilityRejection(spec, capabilities, at) {
218
221
  const projectContext = spec.workspace.projectContext;
219
222
  const nativeRuntime = spec.runtime.name === "codex" || spec.runtime.name === "claude";
223
+ if (spec.agent.projectSkillBindingGeneration !== undefined
224
+ && (!nativeRuntime || !capabilities?.includes(PROJECT_SKILL_PROJECTION_V2_CAPABILITY))) {
225
+ return rejection(spec.executionId, "capability_missing", `${PROJECT_SKILL_PROJECTION_V2_CAPABILITY} is unavailable`, at);
226
+ }
220
227
  return projectContext !== undefined
221
228
  && projectContext.projectIds.length > 0
222
229
  && (!nativeRuntime || !capabilities?.includes(PROJECT_WORKSPACES_CAPABILITY))
@@ -423,6 +430,21 @@ export async function runExecution(config, input, dependencies) {
423
430
  const resetBoundImDecision = dependencies.resetBoundImDecision ?? resetBoundImDecisionFile;
424
431
  const telemetry = new TelemetryQueue(dependencies.report, bestEffortTimeoutMs, positiveTelemetryLimit(dependencies.telemetryMaxPendingFrames, DEFAULT_TELEMETRY_MAX_PENDING_FRAMES), Math.max(config.executionLimits.maxEventBytes, positiveTelemetryLimit(dependencies.telemetryMaxPendingBytes, DEFAULT_TELEMETRY_MAX_PENDING_BYTES)));
425
432
  const supervisorState = { active: null, abortOnce: null };
433
+ const proveActiveSupervisorStopped = async (error, retainProjectSkillLease = false) => {
434
+ if (error instanceof ProjectSkillRuntimeOwnershipUnverifiedError)
435
+ throw error;
436
+ if (supervisorState.active === null || supervisorState.abortOnce === null)
437
+ return;
438
+ try {
439
+ await supervisorState.abortOnce();
440
+ }
441
+ catch (abortError) {
442
+ if (retainProjectSkillLease)
443
+ throw new ProjectSkillRuntimeOwnershipUnverifiedError();
444
+ const detail = abortError instanceof Error ? abortError.message : String(abortError);
445
+ throw new AggregateError([error, abortError], `Failed to stop the execution supervisor: ${detail}`);
446
+ }
447
+ };
426
448
  let launchClosed = false;
427
449
  const launchAttempts = new Set();
428
450
  const closeLaunchGate = async () => {
@@ -557,6 +579,22 @@ export async function runExecution(config, input, dependencies) {
557
579
  },
558
580
  } : {}),
559
581
  };
582
+ // This is the sole production brand owner. Pre-spawn rejection has no process; startGuarded
583
+ // aborts its own failures, and this wrapper awaits full-tree stop for every later rejection.
584
+ const projectSkillLeaseSafeLaunchOwner = Object.freeze({
585
+ claim(launch) {
586
+ const safeLaunch = async (root) => {
587
+ try {
588
+ return await launch(root);
589
+ }
590
+ catch (error) {
591
+ await proveActiveSupervisorStopped(error, true);
592
+ throw redactProjectSkillRuntimeRootError(error, root);
593
+ }
594
+ };
595
+ return safeLaunch;
596
+ },
597
+ });
560
598
  const localDependencies = {
561
599
  ...(dependencies.cancellation === undefined ? {} : { cancellation: dependencies.cancellation }),
562
600
  ...(dependencies.startupGate === undefined ? {} : { startupGate: dependencies.startupGate }),
@@ -564,6 +602,7 @@ export async function runExecution(config, input, dependencies) {
564
602
  ? {}
565
603
  : { startupTimeoutMs: dependencies.startupTimeoutMs }),
566
604
  ...(dependencies.projectSkills === undefined ? {} : { projectSkills: dependencies.projectSkills }),
605
+ projectSkillLeaseSafeLaunchOwner,
567
606
  ...(dependencies.abilityRelease === undefined ? {} : { abilityRelease: dependencies.abilityRelease }),
568
607
  launchRuntime: async (request) => {
569
608
  if (launchClosed || dependencies.cancellation?.isRequested())
@@ -574,7 +613,26 @@ export async function runExecution(config, input, dependencies) {
574
613
  try {
575
614
  const processStartedAt = now().toISOString();
576
615
  const launchControl = { cancel: null };
577
- const guarded = await dependencies.journal.startGuarded(spec.executionId, processStartedAt, () => startSupervisor(supervisorLaunch(request)), {
616
+ const startOwnedSupervisor = async () => {
617
+ const handle = await startSupervisor(supervisorLaunch(request));
618
+ let abortPromise = null;
619
+ const abortOnce = () => {
620
+ if (abortPromise === null) {
621
+ try {
622
+ abortPromise = Promise.resolve(handle.abort());
623
+ }
624
+ catch (error) {
625
+ abortPromise = Promise.reject(error);
626
+ }
627
+ }
628
+ return abortPromise;
629
+ };
630
+ const ownedHandle = { ...handle, abort: abortOnce };
631
+ supervisorState.active = ownedHandle;
632
+ supervisorState.abortOnce = abortOnce;
633
+ return ownedHandle;
634
+ };
635
+ const guarded = await dependencies.journal.startGuarded(spec.executionId, processStartedAt, startOwnedSupervisor, {
578
636
  beforeRelease: ({ handle, abort }) => {
579
637
  supervisorState.active = handle;
580
638
  let stopPromise = null;
@@ -736,17 +794,19 @@ export async function runExecution(config, input, dependencies) {
736
794
  const cancelled = error instanceof ExecutionCancelledError || error instanceof RuntimeCancelledError;
737
795
  if (cancelled) {
738
796
  await closeLaunchGate();
739
- await dependencies.cancellation?.waitForStop();
740
- }
741
- else if (supervisorState.active !== null && supervisorState.abortOnce !== null) {
742
797
  try {
743
- await supervisorState.abortOnce();
798
+ await dependencies.cancellation?.waitForStop();
744
799
  }
745
- catch (abortError) {
746
- const detail = abortError instanceof Error ? abortError.message : String(abortError);
747
- throw new AggregateError([error, abortError], `Failed to stop the execution supervisor: ${detail}`);
800
+ catch (stopError) {
801
+ if (spec.agent.projectSkillBindingGeneration !== undefined) {
802
+ throw new ProjectSkillRuntimeOwnershipUnverifiedError();
803
+ }
804
+ throw stopError;
748
805
  }
749
806
  }
807
+ else {
808
+ await proveActiveSupervisorStopped(error, spec.agent.projectSkillBindingGeneration !== undefined);
809
+ }
750
810
  completion = cancelled
751
811
  ? ExecutionCompletedSchema.parse({
752
812
  type: "execution:completed",