@gajae-code/coding-agent 0.12.6 → 0.12.8

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 (150) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/dist/types/async/job-manager.d.ts +36 -1
  3. package/dist/types/commands/notify.d.ts +4 -0
  4. package/dist/types/config/file-lock.d.ts +3 -0
  5. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  6. package/dist/types/config/model-registry.d.ts +2 -0
  7. package/dist/types/config/resolve-config-value.d.ts +2 -1
  8. package/dist/types/config/settings-schema.d.ts +17 -1
  9. package/dist/types/eval/backend.d.ts +2 -2
  10. package/dist/types/exec/bash-executor.d.ts +8 -10
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -36
  12. package/dist/types/internal-urls/registry-helpers.d.ts +0 -18
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  15. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  16. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  17. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  18. package/dist/types/sdk/broker/broker.d.ts +5 -1
  19. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -3
  21. package/dist/types/sdk/bus/config.d.ts +75 -113
  22. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  23. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  24. package/dist/types/sdk/bus/index.d.ts +1 -1
  25. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  26. package/dist/types/sdk/bus/notification-service.d.ts +31 -4
  27. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  28. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  29. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
  31. package/dist/types/sdk/client/discovery.d.ts +2 -1
  32. package/dist/types/sdk/host/host.d.ts +2 -23
  33. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  34. package/dist/types/sdk/host/reverse-leases.d.ts +4 -7
  35. package/dist/types/sdk/index.d.ts +1 -0
  36. package/dist/types/sdk/providers.d.ts +22 -0
  37. package/dist/types/sdk/session-directory.d.ts +1 -1
  38. package/dist/types/session/artifacts.d.ts +5 -8
  39. package/dist/types/session/client-bridge.d.ts +0 -14
  40. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  41. package/dist/types/session/internal/managed-session-storage.d.ts +11 -4
  42. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -0
  43. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  44. package/dist/types/session/messages.d.ts +1 -4
  45. package/dist/types/session/session-manager.d.ts +7 -6
  46. package/dist/types/session/session-storage.d.ts +2 -0
  47. package/dist/types/session/streaming-output.d.ts +0 -32
  48. package/dist/types/task/receipt.d.ts +1 -2
  49. package/dist/types/task/types.d.ts +43 -178
  50. package/dist/types/tools/bash.d.ts +1 -1
  51. package/dist/types/tools/fetch.d.ts +0 -3
  52. package/dist/types/tools/output-meta.d.ts +5 -37
  53. package/dist/types/tools/subagent.d.ts +15 -0
  54. package/dist/types/tools/todo-write.d.ts +3 -0
  55. package/dist/types/tools/tool-result.d.ts +0 -1
  56. package/package.json +10 -8
  57. package/scripts/build-sdk-package-smoke.ts +51 -0
  58. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  59. package/src/async/job-manager.ts +430 -46
  60. package/src/cli/notify-cli.ts +204 -94
  61. package/src/commands/notify.ts +19 -8
  62. package/src/config/file-lock.ts +6 -0
  63. package/src/config/model-discovery-manager.ts +50 -8
  64. package/src/config/model-registry.ts +862 -75
  65. package/src/config/resolve-config-value.ts +10 -8
  66. package/src/config/settings-schema.ts +25 -15
  67. package/src/config/settings.ts +15 -15
  68. package/src/cursor.ts +13 -20
  69. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -3
  70. package/src/eval/backend.ts +2 -2
  71. package/src/exec/bash-executor.ts +151 -425
  72. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  73. package/src/gjc-runtime/ultragoal-runtime.ts +3 -280
  74. package/src/gjc-runtime/workflow-manifest.generated.json +1 -40
  75. package/src/gjc-runtime/workflow-manifest.ts +0 -12
  76. package/src/internal-urls/agent-protocol.ts +38 -98
  77. package/src/internal-urls/artifact-protocol.ts +17 -28
  78. package/src/internal-urls/docs-index.generated.ts +7 -7
  79. package/src/internal-urls/registry-helpers.ts +0 -62
  80. package/src/main.ts +43 -7
  81. package/src/modes/DESIGN.md +11 -11
  82. package/src/modes/acp/acp-agent.ts +0 -24
  83. package/src/modes/acp/acp-client-bridge.ts +11 -154
  84. package/src/modes/components/hook-selector.ts +155 -44
  85. package/src/modes/components/model-selector.ts +242 -49
  86. package/src/modes/components/notifications-settings-editor.ts +631 -85
  87. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  88. package/src/modes/controllers/selector-controller.ts +464 -50
  89. package/src/prompts/tools/bash.md +2 -2
  90. package/src/sdk/acp/adapter.ts +9 -200
  91. package/src/sdk/broker/broker.ts +54 -10
  92. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  93. package/src/sdk/broker/lifecycle.ts +47 -50
  94. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  95. package/src/sdk/bus/chat-daemon-control.ts +35 -8
  96. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  97. package/src/sdk/bus/config.ts +534 -286
  98. package/src/sdk/bus/discord-live-provider.ts +57 -2
  99. package/src/sdk/bus/discord-provider.ts +21 -0
  100. package/src/sdk/bus/index.ts +105 -359
  101. package/src/sdk/bus/notification-orchestration.ts +371 -68
  102. package/src/sdk/bus/notification-service.ts +431 -130
  103. package/src/sdk/bus/session-control.ts +119 -44
  104. package/src/sdk/bus/slack-live-provider.ts +78 -2
  105. package/src/sdk/bus/slack-provider.ts +24 -0
  106. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  107. package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
  108. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  109. package/src/sdk/bus/telegram-daemon.ts +23 -15
  110. package/src/sdk/client/discovery.ts +10 -4
  111. package/src/sdk/host/host.ts +9 -187
  112. package/src/sdk/host/query/handlers.ts +20 -1
  113. package/src/sdk/host/reverse-leases.ts +42 -249
  114. package/src/sdk/index.ts +1 -0
  115. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  116. package/src/sdk/protocol/operation-registry.ts +5 -2
  117. package/src/sdk/providers.ts +51 -0
  118. package/src/sdk/session-directory.ts +1 -0
  119. package/src/sdk/session.ts +10 -7
  120. package/src/session/agent-session.ts +59 -202
  121. package/src/session/artifacts.ts +74 -211
  122. package/src/session/client-bridge.ts +0 -15
  123. package/src/session/internal/managed-session-scope.ts +981 -450
  124. package/src/session/internal/managed-session-storage.ts +362 -124
  125. package/src/session/internal/native-publish-outcome.ts +3 -1
  126. package/src/session/internal/session-open-errors.ts +11 -0
  127. package/src/session/messages.ts +7 -32
  128. package/src/session/session-manager.ts +48 -15
  129. package/src/session/session-storage.ts +72 -5
  130. package/src/session/streaming-output.ts +40 -198
  131. package/src/slash-commands/builtin-registry.ts +82 -4
  132. package/src/task/index.ts +237 -65
  133. package/src/task/receipt.ts +2 -4
  134. package/src/task/render.ts +0 -30
  135. package/src/task/types.ts +10 -34
  136. package/src/tools/ask.ts +1 -1
  137. package/src/tools/bash.ts +239 -818
  138. package/src/tools/eval.ts +4 -99
  139. package/src/tools/fetch.ts +15 -77
  140. package/src/tools/find.ts +3 -7
  141. package/src/tools/output-meta.ts +114 -696
  142. package/src/tools/search.ts +4 -9
  143. package/src/tools/subagent.ts +97 -75
  144. package/src/tools/telegram-send.ts +4 -2
  145. package/src/tools/todo-write.ts +47 -0
  146. package/src/tools/tool-result.ts +0 -1
  147. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +0 -83
  148. package/dist/types/utils/artifact-id.d.ts +0 -2
  149. package/src/gjc-runtime/ultragoal-review-source.ts +0 -378
  150. package/src/utils/artifact-id.ts +0 -6
