@gajae-code/coding-agent 0.16.4 → 0.16.6

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 (198) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/customize-doctor.d.ts +22 -0
  4. package/dist/types/config/file-lock.d.ts +31 -3
  5. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  6. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  7. package/dist/types/discovery/helpers.d.ts +33 -0
  8. package/dist/types/exec/bash-executor.d.ts +17 -1
  9. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  10. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  11. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  12. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  13. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  14. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  15. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  16. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  17. package/dist/types/exec/isolated-shell.d.ts +19 -0
  18. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  19. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  22. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  23. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  24. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  25. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  26. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  27. package/dist/types/extensibility/runtime-skill-discovery.d.ts +4 -1
  28. package/dist/types/extensibility/skill-management.d.ts +9 -4
  29. package/dist/types/extensibility/skills.d.ts +2 -0
  30. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  31. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +12 -0
  33. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  34. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  35. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  36. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  37. package/dist/types/modes/interactive-mode.d.ts +2 -0
  38. package/dist/types/modes/types.d.ts +6 -0
  39. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  40. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  43. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  44. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  45. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  47. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  48. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  49. package/dist/types/sdk/client/client.d.ts +6 -0
  50. package/dist/types/sdk/host/host.d.ts +3 -1
  51. package/dist/types/sdk/host/session-runtime.d.ts +4 -2
  52. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  53. package/dist/types/sdk/router/session-router.d.ts +10 -2
  54. package/dist/types/sdk/session.d.ts +5 -1
  55. package/dist/types/session/agent-session.d.ts +2 -0
  56. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  57. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  58. package/dist/types/session/messages.d.ts +1 -0
  59. package/dist/types/session/session-manager.d.ts +3 -1
  60. package/dist/types/setup/paseo/announce.d.ts +4 -3
  61. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  62. package/dist/types/system-prompt.d.ts +8 -0
  63. package/dist/types/task/executor.d.ts +3 -0
  64. package/dist/types/tools/bash.d.ts +1 -0
  65. package/dist/types/tools/github-cache.d.ts +2 -1
  66. package/dist/types/tools/index.d.ts +2 -0
  67. package/package.json +9 -7
  68. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  69. package/src/capability/index.ts +14 -2
  70. package/src/capability/types.ts +4 -0
  71. package/src/cli/customize-doctor.ts +155 -12
  72. package/src/cli.ts +22 -0
  73. package/src/commands/sdk.ts +74 -31
  74. package/src/config/file-lock-gc.ts +40 -2
  75. package/src/config/file-lock.ts +198 -14
  76. package/src/config/model-preset-registry.ts +115 -16
  77. package/src/config/model-registry.ts +16 -12
  78. package/src/coordinator-mcp/question-state.ts +33 -2
  79. package/src/coordinator-mcp/server.ts +477 -101
  80. package/src/crash/sanitize.ts +1 -1
  81. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  82. package/src/discovery/builtin.ts +50 -62
  83. package/src/discovery/helpers.ts +297 -32
  84. package/src/exec/bash-executor.ts +330 -50
  85. package/src/exec/bash-shell-guardian.ts +388 -0
  86. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  87. package/src/exec/bash-shell-signals.ts +29 -0
  88. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  89. package/src/exec/bash-shell-supervisor.ts +359 -0
  90. package/src/exec/bash-shell-worker-entry.ts +5 -0
  91. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  92. package/src/exec/bash-shell-worker.ts +116 -0
  93. package/src/exec/isolated-shell.ts +393 -0
  94. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  95. package/src/extensibility/extensions/function-hooks.ts +959 -0
  96. package/src/extensibility/extensions/index.ts +22 -0
  97. package/src/extensibility/extensions/loader.ts +53 -2
  98. package/src/extensibility/extensions/runner.ts +895 -264
  99. package/src/extensibility/extensions/types.ts +42 -0
  100. package/src/extensibility/extensions/wrapper.ts +56 -15
  101. package/src/extensibility/gjc-plugins/compiler.ts +20 -2
  102. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  103. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  104. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  105. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  106. package/src/extensibility/gjc-plugins/types.ts +25 -0
  107. package/src/extensibility/hooks/loader.ts +133 -16
  108. package/src/extensibility/hooks/types.ts +28 -0
  109. package/src/extensibility/runtime-skill-discovery.ts +80 -47
  110. package/src/extensibility/skill-management.ts +200 -52
  111. package/src/extensibility/skills.ts +29 -9
  112. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  113. package/src/gjc-runtime/gc-runtime.ts +29 -0
  114. package/src/gjc-runtime/linux-proc.ts +14 -0
  115. package/src/gjc-runtime/session-state-lock.ts +51 -8
  116. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  117. package/src/gjc-runtime/state-writer.ts +30 -29
  118. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  119. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  120. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  121. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  122. package/src/harness-control-plane/session-lease.ts +2 -2
  123. package/src/internal-urls/docs-index.generated.ts +6 -6
  124. package/src/lsp/config.ts +10 -3
  125. package/src/lsp/index.ts +1 -1
  126. package/src/lsp/path-trust.ts +69 -14
  127. package/src/main.ts +6 -1
  128. package/src/modes/acp/acp-agent.ts +16 -3
  129. package/src/modes/components/bash-execution.ts +4 -1
  130. package/src/modes/components/execution-shared.ts +4 -1
  131. package/src/modes/components/session-observer-overlay.ts +1 -1
  132. package/src/modes/components/tool-status-header.ts +3 -1
  133. package/src/modes/controllers/command-controller.ts +1 -0
  134. package/src/modes/controllers/event-controller.ts +3 -2
  135. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  136. package/src/modes/controllers/input-controller.ts +6 -3
  137. package/src/modes/interactive-mode.ts +6 -5
  138. package/src/modes/print-mode.ts +5 -1
  139. package/src/modes/prompt-action-autocomplete.ts +54 -27
  140. package/src/modes/runtime-init.ts +2 -0
  141. package/src/modes/types.ts +16 -3
  142. package/src/modes/utils/ui-helpers.ts +1 -0
  143. package/src/prompts/tools/subagent.md +1 -0
  144. package/src/runtime-mcp/manager.ts +20 -2
  145. package/src/runtime-mcp/pool.test.ts +20 -11
  146. package/src/sdk/broker/broker.ts +119 -28
  147. package/src/sdk/broker/ensure.ts +248 -41
  148. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  149. package/src/sdk/broker/lifecycle.ts +56 -6
  150. package/src/sdk/broker/startup-failure.ts +15 -6
  151. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  152. package/src/sdk/bus/chat-daemon-runtime.ts +35 -5
  153. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  154. package/src/sdk/bus/conversation-store.ts +29 -0
  155. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  156. package/src/sdk/bus/slack-conversation.ts +1 -0
  157. package/src/sdk/bus/slack-daemon.ts +387 -48
  158. package/src/sdk/bus/telegram-daemon.ts +16 -2
  159. package/src/sdk/bus/topic-registry.ts +19 -0
  160. package/src/sdk/cli/master-cli.ts +1 -1
  161. package/src/sdk/cli/session-cli.ts +2 -2
  162. package/src/sdk/client/client.ts +66 -17
  163. package/src/sdk/host/host.ts +29 -9
  164. package/src/sdk/host/query/revision-store.ts +19 -13
  165. package/src/sdk/host/reverse-leases.ts +7 -4
  166. package/src/sdk/host/session-runtime.test.ts +50 -8
  167. package/src/sdk/host/session-runtime.ts +158 -51
  168. package/src/sdk/lifecycle/service.ts +4 -0
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/router/session-router.ts +89 -38
  171. package/src/sdk/session.ts +155 -26
  172. package/src/sdk/transport/relay.ts +35 -13
  173. package/src/session/agent-session.ts +117 -52
  174. package/src/session/fallback-chain-controller.ts +38 -0
  175. package/src/session/internal/managed-session-storage.ts +19 -5
  176. package/src/session/messages.ts +5 -0
  177. package/src/session/session-manager.ts +29 -5
  178. package/src/session-import/redact.ts +5 -1
  179. package/src/setup/paseo/announce.ts +49 -43
  180. package/src/setup/paseo/setup-deps.ts +1 -1
  181. package/src/skill-state/canonical-skills.ts +12 -0
  182. package/src/skills/skills.test.ts +41 -21
  183. package/src/system-prompt.ts +25 -3
  184. package/src/task/executor.ts +5 -0
  185. package/src/task/index.ts +4 -0
  186. package/src/tools/bash.ts +28 -11
  187. package/src/tools/gh.ts +26 -3
  188. package/src/tools/github-cache.ts +68 -16
  189. package/src/tools/index.ts +2 -0
  190. package/src/tools/job.ts +13 -2
  191. package/src/tools/read.ts +14 -2
  192. package/src/tools/render-utils.ts +1 -3
  193. package/src/tools/resource-gc.ts +28 -8
  194. package/src/tools/skill-discovery.ts +5 -0
  195. package/src/tools/skill.ts +15 -0
  196. package/src/tools/subagent.ts +3 -2
  197. package/src/tools/tool-catalog.generated.ts +2 -2
  198. package/src/tools/ultragoal-ask-guard.ts +2 -2