@@ -6,6 +6,8 @@ import {
6
6
  applyOwnerOnlyFdSecurity,
7
7
  applyOwnerOnlyPathSecurity,
8
8
  exactRemoveDirectoryTree,
9
+ exactReplacePath,
10
+ exactRestore,
9
11
  exactUnlink,
10
12
  type NativeDirectoryTreeSnapshot,
11
13
  type NativeOwnerOnlySecurityResult,
@@ -34,7 +36,6 @@ const LOCK_LEASE_MS = 60_000;
34
36
  const LOCK_HEARTBEAT_MS = 10_000;
35
37
  const LOCK_WAIT_MS = 5_000;
36
38
 
37
- const LOCK_STALE_RECHECK_MS = 100;
38
39
  export class ManagedPublishError extends Error {
39
40
  readonly classification:
40
41
  | "destination_conflict"
@@ -138,7 +139,38 @@ export interface ManagedStorageLock {
138
139
 
139
140
  export interface ManagedFileSnapshot {
140
141
  bytes: Buffer;
141
- identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint; ctimeNs: bigint; sha256: string };
142
+ identity: {
143
+ dev: bigint;
144
+ ino: bigint;
145
+ nlink: bigint;
146
+ size: number;
147
+ mtimeNs: bigint;
148
+ ctimeNs: bigint;
149
+ sha256: string;
150
+ };
151
+ }
152
+
153
+ type ReplacementCleanupReceipt = {
154
+ version: 1;
155
+ predecessor: string;
156
+ successor: string;
157
+ identity: {
158
+ dev: string;
159
+ ino: string;
160
+ nlink: string;
161
+ size: string;
162
+ mtimeNs: string;
163
+ ctimeNs: string;
164
+ sha256: string;
165
+ };
166
+ };
167
+
168
+ const U64_MAX = 18_446_744_073_709_551_615n;
169
+
170
+ function parseCanonicalU64(value: unknown): bigint | undefined {
171
+ if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) return undefined;
172
+ const parsed = BigInt(value);
173
+ return parsed <= U64_MAX ? parsed : undefined;
142
174
  }
143
175
 
144
176
  const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
@@ -264,8 +296,19 @@ type LockRecord = {
264
296
  createdAt: number;
265
297
  heartbeatAt: number;
266
298
  leaseExpiresAt: number;
299
+ released?: boolean;
267
300
  };
268
301
 
302
+ export interface ManagedLockRetirementTestEvent {
303
+ readonly path: string;
304
+ readonly attemptId: string;
305
+ }
306
+
307
+ /** Test-only seam immediately before exact retirement of one observed lock identity. */
308
+ export const ManagedLockTestHooks: {
309
+ beforeObservedRetirement?: (event: ManagedLockRetirementTestEvent) => void;
310
+ } = {};
311
+
269
312
  /** Captured configured-root authority for managed paths only. */
270
313
  export interface ManagedDirectoryRoot {
271
314
  readonly canonicalPath: string;
@@ -475,11 +518,11 @@ export class ManagedSessionDescendantStore {
475
518
  readonly #authority: RecoveryFsRoot | undefined;
476
519
  #ownsAuthority = false;
477
520
  #closed = false;
521
+ #reconcilingReplacementCleanup = false;
478
522
  readonly #authorityBaseDir: string;
479
523
  /** Logical profile root inherited by nested managed session destinations. */
480
524
  readonly #profileAgentDir: string;
481
525
  readonly #subtreeRoot: ManagedDirectoryRoot;
482
- #portableMutationFence?: ManagedStorageLock;
483
526
 
484
527
  constructor(
485
528
  root: ManagedDirectoryRoot,
@@ -548,40 +591,6 @@ export class ManagedSessionDescendantStore {
548
591
  return this.#policy;
549
592
  }
550
593
 
551
- get hasRetainedAuthority(): boolean {
552
- return this.#authority !== undefined;
553
- }
554
-
555
- async acquireMutationFence(name: string): Promise<ManagedStorageLock | undefined> {
556
- if (this.#authority) return undefined;
557
- if (!/^[a-zA-Z0-9_.-]+$/.test(name)) throw new Error("invalid_mutation_fence");
558
- this.#assertBound();
559
- if (this.#portableMutationFence) throw new Error("migration_busy");
560
- const lock = await acquireManagedLock(this.#baseDir, `.gjc-${name}`, this.#subtreeRoot, this.#policy);
561
- try {
562
- this.#assertBound();
563
- lock.assertOwned();
564
- } catch (error) {
565
- await lock.release();
566
- throw error;
567
- }
568
- this.#portableMutationFence = lock;
569
- return {
570
- path: lock.path,
571
- attemptId: lock.attemptId,
572
- assertOwned: () => {
573
- this.#assertBound();
574
- lock.assertOwned();
575
- },
576
- release: async () => {
577
- try {
578
- await lock.release();
579
- } finally {
580
- if (this.#portableMutationFence === lock) this.#portableMutationFence = undefined;
581
- }
582
- },
583
- };
584
- }
585
594
  get profileAgentDir(): string {
586
595
  return this.#profileAgentDir;
587
596
  }
@@ -659,18 +668,7 @@ export class ManagedSessionDescendantStore {
659
668
  this.#assertBound();
660
669
  }
661
670
  #assertBound(): void {
662
- if (!this.#authority) {
663
- const named = fs.lstatSync(this.#baseDir, { bigint: true });
664
- if (
665
- !named.isDirectory() ||
666
- named.isSymbolicLink() ||
667
- named.dev !== this.#subtreeRoot.dev ||
668
- named.ino !== this.#subtreeRoot.ino
669
- ) {
670
- throw new Error("Managed descendant root binding changed");
671
- }
672
- return;
673
- }
671
+ if (!this.#authority) return;
674
672
  const named = fs.lstatSync(this.#baseDir, { bigint: true });
675
673
  const retained =
676
674
  this.#authorityBaseDir === this.#baseDir
@@ -696,7 +694,86 @@ export class ManagedSessionDescendantStore {
696
694
  }
697
695
  }
698
696
 
697
+ #reconcileReplacementCleanupReceipts(): void {
698
+ if (this.#reconcilingReplacementCleanup) return;
699
+ this.#reconcilingReplacementCleanup = true;
700
+ try {
701
+ for (const name of fs.readdirSync(this.#baseDir)) {
702
+ const match = /^\.gjc-replace-cleanup-([0-9a-f]+)-([0-9a-f]+)\.json$/.exec(name);
703
+ if (!match) continue;
704
+ const receiptPath = path.join(this.#baseDir, name);
705
+ const parsed = JSON.parse(
706
+ captureManagedFileNoFollow(receiptPath).bytes.toString("utf8"),
707
+ ) as Partial<ReplacementCleanupReceipt>;
708
+ const identity = parsed.identity;
709
+ const dev = parseCanonicalU64(identity?.dev);
710
+ const ino = parseCanonicalU64(identity?.ino);
711
+ const nlink = parseCanonicalU64(identity?.nlink);
712
+ const size = parseCanonicalU64(identity?.size);
713
+ const mtimeNs = parseCanonicalU64(identity?.mtimeNs);
714
+ const ctimeNs = parseCanonicalU64(identity?.ctimeNs);
715
+ const expectedPredecessor =
716
+ dev !== undefined && ino !== undefined
717
+ ? path.join(this.#baseDir, `.gjc-exact-replace-destination-${dev.toString(16)}-${ino.toString(16)}`)
718
+ : undefined;
719
+ if (
720
+ parsed.version !== 1 ||
721
+ dev === undefined ||
722
+ ino === undefined ||
723
+ nlink === undefined ||
724
+ size === undefined ||
725
+ mtimeNs === undefined ||
726
+ ctimeNs === undefined ||
727
+ typeof identity?.sha256 !== "string" ||
728
+ !match[1] ||
729
+ !match[2] ||
730
+ BigInt(`0x${match[1]}`) !== dev ||
731
+ BigInt(`0x${match[2]}`) !== ino ||
732
+ typeof parsed.predecessor !== "string" ||
733
+ path.resolve(parsed.predecessor) !== expectedPredecessor ||
734
+ typeof parsed.successor !== "string" ||
735
+ path.dirname(path.resolve(parsed.successor)) !== this.#baseDir
736
+ )
737
+ throw new Error("managed_replace_cleanup_receipt_invalid");
738
+ const retired = exactUnlink(expectedPredecessor, {
739
+ dev,
740
+ ino,
741
+ nlink,
742
+ parentDev: this.#subtreeRoot.dev,
743
+ parentIno: this.#subtreeRoot.ino,
744
+ size,
745
+ mtimeNs,
746
+ sha256: identity.sha256,
747
+ quarantineName: `.gjc-replace-retry-${dev.toString(16)}-${ino.toString(16)}`,
748
+ });
749
+ if (!retired.ok) throw new Error(`managed_replace_cleanup_pending:${retired.code ?? "unknown"}`);
750
+ const receipt = captureManagedFileNoFollow(receiptPath);
751
+ const removed = exactUnlink(receiptPath, {
752
+ dev: receipt.identity.dev,
753
+ ino: receipt.identity.ino,
754
+ nlink: receipt.identity.nlink,
755
+ parentDev: this.#subtreeRoot.dev,
756
+ parentIno: this.#subtreeRoot.ino,
757
+ size: BigInt(receipt.identity.size),
758
+ mtimeNs: receipt.identity.mtimeNs,
759
+ sha256: receipt.identity.sha256,
760
+ quarantineName: `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}`,
761
+ });
762
+ if (!removed.ok) throw new Error(`managed_replace_receipt_cleanup_pending:${removed.code ?? "unknown"}`);
763
+ }
764
+ } finally {
765
+ this.#reconcilingReplacementCleanup = false;
766
+ }
767
+ }
768
+
769
+ #beforeMutation(): void {
770
+ this.#assertBound();
771
+ this.#reconcileReplacementCleanupReceipts();
772
+ this.#assertBound();
773
+ }
774
+
699
775
  ensureDirectory(relativePath = ""): ManagedDirectoryRoot {
776
+ this.#beforeMutation();
700
777
  this.#assertBound();
701
778
  if (this.#authority) {
702
779
  const relative = this.#relative(this.#resolve(relativePath));
@@ -716,50 +793,42 @@ export class ManagedSessionDescendantStore {
716
793
  }
717
794
 
718
795
  async publishNoReplace(relativePath: string, bytes: Uint8Array): Promise<void> {
719
- this.#assertBound();
720
- this.#portableMutationFence?.assertOwned();
796
+ this.#beforeMutation();
721
797
  const resolved = this.#resolve(relativePath);
722
798
  if (this.#authority) {
799
+ this.#assertBound();
723
800
  this.#publishRetainedNoReplace(this.#relative(resolved), bytes);
724
801
  this.#assertBound();
725
802
  return;
726
803
  }
727
- await publishManagedFileNoReplace(
728
- resolved,
729
- bytes,
730
- this.#portableMutationFence ? () => this.#portableMutationFence?.assertOwned() : undefined,
731
- this.#subtreeRoot,
732
- this.#policy,
733
- );
734
- this.#assertBound();
735
- this.#portableMutationFence?.assertOwned();
804
+ await publishManagedFileNoReplace(resolved, bytes, undefined, this.#root, this.#policy);
736
805
  }
737
806
 
738
807
  publishNoReplaceSync(relativePath: string, bytes: Uint8Array): void {
739
- this.#assertBound();
740
- this.#portableMutationFence?.assertOwned();
808
+ this.#beforeMutation();
741
809
  const resolved = this.#resolve(relativePath);
742
810
  if (!this.#authority) {
743
- publishManagedFileNoReplaceSync(
744
- resolved,
745
- bytes,
746
- this.#subtreeRoot,
747
- this.#policy,
748
- this.#portableMutationFence ? () => this.#portableMutationFence?.assertOwned() : undefined,
749
- );
811
+ publishManagedFileNoReplaceSync(resolved, bytes, this.#root, this.#policy);
750
812
  this.#assertBound();
751
- this.#portableMutationFence?.assertOwned();
752
813
  return;
753
814
  }
815
+ this.#assertBound();
754
816
  this.#publishRetainedNoReplace(this.#relative(resolved), bytes);
755
817
  this.#assertBound();
756
818
  }
757
819
 
758
820
  async replace(relativePath: string, bytes: Uint8Array): Promise<void> {
821
+ this.#beforeMutation();
759
822
  this.#assertBound();
760
823
  const resolved = this.#resolve(relativePath);
761
824
  if (!this.#authority) {
762
- await replaceManagedFile(resolved, bytes, this.#subtreeRoot, this.#policy);
825
+ try {
826
+ const expected = captureManagedFileNoFollow(resolved);
827
+ replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
828
+ } catch (error) {
829
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
830
+ await publishManagedFileNoReplace(resolved, bytes, undefined, this.#subtreeRoot, this.#policy);
831
+ }
763
832
  this.#assertBound();
764
833
  return;
765
834
  }
@@ -769,17 +838,28 @@ export class ManagedSessionDescendantStore {
769
838
  }
770
839
 
771
840
  replaceExpected(relativePath: string, bytes: Uint8Array, expected: ManagedFileSnapshot): void {
841
+ this.#beforeMutation();
772
842
  this.#assertBound();
773
843
  const resolved = this.#resolve(relativePath);
774
844
  if (!this.#authority) {
775
- const fence = this.#portableMutationFence;
776
- if (!fence) throw new Error("managed_replace_exact_unavailable");
777
- fence.assertOwned();
778
845
  const current = captureManagedFileNoFollow(resolved);
779
846
  if (!sameIdentity(current.identity, expected.identity) || current.identity.sha256 !== expected.identity.sha256)
780
847
  throw new Error("managed_replace_identity_mismatch");
781
- replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, () => fence.assertOwned());
782
- fence.assertOwned();
848
+ replaceManagedFileSync(
849
+ resolved,
850
+ bytes,
851
+ this.#subtreeRoot,
852
+ this.#policy,
853
+ () => {
854
+ const currentAtCommit = captureManagedFileNoFollow(resolved);
855
+ if (
856
+ !sameIdentity(currentAtCommit.identity, expected.identity) ||
857
+ currentAtCommit.identity.sha256 !== expected.identity.sha256
858
+ )
859
+ throw new Error("managed_replace_identity_mismatch");
860
+ },
861
+ expected.identity,
862
+ );
783
863
  return;
784
864
  }
785
865
  const replaced = (this.#authority as RecoveryFsRoot & RetainedManagedReplacer).replaceManaged(
@@ -795,12 +875,18 @@ export class ManagedSessionDescendantStore {
795
875
  if (!replaced.ok) throw new Error(replaced.code ?? "managed_replace_failed");
796
876
  this.#assertBound();
797
877
  }
798
-
799
878
  replaceSync(relativePath: string, bytes: Uint8Array): void {
879
+ this.#beforeMutation();
800
880
  this.#assertBound();
801
881
  const resolved = this.#resolve(relativePath);
802
882
  if (!this.#authority) {
803
- replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy);
883
+ try {
884
+ const expected = captureManagedFileNoFollow(resolved);
885
+ replaceManagedFileSync(resolved, bytes, this.#subtreeRoot, this.#policy, undefined, expected.identity);
886
+ } catch (error) {
887
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
888
+ publishManagedFileNoReplaceSync(resolved, bytes, this.#subtreeRoot, this.#policy);
889
+ }
804
890
  this.#assertBound();
805
891
  return;
806
892
  }
@@ -809,6 +895,7 @@ export class ManagedSessionDescendantStore {
809
895
  }
810
896
 
811
897
  appendSync(relativePath: string, bytes: Uint8Array): void {
898
+ this.#beforeMutation();
812
899
  this.#assertBound();
813
900
  const resolved = this.#resolve(relativePath);
814
901
  let existing = this.readExpected(relativePath);
@@ -1006,6 +1093,7 @@ export class ManagedSessionDescendantStore {
1006
1093
  identity: {
1007
1094
  dev: BigInt(read.identity.dev),
1008
1095
  ino: BigInt(read.identity.ino),
1096
+ nlink: BigInt(read.identity.nlink),
1009
1097
  size: Number(read.identity.size),
1010
1098
  mtimeNs: BigInt(read.identity.mtimeNs),
1011
1099
  ctimeNs: BigInt(read.identity.ctimeNs),
@@ -1016,6 +1104,7 @@ export class ManagedSessionDescendantStore {
1016
1104
 
1017
1105
  /** Remove an exact captured file without reopening its pathname as authority. */
1018
1106
  removeExpected(relativePath: string, expected: ManagedFileSnapshot): void {
1107
+ this.#beforeMutation();
1019
1108
  this.#assertBound();
1020
1109
  if (!this.#authority) {
1021
1110
  const removed = exactUnlink(this.#resolve(relativePath), {
@@ -1055,6 +1144,7 @@ export class ManagedSessionDescendantStore {
1055
1144
  }
1056
1145
  /** Read and remove one managed descendant through retained authority. */
1057
1146
  async consume(relativePath: string): Promise<Uint8Array | null> {
1147
+ this.#beforeMutation();
1058
1148
  this.#assertBound();
1059
1149
  const resolved = this.#resolve(relativePath);
1060
1150
  if (this.#authority) {
@@ -1093,6 +1183,7 @@ export class ManagedSessionDescendantStore {
1093
1183
 
1094
1184
  /** Remove one managed descendant through retained authority when it exists. */
1095
1185
  async remove(relativePath: string): Promise<void> {
1186
+ this.#beforeMutation();
1096
1187
  await this.consume(relativePath);
1097
1188
  }
1098
1189
 
@@ -1116,6 +1207,7 @@ export class ManagedSessionDescendantStore {
1116
1207
  destinationRelativePath: string,
1117
1208
  snapshot: NativeDirectoryTreeSnapshot,
1118
1209
  ): Promise<void> {
1210
+ this.#beforeMutation();
1119
1211
  const actual = this.captureTree(sourceRelativePath);
1120
1212
  if (JSON.stringify(actual) !== JSON.stringify(snapshot)) throw new Error("artifact_source_changed");
1121
1213
  this.ensureDirectory(destinationRelativePath);
@@ -1162,6 +1254,7 @@ export class ManagedSessionDescendantStore {
1162
1254
  destinationRelativePath: string,
1163
1255
  expected: NativeDirectoryTreeSnapshot,
1164
1256
  ): NativeDirectoryTreeSnapshot {
1257
+ this.#beforeMutation();
1165
1258
  this.#assertBound();
1166
1259
  const moved = this.#authority
1167
1260
  ? this.#authority.renameManagedTreeNoReplace(
@@ -1189,6 +1282,7 @@ export class ManagedSessionDescendantStore {
1189
1282
  }
1190
1283
 
1191
1284
  removeTreeExpected(relativePath: string, expected: NativeDirectoryTreeSnapshot): void {
1285
+ this.#beforeMutation();
1192
1286
  this.#assertBound();
1193
1287
  if (!this.#authority) {
1194
1288
  const removed = exactRemoveDirectoryTree(this.#resolve(relativePath), expected);
@@ -1201,6 +1295,7 @@ export class ManagedSessionDescendantStore {
1201
1295
  this.#assertBound();
1202
1296
  }
1203
1297
  fsyncTree(): NativeDirectoryTreeSnapshot {
1298
+ this.#beforeMutation();
1204
1299
  this.#assertBound();
1205
1300
  if (!this.#authority) return fsyncManagedArtifactTree(this.#baseDir);
1206
1301
  const baseRelative = this.#relative(this.#baseDir);
@@ -1296,6 +1391,7 @@ function identity(stat: fs.BigIntStats, sha256 = ""): ManagedFileSnapshot["ident
1296
1391
  return {
1297
1392
  dev: stat.dev,
1298
1393
  ino: stat.ino,
1394
+ nlink: stat.nlink,
1299
1395
  size: Number(stat.size),
1300
1396
  mtimeNs: stat.mtimeNs,
1301
1397
  ctimeNs: stat.ctimeNs,
@@ -1307,6 +1403,7 @@ function sameIdentity(left: ManagedFileSnapshot["identity"], right: ManagedFileS
1307
1403
  return (
1308
1404
  left.dev === right.dev &&
1309
1405
  left.ino === right.ino &&
1406
+ left.nlink === right.nlink &&
1310
1407
  left.size === right.size &&
1311
1408
  left.mtimeNs === right.mtimeNs &&
1312
1409
  left.ctimeNs === right.ctimeNs
@@ -1331,11 +1428,9 @@ function isCtimeOnlyIdentityMismatch(
1331
1428
  return sameStableIdentityIgnoringCtime(observed, expected) && observed.ctimeNs !== expected.ctimeNs;
1332
1429
  }
1333
1430
 
1334
- function parseLock(pathname: string): LockRecord | undefined {
1431
+ function parseLockBytes(bytes: Uint8Array): LockRecord | undefined {
1335
1432
  try {
1336
- const stat = fs.lstatSync(pathname);
1337
- if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
1338
- const value: unknown = JSON.parse(fs.readFileSync(pathname, "utf8"));
1433
+ const value: unknown = JSON.parse(Buffer.from(bytes).toString("utf8"));
1339
1434
  if (!value || typeof value !== "object") return undefined;
1340
1435
  const record = value as Partial<LockRecord>;
1341
1436
  return typeof record.attemptId === "string" &&
@@ -1343,7 +1438,8 @@ function parseLock(pathname: string): LockRecord | undefined {
1343
1438
  typeof record.processStartId === "string" &&
1344
1439
  typeof record.leaseExpiresAt === "number" &&
1345
1440
  typeof record.heartbeatAt === "number" &&
1346
- typeof record.createdAt === "number"
1441
+ typeof record.createdAt === "number" &&
1442
+ (record.released === undefined || typeof record.released === "boolean")
1347
1443
  ? (record as LockRecord)
1348
1444
  : undefined;
1349
1445
  } catch {
@@ -1351,6 +1447,26 @@ function parseLock(pathname: string): LockRecord | undefined {
1351
1447
  }
1352
1448
  }
1353
1449
 
1450
+ function parseLock(pathname: string): LockRecord | undefined {
1451
+ try {
1452
+ const stat = fs.lstatSync(pathname);
1453
+ if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
1454
+ return parseLockBytes(fs.readFileSync(pathname));
1455
+ } catch {
1456
+ return undefined;
1457
+ }
1458
+ }
1459
+
1460
+ function captureLock(pathname: string): { record: LockRecord; snapshot: ManagedFileSnapshot } | undefined {
1461
+ try {
1462
+ const snapshot = captureManagedFileNoFollow(pathname);
1463
+ const record = parseLockBytes(snapshot.bytes);
1464
+ return record ? { record, snapshot } : undefined;
1465
+ } catch {
1466
+ return undefined;
1467
+ }
1468
+ }
1469
+
1354
1470
  function ownerDefinitelyGone(record: LockRecord): boolean {
1355
1471
  if (record.bootId && bootId() && record.bootId !== bootId()) return true;
1356
1472
  try {
@@ -1525,7 +1641,6 @@ export function publishManagedFileNoReplaceSync(
1525
1641
  bytes: Uint8Array,
1526
1642
  root?: ManagedDirectoryRoot,
1527
1643
  policy: ManagedSessionSecurityPolicy = "default",
1528
- assertOwned?: () => void,
1529
1644
  ): void {
1530
1645
  const parent = path.dirname(destination);
1531
1646
  ensureManagedDirectory(parent, root, policy);
@@ -1536,7 +1651,6 @@ export function publishManagedFileNoReplaceSync(
1536
1651
  let outcome: NativePublishOutcome | undefined;
1537
1652
 
1538
1653
  try {
1539
- assertOwned?.();
1540
1654
  fd = fs.openSync(
1541
1655
  staging,
1542
1656
  fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
@@ -1550,9 +1664,7 @@ export function publishManagedFileNoReplaceSync(
1550
1664
  const staged = fs.fstatSync(fd, { bigint: true });
1551
1665
  stagingIdentity = { dev: staged.dev, ino: staged.ino };
1552
1666
 
1553
- assertOwned?.();
1554
1667
  outcome = classifyNativePublishOutcome(renameNoReplacePath(staging, destination));
1555
- assertOwned?.();
1556
1668
  if (!outcome.ok) throw publishFailure(outcome);
1557
1669
 
1558
1670
  const named = fs.lstatSync(destination, { bigint: true });
@@ -1589,14 +1701,15 @@ export function replaceManagedFileSync(
1589
1701
  root: ManagedDirectoryRoot,
1590
1702
  policy: ManagedSessionSecurityPolicy = "default",
1591
1703
  assertFence?: () => void,
1704
+ expectedDestination?: ManagedFileSnapshot["identity"],
1592
1705
  ): void {
1593
1706
  const parent = path.dirname(destination);
1594
1707
  ensureManagedDirectory(parent, root, policy);
1595
1708
  const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.replacement`);
1596
1709
  let fd: number | undefined;
1597
1710
  let stagedIdentity: { dev: bigint; ino: bigint } | undefined;
1711
+ let preserveStaging = false;
1598
1712
  try {
1599
- assertFence?.();
1600
1713
  fd = fs.openSync(
1601
1714
  staging,
1602
1715
  fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
@@ -1609,22 +1722,137 @@ export function replaceManagedFileSync(
1609
1722
  secureFileDescriptor(staging, fd, "verify");
1610
1723
  const staged = fs.fstatSync(fd, { bigint: true });
1611
1724
  stagedIdentity = { dev: staged.dev, ino: staged.ino };
1725
+ if (process.platform === "win32" && expectedDestination) {
1726
+ fs.closeSync(fd);
1727
+ fd = undefined;
1728
+ }
1612
1729
  assertManagedDirectoryRoot(root);
1613
1730
  assertFence?.();
1614
- fs.renameSync(staging, destination);
1731
+ if (process.platform !== "win32" && expectedDestination) {
1732
+ throw new Error("managed_replace_exact_unavailable");
1733
+ }
1734
+ if (process.platform === "win32" && expectedDestination) {
1735
+ const parentIdentity = fs.lstatSync(parent, { bigint: true });
1736
+ const replaced = exactReplacePath(
1737
+ staging,
1738
+ destination,
1739
+ {
1740
+ dev: staged.dev,
1741
+ ino: staged.ino,
1742
+ nlink: staged.nlink,
1743
+ parentDev: parentIdentity.dev,
1744
+ parentIno: parentIdentity.ino,
1745
+ size: staged.size,
1746
+ mtimeNs: staged.mtimeNs,
1747
+ sha256: createHash("sha256").update(bytes).digest("hex"),
1748
+ },
1749
+ {
1750
+ dev: expectedDestination.dev,
1751
+ ino: expectedDestination.ino,
1752
+ nlink: expectedDestination.nlink,
1753
+ parentDev: parentIdentity.dev,
1754
+ parentIno: parentIdentity.ino,
1755
+ size: BigInt(expectedDestination.size),
1756
+ mtimeNs: expectedDestination.mtimeNs,
1757
+ sha256: expectedDestination.sha256,
1758
+ },
1759
+ );
1760
+ if (!replaced.ok) {
1761
+ const committedWithPredecessor =
1762
+ replaced.detachedPath === replaced.retainedPlaceholderPath &&
1763
+ replaced.retainedSuccessorPath === destination;
1764
+ if (committedWithPredecessor) {
1765
+ const retired = exactUnlink(replaced.retainedPlaceholderPath!, {
1766
+ dev: expectedDestination.dev,
1767
+ ino: expectedDestination.ino,
1768
+ nlink: expectedDestination.nlink,
1769
+ parentDev: parentIdentity.dev,
1770
+ parentIno: parentIdentity.ino,
1771
+ size: BigInt(expectedDestination.size),
1772
+ mtimeNs: expectedDestination.mtimeNs,
1773
+ sha256: expectedDestination.sha256,
1774
+ quarantineName: `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}`,
1775
+ });
1776
+ if (!retired.ok) {
1777
+ const predecessor =
1778
+ retired.detachedPath ?? retired.retainedPlaceholderPath ?? replaced.retainedPlaceholderPath;
1779
+ const receiptPath = path.join(
1780
+ parent,
1781
+ `.gjc-replace-cleanup-${expectedDestination.dev.toString(16)}-${expectedDestination.ino.toString(16)}.json`,
1782
+ );
1783
+ publishManagedFileNoReplaceSync(
1784
+ receiptPath,
1785
+ Buffer.from(
1786
+ JSON.stringify({
1787
+ version: 1,
1788
+ predecessor,
1789
+ successor: destination,
1790
+ identity: {
1791
+ dev: expectedDestination.dev.toString(),
1792
+ ino: expectedDestination.ino.toString(),
1793
+ nlink: expectedDestination.nlink.toString(),
1794
+ size: String(expectedDestination.size),
1795
+ mtimeNs: expectedDestination.mtimeNs.toString(),
1796
+ ctimeNs: expectedDestination.ctimeNs.toString(),
1797
+ sha256: expectedDestination.sha256,
1798
+ },
1799
+ }),
1800
+ ),
1801
+ root,
1802
+ policy,
1803
+ );
1804
+ throw new Error(
1805
+ `managed_replace_cleanup_pending:receipt=${receiptPath}:predecessor=${predecessor}:successor=${destination}:code=${retired.code ?? "unknown"}`,
1806
+ );
1807
+ }
1808
+ }
1809
+ if (!committedWithPredecessor) {
1810
+ if (replaced.detachedPath) {
1811
+ const restored = exactRestore(replaced.detachedPath, staging, {
1812
+ dev: staged.dev,
1813
+ ino: staged.ino,
1814
+ nlink: staged.nlink,
1815
+ parentDev: parentIdentity.dev,
1816
+ parentIno: parentIdentity.ino,
1817
+ size: staged.size,
1818
+ mtimeNs: staged.mtimeNs,
1819
+ sha256: createHash("sha256").update(bytes).digest("hex"),
1820
+ });
1821
+ if (restored.ok) preserveStaging = true;
1822
+ if (!restored.ok)
1823
+ throw new Error(
1824
+ `managed_replace_failed:${replaced.code ?? "unknown"}:retained=${replaced.detachedPath}:restore=${restored.code ?? "unknown"}`,
1825
+ );
1826
+ }
1827
+ const retained = [
1828
+ replaced.detachedPath,
1829
+ replaced.retainedSuccessorPath,
1830
+ replaced.retainedPlaceholderPath,
1831
+ replaced.retainedUnknownPath,
1832
+ ]
1833
+ .filter((pathname): pathname is string => typeof pathname === "string")
1834
+ .join(",");
1835
+ throw new Error(
1836
+ `managed_replace_failed:${replaced.code ?? "unknown"}${retained ? `:retained=${retained}` : ""}`,
1837
+ );
1838
+ }
1839
+ }
1840
+ } else fs.renameSync(staging, destination);
1615
1841
  assertFence?.();
1616
1842
  assertManagedDirectoryRoot(root);
1617
1843
  const named = fs.lstatSync(destination, { bigint: true });
1618
1844
  if (!named.isFile() || named.isSymbolicLink() || named.dev !== staged.dev || named.ino !== staged.ino) {
1619
1845
  throw new Error("destination_identity_changed");
1620
1846
  }
1621
- secureFileDescriptor(destination, fd, "verify");
1622
- fs.closeSync(fd);
1623
- fd = undefined;
1847
+ if (fd !== undefined) {
1848
+ secureFileDescriptor(destination, fd, "verify");
1849
+ fs.closeSync(fd);
1850
+ fd = undefined;
1851
+ }
1624
1852
  fsyncDirectory(parent);
1625
1853
  } finally {
1626
1854
  if (fd !== undefined) fs.closeSync(fd);
1627
- if (stagedIdentity) {
1855
+ if (stagedIdentity && !preserveStaging) {
1628
1856
  try {
1629
1857
  const named = fs.lstatSync(staging, { bigint: true });
1630
1858
  if (named.dev === stagedIdentity.dev && named.ino === stagedIdentity.ino) fs.unlinkSync(staging);
@@ -1670,7 +1898,6 @@ export async function acquireManagedLock(
1670
1898
  ensureManagedDirectory(locksDirectory, root, policy);
1671
1899
  const lockPath = path.join(locksDirectory, `${name}.lock`);
1672
1900
  const deadline = Date.now() + LOCK_WAIT_MS;
1673
- let staleObservedAt: number | undefined;
1674
1901
  while (true) {
1675
1902
  const attemptId = randomUUID();
1676
1903
  const now = Date.now();
@@ -1703,12 +1930,8 @@ export async function acquireManagedLock(
1703
1930
  let descriptorClosed = false;
1704
1931
  const closeDescriptor = (): void => {
1705
1932
  if (descriptorClosed) return;
1706
- try {
1707
- secureFileDescriptor(lockPath, fd, "verify");
1708
- } finally {
1709
- fs.closeSync(fd);
1710
- descriptorClosed = true;
1711
- }
1933
+ fs.closeSync(fd);
1934
+ descriptorClosed = true;
1712
1935
  };
1713
1936
  const assertOwned = (): void => {
1714
1937
  const current = parseLock(lockPath);
@@ -1722,17 +1945,23 @@ export async function acquireManagedLock(
1722
1945
  released ||
1723
1946
  descriptorClosed ||
1724
1947
  !current ||
1948
+ current.released === true ||
1725
1949
  !sameFileIdentity(lockIdentity, named) ||
1726
- current.attemptId !== attemptId ||
1727
- current.leaseExpiresAt < Date.now()
1950
+ current.attemptId !== attemptId
1728
1951
  )
1729
1952
  throw new Error("migration_busy");
1953
+ const now = Date.now();
1954
+ if (current.leaseExpiresAt < now + LOCK_HEARTBEAT_MS) {
1955
+ writeLockDescriptor(fd, {
1956
+ ...record,
1957
+ heartbeatAt: now,
1958
+ leaseExpiresAt: now + LOCK_LEASE_MS,
1959
+ });
1960
+ }
1730
1961
  };
1731
1962
  const heartbeat = setInterval(() => {
1732
1963
  try {
1733
1964
  assertOwned();
1734
- const now = Date.now();
1735
- writeLockDescriptor(fd, { ...record, heartbeatAt: now, leaseExpiresAt: now + LOCK_LEASE_MS });
1736
1965
  } catch {
1737
1966
  /* fencing rejects later publication */
1738
1967
  }
@@ -1745,10 +1974,16 @@ export async function acquireManagedLock(
1745
1974
  clearInterval(heartbeat);
1746
1975
  try {
1747
1976
  assertOwned();
1977
+ secureFileDescriptor(lockPath, fd, "verify");
1748
1978
  const now = Date.now();
1749
- // Do not unlink by pathname: a stale owner could otherwise remove a successor.
1750
- // The lease is retired through the verified inode-bound descriptor instead.
1751
- writeLockDescriptor(fd, { ...record, heartbeatAt: now, leaseExpiresAt: now });
1979
+ // A released record is the only live-process reclaim authority. Expiry alone
1980
+ // never authorizes stealing from a holder whose process is still present.
1981
+ writeLockDescriptor(fd, {
1982
+ ...record,
1983
+ released: true,
1984
+ heartbeatAt: now,
1985
+ leaseExpiresAt: now,
1986
+ });
1752
1987
  fsyncDirectory(locksDirectory);
1753
1988
  } finally {
1754
1989
  released = true;
@@ -1758,24 +1993,27 @@ export async function acquireManagedLock(
1758
1993
  };
1759
1994
  } catch (error) {
1760
1995
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
1761
- const owner = parseLock(lockPath);
1762
- if (owner && owner.leaseExpiresAt < Date.now()) {
1763
- const ownerGone = ownerDefinitelyGone(owner);
1764
- if (staleObservedAt === undefined) staleObservedAt = Date.now();
1765
- if (ownerGone || Date.now() - staleObservedAt >= LOCK_STALE_RECHECK_MS) {
1766
- const quarantine = `${lockPath}.${randomUUID()}.stale`;
1767
- try {
1768
- fs.renameSync(lockPath, quarantine);
1769
- const quarantined = parseLock(quarantine);
1770
- if (!quarantined || quarantined.attemptId !== owner.attemptId) throw new Error("migration_busy");
1771
- fs.unlinkSync(quarantine);
1772
- fsyncDirectory(locksDirectory);
1773
- } catch {
1774
- /* retry owner observation */
1775
- }
1776
- staleObservedAt = undefined;
1996
+ const observed = captureLock(lockPath);
1997
+ const owner = observed?.record;
1998
+ const reclaimable =
1999
+ owner?.released === true ||
2000
+ (owner !== undefined && owner.leaseExpiresAt < Date.now() && ownerDefinitelyGone(owner));
2001
+ if (observed && owner && reclaimable) {
2002
+ try {
2003
+ ManagedLockTestHooks.beforeObservedRetirement?.({ path: lockPath, attemptId: owner.attemptId });
2004
+ const removed = exactUnlink(lockPath, {
2005
+ dev: observed.snapshot.identity.dev,
2006
+ ino: observed.snapshot.identity.ino,
2007
+ size: BigInt(observed.snapshot.identity.size),
2008
+ mtimeNs: observed.snapshot.identity.mtimeNs,
2009
+ sha256: observed.snapshot.identity.sha256,
2010
+ quarantineName: `.gjc-lock-${randomUUID()}.stale`,
2011
+ });
2012
+ if (removed.ok || removed.code === "cleanup_pending") fsyncDirectory(locksDirectory);
2013
+ } catch {
2014
+ /* retry owner observation */
1777
2015
  }
1778
- } else staleObservedAt = undefined;
2016
+ }
1779
2017
  if (Date.now() >= deadline) throw new Error("migration_busy");
1780
2018
  await new Promise<void>(resolve => setTimeout(resolve, 50));
1781
2019
  }