@@ -13,6 +13,7 @@ import { initTheme } from "../modes/theme/theme";
13
13
  import { ACP_MCP_REQUEST_TIMEOUT_MS, ACP_MCP_STARTUP_HEADROOM_MS } from "../sdk/acp/mcp";
14
14
  import { Broker } from "../sdk/broker/broker";
15
15
  import { readBrokerDiscovery } from "../sdk/broker/discovery";
16
+ import { reconcileBrokerGenerationForStartup, withBrokerStartupLock } from "../sdk/broker/ensure";
16
17
  import { completeBrokerProcess } from "../sdk/broker/internal";
17
18
  import {
18
19
  type LifecycleTranscriptEvidence,
@@ -1086,59 +1087,101 @@ export default class Sdk extends Command {
1086
1087
  await runSessionHost();
1087
1088
  return;
1088
1089
  }
1089
- const agentDir = internal.agentDir;
1090
- const broker = new Broker({
1091
- agentDir,
1092
- masterOrphanGraceMs: (await Settings.loadForScope({ cwd: process.cwd(), agentDir })).get(
1093
- "sdk.masterOrphanGraceMs",
1094
- ),
1095
- resolveDirectoryMigration: async cwd => {
1096
- const settings = await Settings.loadForScope({ cwd, agentDir });
1090
+ const agentDir = path.resolve(internal.agentDir);
1091
+ let broker: Broker | undefined;
1092
+ try {
1093
+ broker = await withBrokerStartupLock(agentDir, async deadline => {
1094
+ const remainingMs = Math.max(1, deadline - Date.now());
1095
+ const testWatchdogMs = Number(process.env.GJC_SDK_TEST_BROKER_STARTUP_WATCHDOG_MS ?? 0);
1096
+ const watchdogMs =
1097
+ Number.isSafeInteger(testWatchdogMs) && testWatchdogMs > 0 && testWatchdogMs <= remainingMs
1098
+ ? testWatchdogMs
1099
+ : remainingMs;
1100
+ const startupWatchdog = setTimeout(() => {
1101
+ process.stderr.write(`SDK broker startup exceeded its ${watchdogMs}ms fence deadline.\n`);
1102
+ process.exit(1);
1103
+ }, watchdogMs);
1097
1104
  try {
1098
- const policy = settings.get("session.directoryMigration");
1099
- return policy === "disabled" ? "disabled" : "copy-retain";
1105
+ if (await reconcileBrokerGenerationForStartup({ agentDir }, deadline)) return undefined;
1106
+ if (process.env.GJC_SDK_TEST_BROKER_STARTUP_STALL === "1") {
1107
+ const stalled = Promise.withResolvers<void>();
1108
+ await stalled.promise;
1109
+ }
1110
+ const startupDelayMs = Number(process.env.GJC_SDK_TEST_BROKER_STARTUP_DELAY_MS ?? 0);
1111
+ if (Number.isSafeInteger(startupDelayMs) && startupDelayMs > 0 && startupDelayMs <= 10_000)
1112
+ await Bun.sleep(startupDelayMs);
1113
+ const candidate = new Broker({
1114
+ agentDir,
1115
+ masterOrphanGraceMs: (await Settings.loadForScope({ cwd: process.cwd(), agentDir })).get(
1116
+ "sdk.masterOrphanGraceMs",
1117
+ ),
1118
+ resolveDirectoryMigration: async cwd => {
1119
+ const settings = await Settings.loadForScope({ cwd, agentDir });
1120
+ try {
1121
+ const policy = settings.get("session.directoryMigration");
1122
+ return policy === "disabled" ? "disabled" : "copy-retain";
1123
+ } finally {
1124
+ await settings.close();
1125
+ }
1126
+ },
1127
+ });
1128
+ broker = candidate;
1129
+ await candidate.start();
1130
+ return candidate;
1100
1131
  } finally {
1101
- await settings.close();
1132
+ clearTimeout(startupWatchdog);
1102
1133
  }
1103
- },
1104
- });
1105
- try {
1106
- await broker.start();
1134
+ });
1107
1135
  } catch (error) {
1136
+ if (broker) {
1137
+ try {
1138
+ await broker.stop();
1139
+ } catch {
1140
+ // Preserve the startup/fence failure that made this bootstrap unusable.
1141
+ }
1142
+ }
1108
1143
  // This process spawns detached with stdio ignored (see ensure.ts), so the
1109
1144
  // durable marker is the only channel the caller has to see why start()
1110
1145
  // failed instead of a bare exit code (#3963).
1111
- await writeBrokerStartupFailureMarker(agentDir, {
1112
- reason: error instanceof Error ? error.message : String(error),
1113
- exitCode: 1,
1114
- signal: null,
1115
- pid: process.pid,
1116
- });
1146
+ const incarnation = processIncarnation(process.pid);
1147
+ if (incarnation)
1148
+ await writeBrokerStartupFailureMarker(agentDir, {
1149
+ reason: error instanceof Error ? error.message : String(error),
1150
+ exitCode: 1,
1151
+ signal: null,
1152
+ pid: process.pid,
1153
+ incarnation,
1154
+ });
1117
1155
  throw error;
1118
1156
  }
1119
- if (!broker.ownsDiscovery) {
1157
+ if (!broker) return;
1158
+ const runningBroker = broker;
1159
+ if (!runningBroker.ownsDiscovery) {
1120
1160
  // Another broker owns discovery; this process exits cleanly (code 0) as
1121
1161
  // the race loser. Record why so a caller polling for a winner that never
1122
1162
  // appears can diagnose the loss instead of seeing only a bare exit 0.
1123
- await writeBrokerStartupFailureMarker(agentDir, {
1124
- reason: "Another broker owns the lock/discovery; this broker exited as the race loser.",
1125
- exitCode: 0,
1126
- signal: null,
1127
- pid: process.pid,
1128
- });
1163
+ const incarnation = processIncarnation(process.pid);
1164
+ if (incarnation)
1165
+ await writeBrokerStartupFailureMarker(agentDir, {
1166
+ reason: "Another broker owns the lock/discovery; this broker exited as the race loser.",
1167
+ exitCode: 0,
1168
+ signal: null,
1169
+ pid: process.pid,
1170
+ incarnation,
1171
+ });
1129
1172
  return;
1130
1173
  }
1131
1174
  // A live broker must not keep advertising sessions whose host process is
1132
1175
  // gone; the sweep is the broker-side half of the host reaping bound.
1133
- const stopSweep = startBrokerDeadRegistrationSweep(broker);
1176
+ const stopSweep = startBrokerDeadRegistrationSweep(runningBroker);
1134
1177
  const stop = () => {
1135
1178
  stopSweep();
1136
- void broker.stop();
1179
+ void runningBroker.stop();
1137
1180
  };
1138
1181
  process.once("SIGTERM", stop);
1139
1182
  process.once("SIGINT", stop);
1140
1183
  try {
1141
- await completeBrokerProcess(broker);
1184
+ await completeBrokerProcess(runningBroker);
1142
1185
  } finally {
1143
1186
  stopSweep();
1144
1187
  }
@@ -17,7 +17,12 @@ import type {
17
17
  } from "../gjc-runtime/gc-runtime";
18
18
  import { gcPidStatusLabel } from "../gjc-runtime/gc-runtime";
19
19
  import { resolveReceiptSpoolDir } from "../harness-control-plane/receipt-spool";
20
- import { readFileLockObservationForGc, removeFileLockDirForGc } from "./file-lock";
20
+ import {
21
+ fileLockStagingOwnerPid,
22
+ inspectFileLockStagingDir,
23
+ readFileLockObservationForGc,
24
+ removeFileLockDirForGc,
25
+ } from "./file-lock";
21
26
 
22
27
  const MAX_WALK_DEPTH = 6;
23
28
  /** Default per-root walk budget. Truncation is a warning, not a hard error. */
@@ -67,7 +72,27 @@ function keptMalformedRecord(lockDir: string): GcRecord {
67
72
  };
68
73
  }
69
74
 
75
+ function stagingOwnerStatus(ctx: GcContext, pid: number): "alive" | "dead" | "unknown" {
76
+ const status = gcPidStatusLabel(ctx.probe(pid));
77
+ return status === "eperm" ? "unknown" : status;
78
+ }
79
+
70
80
  async function collectLockRecord(lockDir: string, ctx: GcContext): Promise<GcRecord> {
81
+ if (fileLockStagingOwnerPid(path.basename(lockDir)) !== null) {
82
+ const staging = await inspectFileLockStagingDir(lockDir, pid => stagingOwnerStatus(ctx, pid));
83
+ return {
84
+ store: "file_locks",
85
+ id: lockDir,
86
+ path: lockDir,
87
+ pid: staging.pid,
88
+ pid_status: staging.status,
89
+ status: "file_lock_staging_orphan",
90
+ stale: staging.status === "dead",
91
+ removable: staging.status === "dead",
92
+ action: "none",
93
+ reason: staging.reason,
94
+ };
95
+ }
71
96
  const observation = await readFileLockObservationForGc(lockDir);
72
97
  const info = observation?.info;
73
98
  if (!info) return keptMalformedRecord(lockDir);
@@ -131,7 +156,7 @@ async function walkForLockDirs(
131
156
  state.entries++;
132
157
  if (!stat.isDirectory() || stat.isSymbolicLink()) return;
133
158
 
134
- if (path.basename(dir).endsWith(".lock")) {
159
+ if (path.basename(dir).endsWith(".lock") || fileLockStagingOwnerPid(path.basename(dir)) !== null) {
135
160
  lockDirs.add(dir);
136
161
  return;
137
162
  }
@@ -152,6 +177,11 @@ async function walkForLockDirs(
152
177
  state.truncated = true;
153
178
  return;
154
179
  }
180
+ // Session index locks live directly in sessions/, but session payload trees remain pruned.
181
+ if (entry === "sessions") {
182
+ await walkForLockDirs(path.join(dir, entry), MAX_WALK_DEPTH - 1, state, lockDirs, errors, maxWalkEntries);
183
+ continue;
184
+ }
155
185
  if (PRUNED_DIR_NAMES.has(entry)) continue;
156
186
  await walkForLockDirs(path.join(dir, entry), depth + 1, state, lockDirs, errors, maxWalkEntries);
157
187
  }
@@ -204,6 +234,14 @@ export const fileLocksGcAdapter: GcStoreAdapter = {
204
234
  },
205
235
  async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
206
236
  const lockDir = record.path ?? record.id;
237
+ if (fileLockStagingOwnerPid(path.basename(lockDir)) !== null) {
238
+ try {
239
+ const staging = await inspectFileLockStagingDir(lockDir, pid => stagingOwnerStatus(ctx, pid), true);
240
+ return staging.removed ? { removed: true } : { removed: false, skipped: staging.reason };
241
+ } catch (error) {
242
+ return { removed: false, error: errorMessage(error) };
243
+ }
244
+ }
207
245
  const observation = await readFileLockObservationForGc(lockDir);
208
246
  const info = observation?.info;
209
247
  if (!info) return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
@@ -15,6 +15,7 @@ import {
15
15
  renameNoReplacePathAsync,
16
16
  snapshotDirectoryTree,
17
17
  } from "@gajae-code/natives";
18
+ import { logger } from "@gajae-code/utils";
18
19
  import { isEnoent } from "@gajae-code/utils/fs-error";
19
20
  import { nativeProcessBindings } from "@gajae-code/utils/native-process";
20
21
 
@@ -30,6 +31,23 @@ export interface FileLockOptions {
30
31
  previousOwnerHostIds?: readonly string[];
31
32
  }
32
33
 
34
+ export class FileLockAcquireError extends Error {
35
+ readonly code = "acquire_timeout";
36
+
37
+ constructor(
38
+ readonly filePath: string,
39
+ readonly lockPath: string,
40
+ readonly attempts: number,
41
+ readonly holder: string,
42
+ ) {
43
+ super(
44
+ `Failed to acquire lock for ${filePath} after ${attempts} attempts: ${holder} (${lockPath}); ` +
45
+ `a live owner is never displaced — if this is an SDK broker (gjc sdk status), it must finish or be stopped before retrying`,
46
+ );
47
+ this.name = "FileLockAcquireError";
48
+ }
49
+ }
50
+
33
51
  const DEFAULT_OPTIONS: Required<
34
52
  Omit<FileLockOptions, "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired">
35
53
  > = {
@@ -38,9 +56,9 @@ const DEFAULT_OPTIONS: Required<
38
56
  retryDelayMs: 100,
39
57
  };
40
58
 
41
- /** Release retries cover transient Windows/Dropbox handle denial without extending the lock indefinitely. */
42
- export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 5;
43
- export const FILE_LOCK_RELEASE_RETRY_DELAY_MS = 10;
59
+ /** Release retries cover transient handle denial and a competing exact-removal quarantine cleanup. */
60
+ export const FILE_LOCK_RELEASE_RETRY_ATTEMPTS = 20;
61
+ export const FILE_LOCK_RELEASE_RETRY_DELAY_MS = 25;
44
62
  const PROCESS_START_TIME_FORMAT = "utc-v1";
45
63
 
46
64
  type LocalLockState = {
@@ -101,12 +119,27 @@ export function processStartTime(pid: number): string | null {
101
119
  }
102
120
 
103
121
  let ownProcessStartTime: string | undefined;
122
+ let ownProcessIncarnation: string | null | undefined;
104
123
 
105
124
  function currentProcessStartTime(): string {
106
125
  if (ownProcessStartTime === undefined) ownProcessStartTime = processStartTime(process.pid) ?? "unknown";
107
126
  return ownProcessStartTime;
108
127
  }
109
128
 
129
+ function processIncarnation(pid: number): string | null {
130
+ try {
131
+ const incarnation = nativeProcessBindings().Process.fromPid(pid)?.incarnation;
132
+ return typeof incarnation === "string" && incarnation.length > 0 ? incarnation : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ function currentProcessIncarnation(): string | null {
139
+ if (ownProcessIncarnation === undefined) ownProcessIncarnation = processIncarnation(process.pid);
140
+ return ownProcessIncarnation;
141
+ }
142
+
110
143
  function cachedProcessStartTime(owner: FileLockOwnerToken, cache?: Map<string, string | null>): string | null {
111
144
  if (!cache) return processStartTime(owner.pid);
112
145
  const key = `${owner.pid}:${owner.start_time ?? ""}`;
@@ -119,6 +152,10 @@ function cachedProcessStartTime(owner: FileLockOwnerToken, cache?: Map<string, s
119
152
 
120
153
  function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
121
154
  if (ownerLiveness(owner.pid) !== "alive") return false;
155
+ if (owner.process_incarnation) {
156
+ const currentIncarnation = processIncarnation(owner.pid);
157
+ return currentIncarnation === null || currentIncarnation === owner.process_incarnation;
158
+ }
122
159
  if (!owner.start_time || owner.start_time === "unknown") return true;
123
160
  const currentStartTime = cachedProcessStartTime(owner, startTimeCache);
124
161
  if (currentStartTime === null || currentStartTime === owner.start_time) return true;
@@ -130,10 +167,12 @@ function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map<string, st
130
167
  }
131
168
 
132
169
  function lockInfo(ownerHostId: string | undefined, ownerToken: string): LockInfo {
170
+ const incarnation = currentProcessIncarnation();
133
171
  return {
134
172
  pid: process.pid,
135
173
  start_time: currentProcessStartTime(),
136
174
  start_time_format: PROCESS_START_TIME_FORMAT,
175
+ ...(incarnation === null ? {} : { process_incarnation: incarnation }),
137
176
  timestamp: Date.now(),
138
177
  owner_token: ownerToken,
139
178
  ...(ownerHostId === undefined ? {} : { owner_host_id: ownerHostId }),
@@ -327,7 +366,8 @@ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
327
366
  }
328
367
 
329
368
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
330
- const { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token } = parsed as Partial<LockInfo>;
369
+ const { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token } =
370
+ parsed as Partial<LockInfo>;
331
371
  if (
332
372
  typeof pid !== "number" ||
333
373
  !Number.isInteger(pid) ||
@@ -336,11 +376,12 @@ async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
336
376
  !Number.isFinite(timestamp) ||
337
377
  (start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
338
378
  (start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
379
+ (process_incarnation !== undefined && (typeof process_incarnation !== "string" || !process_incarnation)) ||
339
380
  (owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
340
381
  (owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
341
382
  )
342
383
  return null;
343
- return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
384
+ return { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token };
344
385
  }
345
386
 
346
387
  function parseLockInfoBytes(bytes: string): LockInfo | null {
@@ -351,7 +392,8 @@ function parseLockInfoBytes(bytes: string): LockInfo | null {
351
392
  return null;
352
393
  }
353
394
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
354
- const { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token } = parsed as Partial<LockInfo>;
395
+ const { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token } =
396
+ parsed as Partial<LockInfo>;
355
397
  if (
356
398
  typeof pid !== "number" ||
357
399
  !Number.isInteger(pid) ||
@@ -360,11 +402,12 @@ function parseLockInfoBytes(bytes: string): LockInfo | null {
360
402
  !Number.isFinite(timestamp) ||
361
403
  (start_time !== undefined && (typeof start_time !== "string" || !start_time)) ||
362
404
  (start_time_format !== undefined && (typeof start_time_format !== "string" || !start_time_format)) ||
405
+ (process_incarnation !== undefined && (typeof process_incarnation !== "string" || !process_incarnation)) ||
363
406
  (owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ||
364
407
  (owner_token !== undefined && (typeof owner_token !== "string" || !owner_token))
365
408
  )
366
409
  return null;
367
- return { pid, start_time, start_time_format, timestamp, owner_host_id, owner_token };
410
+ return { pid, start_time, start_time_format, process_incarnation, timestamp, owner_host_id, owner_token };
368
411
  }
369
412
 
370
413
  /** @internal */
@@ -393,6 +436,8 @@ export async function readFileLockInfoForGc(lockDir: string): Promise<FileLockOw
393
436
  /** Owner identity stamped into a `<file>.lock/info` record. */
394
437
  export interface FileLockOwnerToken {
395
438
  pid: number;
439
+ /** Kernel-derived identity for the exact process generation owning `pid`. */
440
+ process_incarnation?: string;
396
441
  start_time?: string;
397
442
  /** Encoding marker for the canonical UTC process-start identity. */
398
443
  start_time_format?: string;
@@ -672,6 +717,11 @@ async function localLockKey(lockPath: string): Promise<string> {
672
717
  }
673
718
 
674
719
  function ownerIncarnationChanged(owner: FileLockOwnerToken, startTimeCache?: Map<string, string | null>): boolean {
720
+ if (owner.process_incarnation) {
721
+ if (ownerLiveness(owner.pid) !== "alive") return false;
722
+ const currentIncarnation = processIncarnation(owner.pid);
723
+ return currentIncarnation !== null && currentIncarnation !== owner.process_incarnation;
724
+ }
675
725
  if (owner.start_time_format !== PROCESS_START_TIME_FORMAT || !owner.start_time || owner.start_time === "unknown")
676
726
  return false;
677
727
  if (ownerLiveness(owner.pid) !== "alive") return false;
@@ -753,6 +803,7 @@ export async function removeFileLockDirForGc(
753
803
  if (!expectedIdentity) return "owner_changed";
754
804
  if (
755
805
  current.pid !== expected.pid ||
806
+ (expected.process_incarnation !== undefined && current.process_incarnation !== expected.process_incarnation) ||
756
807
  (expected.start_time !== undefined && current.start_time !== expected.start_time) ||
757
808
  current.owner_host_id !== expected.owner_host_id ||
758
809
  (expected.owner_token !== undefined && current.owner_token !== expected.owner_token) ||
@@ -931,7 +982,15 @@ async function staleLockSnapshot(
931
982
 
932
983
  async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSnapshot): Promise<boolean> {
933
984
  if (!snapshot.stale) return false;
934
- return (await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity)) === "removed";
985
+ try {
986
+ return (await removeFileLockDirForGc(lockPath, snapshot.owner, snapshot.identity)) === "removed";
987
+ } catch {
988
+ // Exact removal refusal is not authority to fail or mutate by another path.
989
+ // Keep contending: a concurrent reclaimer may already be completing the same
990
+ // dead generation, while a persistent refusal remains fail-closed until the
991
+ // normal retry budget reports the still-held lock.
992
+ return false;
993
+ }
935
994
  }
936
995
 
937
996
  /**
@@ -1332,7 +1391,7 @@ async function lockHolderDescription(lockPath: string): Promise<string> {
1332
1391
  }
1333
1392
  }
1334
1393
 
1335
- async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
1394
+ export async function acquireFileLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
1336
1395
  const requestedFilePath: unknown = filePath;
1337
1396
  if (typeof requestedFilePath !== "string" || requestedFilePath.length === 0 || !path.isAbsolute(requestedFilePath))
1338
1397
  throw new TypeError("filePath must be a non-empty absolute path");
@@ -1344,6 +1403,11 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
1344
1403
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
1345
1404
  const lockPath = getLockPath(filePath);
1346
1405
  await ensureLockParent(path.dirname(lockPath));
1406
+ try {
1407
+ await reapOrphanedLockStagingDirs(lockPath);
1408
+ } catch (error) {
1409
+ logger.debug("Failed to reap orphaned file-lock staging directories", { lockPath, error: String(error) });
1410
+ }
1347
1411
  const ownerToken = crypto.randomUUID();
1348
1412
  const contentionStartTimes = new Map<string, string | null>();
1349
1413
  for (let attempt = 0; attempt < opts.retries; attempt++) {
@@ -1392,10 +1456,7 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
1392
1456
  opts.signal.removeEventListener("abort", onAbort);
1393
1457
  }
1394
1458
  }
1395
- throw new Error(
1396
- `Failed to acquire lock for ${filePath} after ${opts.retries} attempts: ${await lockHolderDescription(lockPath)} (${lockPath}); ` +
1397
- `a live owner is never displaced — if this is an SDK broker (gjc sdk status), it must finish or be stopped before retrying`,
1398
- );
1459
+ throw new FileLockAcquireError(filePath, lockPath, opts.retries, await lockHolderDescription(lockPath));
1399
1460
  }
1400
1461
 
1401
1462
  /**
@@ -1409,7 +1470,7 @@ export async function withFileLock<T>(
1409
1470
  fn: () => Promise<T>,
1410
1471
  options: FileLockOptions = {},
1411
1472
  ): Promise<T> {
1412
- const release = await acquireLock(filePath, options);
1473
+ const release = await acquireFileLock(filePath, options);
1413
1474
  let result: T;
1414
1475
  try {
1415
1476
  result = await fn();
@@ -1424,3 +1485,126 @@ export async function withFileLock<T>(
1424
1485
  await release();
1425
1486
  return result;
1426
1487
  }
1488
+
1489
+ /** Strictly recognize the staging names emitted by tryAcquireLock. */
1490
+ export function fileLockStagingOwnerPid(name: string): number | null {
1491
+ const match = /^.+\.lock\.pending\.([1-9]\d*)\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.exec(
1492
+ name,
1493
+ );
1494
+ if (!match) return null;
1495
+ const pid = Number(match[1]);
1496
+ return Number.isSafeInteger(pid) ? pid : null;
1497
+ }
1498
+
1499
+ export interface FileLockStagingResult {
1500
+ path: string;
1501
+ pid?: number;
1502
+ status: OwnerLiveness;
1503
+ removed: boolean;
1504
+ reason: string;
1505
+ }
1506
+
1507
+ /** Observe identity before probing; neither a replacement nor a symlink inherits the verdict. */
1508
+ export async function inspectFileLockStagingDir(
1509
+ stagingPath: string,
1510
+ probe: (pid: number) => OwnerLiveness = ownerLiveness,
1511
+ remove = false,
1512
+ ): Promise<FileLockStagingResult> {
1513
+ const kept: FileLockStagingResult = {
1514
+ path: stagingPath,
1515
+ status: "unknown",
1516
+ removed: false,
1517
+ reason: "unverified_staging_directory",
1518
+ };
1519
+ const namePid = fileLockStagingOwnerPid(path.basename(stagingPath));
1520
+ if (namePid === null) return kept;
1521
+ const canonical = await canonicalLockPathPreservingFinal(stagingPath);
1522
+ const root = await fs.lstat(canonical, { bigint: true });
1523
+ if (!root.isDirectory() || root.isSymbolicLink()) return kept;
1524
+ const captured = nativeFileLockBindings().snapshotDirectoryTree(canonical);
1525
+ if (
1526
+ !captured.ok ||
1527
+ !captured.snapshot ||
1528
+ captured.snapshot.rootDev !== root.dev.toString() ||
1529
+ captured.snapshot.rootIno !== root.ino.toString()
1530
+ )
1531
+ return kept;
1532
+ const observation = await readFileLockObservationForGc(canonical);
1533
+ let pid: number;
1534
+ if (observation) {
1535
+ if (
1536
+ observation.identity.rootDev !== captured.snapshot.rootDev ||
1537
+ observation.identity.rootIno !== captured.snapshot.rootIno
1538
+ )
1539
+ return kept;
1540
+ pid = observation.info.pid;
1541
+ if (observation.info.owner_host_id !== undefined) return { ...kept, pid, reason: "host_qualified_staging_owner" };
1542
+ } else {
1543
+ // Missing info is the only case where the filename is ownership evidence.
1544
+ try {
1545
+ await fs.lstat(path.join(canonical, "info"));
1546
+ return kept;
1547
+ } catch (error) {
1548
+ if (!isEnoent(error)) throw error;
1549
+ }
1550
+ if (captured.snapshot.entries.some(entry => entry.relativePath !== "")) return kept;
1551
+ pid = namePid;
1552
+ }
1553
+ if (pid === process.pid || namePid === process.pid) {
1554
+ return { ...kept, pid, status: "alive", reason: "in_process_staging_owner" };
1555
+ }
1556
+ let status = probe(pid);
1557
+ if (status === "alive" && observation && ownerIncarnationChanged(observation.info)) status = "dead";
1558
+ const result: FileLockStagingResult = {
1559
+ path: stagingPath,
1560
+ pid,
1561
+ status,
1562
+ removed: false,
1563
+ reason: `file_lock_staging_owner_${status}`,
1564
+ };
1565
+ if (status !== "dead" || !remove) return result;
1566
+ if (observation) {
1567
+ const removal = await removeFileLockDirForGc(canonical, observation.info, observation.identity);
1568
+ return { ...result, removed: removal === "removed", reason: removal };
1569
+ }
1570
+ const removal = nativeFileLockBindings().exactRemoveDirectoryTree(canonical, captured.snapshot);
1571
+ if (
1572
+ removal.detachedPath &&
1573
+ path.resolve(removal.detachedPath) !== path.resolve(canonical) &&
1574
+ !removal.retainedSuccessorPath &&
1575
+ !removal.retainedPlaceholderPath &&
1576
+ !removal.retainedUnknownPath
1577
+ ) {
1578
+ const detached = await fs.lstat(removal.detachedPath, { bigint: true });
1579
+ if (
1580
+ detached.isDirectory() &&
1581
+ !detached.isSymbolicLink() &&
1582
+ detached.dev.toString() === captured.snapshot.rootDev &&
1583
+ detached.ino.toString() === captured.snapshot.rootIno
1584
+ ) {
1585
+ await fs.rmdir(removal.detachedPath);
1586
+ return { ...result, removed: true, reason: "removed" };
1587
+ }
1588
+ }
1589
+ return { ...result, removed: removal.ok, reason: removal.ok ? "removed" : (removal.code ?? "cleanup_failed") };
1590
+ }
1591
+
1592
+ /** Bounded opportunistic cleanup; the acquisition caller treats failures as diagnostic only. */
1593
+ export async function reapOrphanedLockStagingDirs(lockPath: string): Promise<{
1594
+ removed: string[];
1595
+ retained: FileLockStagingResult[];
1596
+ }> {
1597
+ const summary: { removed: string[]; retained: FileLockStagingResult[] } = { removed: [], retained: [] };
1598
+ const parent = path.dirname(lockPath);
1599
+ const prefix = `${path.basename(lockPath)}.pending.`;
1600
+ const entries = await fs.readdir(parent);
1601
+ let candidates = 0;
1602
+ for (const entry of entries) {
1603
+ if (!entry.startsWith(prefix) || fileLockStagingOwnerPid(entry) === null) continue;
1604
+ if (++candidates > 64) break;
1605
+ const result = await inspectFileLockStagingDir(path.join(parent, entry), ownerLiveness, true);
1606
+ if (result.removed) summary.removed.push(result.path);
1607
+ else summary.retained.push(result);
1608
+ }
1609
+ return summary;
1610
+ }