@gajae-code/coding-agent 0.16.3 → 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 (277) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/args.d.ts +2 -2
  4. package/dist/types/cli/customize-doctor.d.ts +22 -0
  5. package/dist/types/cli/root-flags.d.ts +4 -2
  6. package/dist/types/cli.d.ts +1 -1
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  9. package/dist/types/config/file-lock.d.ts +31 -3
  10. package/dist/types/config/model-registry.d.ts +4 -0
  11. package/dist/types/config/model-resolver.d.ts +9 -0
  12. package/dist/types/config/settings-schema.d.ts +29 -1
  13. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  14. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  15. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  16. package/dist/types/discovery/helpers.d.ts +35 -0
  17. package/dist/types/exec/bash-executor.d.ts +17 -1
  18. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  19. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  20. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  21. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  22. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  23. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  24. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  25. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  26. package/dist/types/exec/isolated-shell.d.ts +19 -0
  27. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  28. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  29. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  30. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  31. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  32. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  33. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  34. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  35. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  36. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  37. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  38. package/dist/types/extensibility/runtime-skill-discovery.d.ts +5 -2
  39. package/dist/types/extensibility/shared-events.d.ts +1 -1
  40. package/dist/types/extensibility/skill-management.d.ts +9 -4
  41. package/dist/types/extensibility/skills.d.ts +2 -0
  42. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  43. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  44. package/dist/types/gjc-runtime/session-state-lock.d.ts +14 -0
  45. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  46. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  47. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  48. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  49. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  50. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  51. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  52. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  53. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  54. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  55. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  56. package/dist/types/modes/interactive-mode.d.ts +3 -0
  57. package/dist/types/modes/types.d.ts +7 -0
  58. package/dist/types/runtime-mcp/client.d.ts +9 -1
  59. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  60. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  61. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  62. package/dist/types/runtime-mcp/types.d.ts +22 -0
  63. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  64. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  65. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  66. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  68. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  69. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  70. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  71. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  72. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  73. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  74. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  75. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  76. package/dist/types/sdk/client/client.d.ts +6 -0
  77. package/dist/types/sdk/host/host.d.ts +3 -1
  78. package/dist/types/sdk/host/session-runtime.d.ts +26 -1
  79. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  80. package/dist/types/sdk/router/session-router.d.ts +10 -2
  81. package/dist/types/sdk/session-api-key.d.ts +26 -0
  82. package/dist/types/sdk/session.d.ts +5 -1
  83. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  84. package/dist/types/session/agent-session.d.ts +11 -0
  85. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  86. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  87. package/dist/types/session/messages.d.ts +1 -0
  88. package/dist/types/session/session-manager.d.ts +3 -1
  89. package/dist/types/setup/paseo/announce.d.ts +112 -0
  90. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  91. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  92. package/dist/types/system-prompt.d.ts +10 -0
  93. package/dist/types/task/executor.d.ts +5 -1
  94. package/dist/types/tools/bash.d.ts +1 -0
  95. package/dist/types/tools/fetch.d.ts +7 -0
  96. package/dist/types/tools/github-cache.d.ts +2 -1
  97. package/dist/types/tools/index.d.ts +10 -0
  98. package/dist/types/tools/path-utils.d.ts +2 -0
  99. package/dist/types/tools/search.d.ts +6 -0
  100. package/package.json +9 -7
  101. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  102. package/scripts/generate-sdk-operation-inventory.ts +11 -0
  103. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  104. package/src/capability/index.ts +14 -2
  105. package/src/capability/types.ts +4 -0
  106. package/src/cli/args.ts +20 -9
  107. package/src/cli/customize-doctor.ts +155 -12
  108. package/src/cli/root-flags.ts +5 -3
  109. package/src/cli.ts +22 -0
  110. package/src/commands/sdk.ts +74 -31
  111. package/src/config/autorouting-tier-map.ts +1 -0
  112. package/src/config/file-lock-gc.ts +40 -2
  113. package/src/config/file-lock.ts +379 -31
  114. package/src/config/model-preset-registry.ts +120 -19
  115. package/src/config/model-profiles.ts +40 -0
  116. package/src/config/model-registry.ts +73 -23
  117. package/src/config/model-resolver.ts +48 -0
  118. package/src/config/settings-schema.ts +33 -1
  119. package/src/config/settings.ts +1 -0
  120. package/src/coordinator-mcp/question-state.ts +33 -2
  121. package/src/coordinator-mcp/server.ts +477 -101
  122. package/src/crash/sanitize.ts +1 -1
  123. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  124. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  125. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  126. package/src/discovery/builtin.ts +52 -55
  127. package/src/discovery/claude-plugins.ts +12 -4
  128. package/src/discovery/helpers.ts +307 -34
  129. package/src/exec/bash-executor.ts +330 -50
  130. package/src/exec/bash-shell-guardian.ts +388 -0
  131. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  132. package/src/exec/bash-shell-signals.ts +29 -0
  133. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  134. package/src/exec/bash-shell-supervisor.ts +359 -0
  135. package/src/exec/bash-shell-worker-entry.ts +5 -0
  136. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  137. package/src/exec/bash-shell-worker.ts +116 -0
  138. package/src/exec/isolated-shell.ts +393 -0
  139. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  140. package/src/extensibility/extensions/function-hooks.ts +959 -0
  141. package/src/extensibility/extensions/index.ts +22 -0
  142. package/src/extensibility/extensions/loader.ts +53 -2
  143. package/src/extensibility/extensions/runner.ts +895 -264
  144. package/src/extensibility/extensions/types.ts +42 -0
  145. package/src/extensibility/extensions/wrapper.ts +56 -15
  146. package/src/extensibility/gjc-plugins/compiler.ts +228 -60
  147. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  148. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  149. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  150. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  151. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  152. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  153. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  154. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  155. package/src/extensibility/gjc-plugins/types.ts +25 -0
  156. package/src/extensibility/hooks/loader.ts +133 -16
  157. package/src/extensibility/hooks/types.ts +28 -0
  158. package/src/extensibility/runtime-skill-discovery.ts +149 -190
  159. package/src/extensibility/shared-events.ts +1 -1
  160. package/src/extensibility/skill-management.ts +200 -52
  161. package/src/extensibility/skills.ts +73 -25
  162. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  163. package/src/gjc-runtime/gc-runtime.ts +29 -0
  164. package/src/gjc-runtime/linux-proc.ts +14 -0
  165. package/src/gjc-runtime/repository-binding.ts +1 -1
  166. package/src/gjc-runtime/session-state-lock.ts +143 -10
  167. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  168. package/src/gjc-runtime/state-writer.ts +30 -29
  169. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  170. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  171. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  172. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  173. package/src/harness-control-plane/session-lease.ts +2 -2
  174. package/src/internal-urls/docs-index.generated.ts +12 -12
  175. package/src/lsp/config.ts +10 -3
  176. package/src/lsp/index.ts +1 -1
  177. package/src/lsp/path-trust.ts +69 -14
  178. package/src/main.ts +78 -35
  179. package/src/modes/DESIGN.md +54 -0
  180. package/src/modes/acp/acp-agent.ts +16 -3
  181. package/src/modes/components/assistant-message.ts +22 -9
  182. package/src/modes/components/bash-execution.ts +4 -1
  183. package/src/modes/components/execution-shared.ts +4 -1
  184. package/src/modes/components/model-selector.ts +166 -33
  185. package/src/modes/components/session-observer-overlay.ts +1 -1
  186. package/src/modes/components/settings-selector.ts +1075 -235
  187. package/src/modes/components/status-line/command.ts +190 -0
  188. package/src/modes/components/status-line/index.ts +1 -0
  189. package/src/modes/components/status-line/segments.ts +32 -5
  190. package/src/modes/components/status-line/types.ts +5 -0
  191. package/src/modes/components/tool-status-header.ts +396 -22
  192. package/src/modes/controllers/command-controller.ts +3 -0
  193. package/src/modes/controllers/event-controller.ts +151 -7
  194. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  195. package/src/modes/controllers/input-controller.ts +6 -3
  196. package/src/modes/controllers/selector-controller.ts +21 -0
  197. package/src/modes/interactive-mode.ts +53 -6
  198. package/src/modes/print-mode.ts +5 -1
  199. package/src/modes/prompt-action-autocomplete.ts +54 -27
  200. package/src/modes/runtime-init.ts +2 -0
  201. package/src/modes/types.ts +17 -3
  202. package/src/modes/utils/ui-helpers.ts +2 -0
  203. package/src/prompts/system/system-prompt.md +8 -0
  204. package/src/prompts/tools/search.md +2 -0
  205. package/src/prompts/tools/subagent.md +1 -0
  206. package/src/runtime-mcp/client.ts +60 -17
  207. package/src/runtime-mcp/manager.ts +189 -14
  208. package/src/runtime-mcp/pool.test.ts +218 -0
  209. package/src/runtime-mcp/pool.ts +224 -23
  210. package/src/runtime-mcp/transports/stdio.ts +158 -24
  211. package/src/runtime-mcp/types.ts +24 -0
  212. package/src/sdk/broker/broker.ts +124 -32
  213. package/src/sdk/broker/ensure.ts +248 -41
  214. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  215. package/src/sdk/broker/lifecycle.ts +56 -6
  216. package/src/sdk/broker/startup-failure.ts +15 -6
  217. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  218. package/src/sdk/bus/chat-daemon-runtime.ts +72 -6
  219. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  220. package/src/sdk/bus/conversation-store.ts +29 -0
  221. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  222. package/src/sdk/bus/slack-conversation.ts +1 -0
  223. package/src/sdk/bus/slack-daemon.ts +478 -49
  224. package/src/sdk/bus/slack-live-provider.ts +12 -0
  225. package/src/sdk/bus/slack-provider.ts +5 -0
  226. package/src/sdk/bus/telegram-daemon.ts +16 -2
  227. package/src/sdk/bus/topic-registry.ts +19 -0
  228. package/src/sdk/cli/master-cli.ts +1 -1
  229. package/src/sdk/cli/session-cli.ts +11 -8
  230. package/src/sdk/client/client.ts +66 -17
  231. package/src/sdk/host/host.ts +29 -9
  232. package/src/sdk/host/query/revision-store.ts +19 -13
  233. package/src/sdk/host/reverse-leases.ts +7 -4
  234. package/src/sdk/host/session-runtime.test.ts +52 -10
  235. package/src/sdk/host/session-runtime.ts +383 -44
  236. package/src/sdk/lifecycle/service.ts +4 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +77 -0
  238. package/src/sdk/router/session-router.ts +89 -38
  239. package/src/sdk/session-api-key.ts +94 -0
  240. package/src/sdk/session.ts +228 -55
  241. package/src/sdk/transport/relay.ts +35 -13
  242. package/src/sdk/transport/serve-cli.ts +3 -3
  243. package/src/session/agent-session.ts +240 -90
  244. package/src/session/contribution-prep.ts +214 -21
  245. package/src/session/fallback-chain-controller.ts +38 -0
  246. package/src/session/internal/managed-session-storage.ts +19 -5
  247. package/src/session/messages.ts +5 -0
  248. package/src/session/session-manager.ts +29 -5
  249. package/src/session-import/redact.ts +5 -1
  250. package/src/setup/paseo/announce.ts +456 -0
  251. package/src/setup/paseo/setup-deps.ts +15 -3
  252. package/src/skill-state/canonical-skills.ts +12 -0
  253. package/src/skills/skills.test.ts +41 -21
  254. package/src/slash-commands/builtin-registry.ts +1 -0
  255. package/src/system-prompt.ts +29 -3
  256. package/src/task/executor.ts +7 -1
  257. package/src/task/index.ts +21 -4
  258. package/src/tools/bash.ts +63 -19
  259. package/src/tools/cron.ts +1 -1
  260. package/src/tools/fetch.ts +29 -1
  261. package/src/tools/gh.ts +26 -3
  262. package/src/tools/github-cache.ts +68 -16
  263. package/src/tools/index.ts +10 -0
  264. package/src/tools/job.ts +21 -4
  265. package/src/tools/monitor.ts +8 -2
  266. package/src/tools/path-utils.ts +7 -0
  267. package/src/tools/read.ts +14 -2
  268. package/src/tools/render-utils.ts +1 -3
  269. package/src/tools/resource-gc.ts +28 -8
  270. package/src/tools/search.ts +116 -58
  271. package/src/tools/skill-discovery.ts +5 -0
  272. package/src/tools/skill.ts +37 -2
  273. package/src/tools/subagent.ts +6 -3
  274. package/src/tools/tool-catalog.generated.ts +10 -3
  275. package/src/tools/ultragoal-ask-guard.ts +2 -2
  276. package/src/web/insane/bridge.ts +113 -23
  277. package/src/web/search/providers/perplexity.ts +102 -41
@@ -7,11 +7,15 @@ import { randomUUID } from "node:crypto";
7
7
  import type { BigIntStats } from "node:fs";
8
8
  import * as fs from "node:fs/promises";
9
9
  import * as path from "node:path";
10
- import { exactUnlinkDirect, type NativeExactUnlinkResult } from "@gajae-code/natives";
10
+ import { exactUnlinkDirect, type NativeExactUnlinkResult, snapshotDirectoryTree } from "@gajae-code/natives";
11
11
 
12
12
  export const EMPTY_DELETE_PREFIX = ".gjc-delete-";
13
13
  const EMPTY_DELETE_RECEIPT_PATTERN =
14
14
  /^\.gjc-delete-session-state-lock-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/u;
15
+ // session-storage uses a GC UUID; managed-session-scope uses stableOperationName's
16
+ // SHA-256 plus a numbered attempt. Neither arbitrary labels nor other receipts qualify.
17
+ const EMPTY_ARTIFACT_REMOVAL_ROOT_PATTERN =
18
+ /^\.gjc-delete-(?:gc-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-artifacts|[0-9a-f]{64}-artifacts-[1-9]\d*)\.removing$/u;
15
19
  const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
16
20
 
17
21
  export interface EmptyDeleteIdentity {
@@ -22,7 +26,7 @@ export interface EmptyDeleteIdentity {
22
26
  mtimeNs: bigint;
23
27
  parentDev: bigint;
24
28
  parentIno: bigint;
25
- sha256: string;
29
+ sha256?: string;
26
30
  }
27
31
 
28
32
  export interface EmptyDeleteGcRecord {
@@ -51,12 +55,15 @@ export interface EmptyDeleteGcReport {
51
55
  export interface EmptyDeleteGcOptions {
52
56
  roots: string[];
53
57
  prune: boolean;
58
+ /** Disk artifact GC must not remove unrelated file receipts. */
59
+ artifactRootsOnly?: boolean;
54
60
  }
55
61
 
56
62
  export interface EmptyDeleteGcDependencies {
57
63
  /** Internal seam for deterministic collect-to-prune race tests. */
58
64
  collect?: (root: string) => Promise<EmptyDeleteGcRecord[]>;
59
65
  exactUnlinkDirect?: typeof exactUnlinkDirect;
66
+ rmdir?: (directory: string) => Promise<void>;
60
67
  }
61
68
 
62
69
  function isUnsafeName(name: string): boolean {
@@ -100,6 +107,64 @@ function isEmptyDeleteReceiptName(name: string): boolean {
100
107
  return EMPTY_DELETE_RECEIPT_PATTERN.test(name);
101
108
  }
102
109
 
110
+ function isEmptyArtifactRemovalRootName(name: string): boolean {
111
+ return EMPTY_ARTIFACT_REMOVAL_ROOT_PATTERN.test(name);
112
+ }
113
+
114
+ function artifactRootReason(file: string, stat: BigIntStats): string {
115
+ if (!stat.isDirectory()) return "not_directory";
116
+ // The native snapshot opens every component with O_NOFOLLOW and enumerates
117
+ // with fdopendir/readdir on the retained directory handle. A root-only snapshot
118
+ // proves zero entries; pathname readdir alone cannot supply that authority.
119
+ const result = snapshotDirectoryTree(file);
120
+ if (!result.ok || !result.snapshot) return `entry_unverifiable: ${result.code ?? "unknown"}`;
121
+ const snapshot = result.snapshot;
122
+ if (snapshot.rootDev !== stat.dev.toString() || snapshot.rootIno !== stat.ino.toString()) return "identity_drift";
123
+ return snapshot.entries.length === 1 && snapshot.entries[0].relativePath === ""
124
+ ? "empty_artifact_removal_root"
125
+ : "non_empty";
126
+ }
127
+
128
+ async function removeEmptyArtifactRoot(
129
+ record: EmptyDeleteGcRecord,
130
+ rmdir: (directory: string) => Promise<void>,
131
+ ): Promise<void> {
132
+ try {
133
+ const expected = record.identity;
134
+ if (!expected) {
135
+ record.action = "kept";
136
+ record.reason = "identity_missing";
137
+ return;
138
+ }
139
+ const parent = await fs.lstat(path.dirname(record.path), { bigint: true });
140
+ const stat = await fs.lstat(record.path, { bigint: true });
141
+ if (
142
+ !parent.isDirectory() ||
143
+ parent.dev !== expected.parentDev ||
144
+ parent.ino !== expected.parentIno ||
145
+ !stat.isDirectory() ||
146
+ stat.dev !== expected.dev ||
147
+ stat.ino !== expected.ino
148
+ ) {
149
+ record.action = "kept";
150
+ record.reason = "identity_drift";
151
+ return;
152
+ }
153
+ const reason = artifactRootReason(record.path, stat);
154
+ if (reason !== "empty_artifact_removal_root") {
155
+ record.action = "kept";
156
+ record.reason = reason;
157
+ return;
158
+ }
159
+ // Never recursive: a writer winning after the empty check makes this fail closed.
160
+ await rmdir(record.path);
161
+ record.action = "removed";
162
+ } catch (error) {
163
+ record.action = "kept";
164
+ record.reason = `entry_remove_failed: ${error instanceof Error ? error.message : String(error)}`;
165
+ }
166
+ }
167
+
103
168
  function sameRootIdentity(left: BigIntStats, right: BigIntStats): boolean {
104
169
  return left.isDirectory() && right.isDirectory() && left.dev === right.dev && left.ino === right.ino;
105
170
  }
@@ -150,7 +215,7 @@ export async function collectEmptyDeleteReceipts(
150
215
  throw error;
151
216
  }
152
217
  for (const name of names) {
153
- if (isUnsafeName(name) || !isEmptyDeleteReceiptName(name)) continue;
218
+ if (isUnsafeName(name) || (!isEmptyDeleteReceiptName(name) && !isEmptyArtifactRemovalRootName(name))) continue;
154
219
  const file = path.join(resolvedRoot, name);
155
220
  let stat: BigIntStats;
156
221
  try {
@@ -169,6 +234,17 @@ export async function collectEmptyDeleteReceipts(
169
234
  records.push({ root, path: file, action: "kept", reason: "symlink" });
170
235
  continue;
171
236
  }
237
+ if (isEmptyArtifactRemovalRootName(name)) {
238
+ const reason = artifactRootReason(file, stat);
239
+ records.push({
240
+ root,
241
+ path: file,
242
+ action: reason === "empty_artifact_removal_root" ? "would_remove" : "kept",
243
+ reason,
244
+ identity: { ...identityOf(stat), parentDev: rootStat.dev, parentIno: rootStat.ino },
245
+ });
246
+ continue;
247
+ }
172
248
  if (!stat.isFile()) {
173
249
  records.push({ root, path: file, action: "kept", reason: "not_regular" });
174
250
  continue;
@@ -232,6 +308,12 @@ export async function runEmptyDeleteGc(
232
308
  continue;
233
309
  }
234
310
  for (const record of records) {
311
+ if (
312
+ options.artifactRootsOnly &&
313
+ record.path !== root &&
314
+ !isEmptyArtifactRemovalRootName(path.basename(record.path))
315
+ )
316
+ continue;
235
317
  if (record.action === "would_remove" && !options.prune) record.observationOnly = true;
236
318
  if (
237
319
  record.action === "skipped" &&
@@ -244,7 +326,15 @@ export async function runEmptyDeleteGc(
244
326
  ) {
245
327
  report.errors.push(`${record.path}: ${record.reason}`);
246
328
  }
247
- if (record.action === "would_remove" && options.prune) {
329
+ if (
330
+ record.action === "would_remove" &&
331
+ options.prune &&
332
+ isEmptyArtifactRemovalRootName(path.basename(record.path))
333
+ ) {
334
+ await removeEmptyArtifactRoot(record, deps.rmdir ?? fs.rmdir);
335
+ if (record.reason.startsWith("entry_remove_failed:"))
336
+ report.errors.push(`${record.path}: ${record.reason}`);
337
+ } else if (record.action === "would_remove" && options.prune) {
248
338
  try {
249
339
  if (!record.identity) {
250
340
  record.action = "kept";
@@ -1704,6 +1704,35 @@ async function runGcDiskArtifacts(input: {
1704
1704
  const sessionIndex = new SessionIndex(agentDir);
1705
1705
  try {
1706
1706
  await sessionIndex.withLocked(async () => {
1707
+ // Quarantine roots can outlive every transcript, so enumerate project
1708
+ // directories independently of survivors, under the same index lock.
1709
+ let projects: Dirent[] = [];
1710
+ try {
1711
+ projects = await fsp.readdir(surface.root, { withFileTypes: true });
1712
+ } catch (error) {
1713
+ if (!isEnoent(error))
1714
+ errors.push({ surface: "artifacts", scope: surface.root, message: gcDiskErrorText(error) });
1715
+ }
1716
+ for (const project of projects) {
1717
+ if (!project.isDirectory() || project.isSymbolicLink()) continue;
1718
+ const directory = path.join(surface.root, project.name);
1719
+ const cleanup = await runEmptyDeleteGc({ roots: [directory], prune, artifactRootsOnly: true });
1720
+ for (const message of cleanup.errors) errors.push({ surface: "artifacts", scope: directory, message });
1721
+ for (const receipt of cleanup.records) {
1722
+ const failed = receipt.reason.startsWith("entry_remove_failed:");
1723
+ surface.records.push({
1724
+ surface: "artifacts",
1725
+ id: `${project.name}/${path.basename(receipt.path)}`,
1726
+ path: receipt.path,
1727
+ bytes: 0,
1728
+ age_days: receipt.identity ? gcDiskAgeDays(now, Number(receipt.identity.mtimeNs / 1_000_000n)) : 0,
1729
+ action: receipt.action === "removed" ? "reclaimed" : failed ? "reclaim_failed" : "keep",
1730
+ reason: receipt.reason,
1731
+ ...(receipt.action === "kept" || receipt.action === "skipped" ? { withheld: true as const } : {}),
1732
+ ...(failed ? { error: receipt.reason } : {}),
1733
+ });
1734
+ }
1735
+ }
1707
1736
  // Recomputed over survivors: whichever transcript the sessions surface left
1708
1737
  // newest in a project directory is the `--continue` target, and a resumed
1709
1738
  // session still reads its own `artifact://` handles.
@@ -66,6 +66,20 @@ export function parseLinuxProcState(stat: string | null | undefined): string | n
66
66
  return parseLinuxProcIdentity(stat)?.state ?? null;
67
67
  }
68
68
 
69
+ /** Parse field 4 (`ppid`) from a `/proc/<pid>/stat` record. */
70
+ export function parseLinuxProcParentPid(stat: string | null | undefined): number | null {
71
+ if (!stat || stat.includes("\0") || stat.includes("\r")) return null;
72
+ const record = stat.endsWith("\n") ? stat.slice(0, -1) : stat;
73
+ const close = record.lastIndexOf(")");
74
+ if (close < 0) return null;
75
+ const fields = record
76
+ .slice(close + 1)
77
+ .trim()
78
+ .split(/[ \t]+/);
79
+ const parentPid = Number.parseInt(fields[1] ?? "", 10);
80
+ return Number.isSafeInteger(parentPid) && parentPid >= 0 ? parentPid : null;
81
+ }
82
+
69
83
  export function probeLinuxProcPidSync(pid: number): LinuxProcPidProbeResult {
70
84
  if (!Number.isSafeInteger(pid) || pid <= 0) return { kind: "unverifiable", reason: "invalid_pid" };
71
85
  if (process.platform !== "linux") return { kind: "unverifiable", reason: "unsupported_platform" };
@@ -106,7 +106,7 @@ function normalizeRelativeSubdir(relative: string): string {
106
106
  .replaceAll("\\", "/")
107
107
  .replace(/^\.\/+/u, "")
108
108
  .replace(/\/+$/u, "");
109
- if (normalized === "" || normalized === ".") {
109
+ if (normalized === "") {
110
110
  throw new RepositoryBindingError("invalid_binding", "relativeSubdir must be a non-empty relative path");
111
111
  }
112
112
  if (path.isAbsolute(normalized) || normalized.split("/").includes("..")) {
@@ -37,18 +37,20 @@ const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca4959
37
37
  */
38
38
  const LOCK_ACQUIRE_RETRY_MS = 5;
39
39
  const LOCK_ACQUIRE_MAX_RETRY_MS = 100;
40
- const LOCK_ACQUIRE_TIMEOUT_MS = 2_000;
40
+ const LOCK_ACQUIRE_TIMEOUT_MS = 5_000;
41
41
  const LOCK_STALE_MS = 30_000;
42
42
  const RELEASED_TRANSITION_GRACE_MS = 1_000;
43
+ const SESSION_STATE_LOCK_BUSY = Symbol("session-state-lock-busy");
43
44
 
44
45
  interface LockRetryBudget {
45
46
  startedAt: number;
46
47
  nextDelayMs: number;
47
48
  attempts: number;
49
+ nonBlocking: boolean;
48
50
  }
49
51
 
50
- function lockRetryBudget(): LockRetryBudget {
51
- return { startedAt: performance.now(), nextDelayMs: LOCK_ACQUIRE_RETRY_MS, attempts: 0 };
52
+ function lockRetryBudget(nonBlocking = false): LockRetryBudget {
53
+ return { startedAt: performance.now(), nextDelayMs: LOCK_ACQUIRE_RETRY_MS, attempts: 0, nonBlocking };
52
54
  }
53
55
 
54
56
  function lockRetryElapsedMs(budget: LockRetryBudget): number {
@@ -146,7 +148,44 @@ interface PendingTransitionRelease {
146
148
  recovery?: Promise<boolean>;
147
149
  }
148
150
 
151
+ interface LocalTransitionQueue {
152
+ waiters: Array<() => void>;
153
+ }
154
+
149
155
  const pendingTransitionReleases = new Map<string, PendingTransitionRelease>();
156
+ const localTransitionQueues = new Map<string, LocalTransitionQueue>();
157
+
158
+ async function joinLocalTransitionQueue(
159
+ key: string,
160
+ transitionDir: string,
161
+ retryBudget?: LockRetryBudget,
162
+ ): Promise<LocalTransitionQueue | undefined> {
163
+ const queue = localTransitionQueues.get(key);
164
+ if (!queue) return undefined;
165
+ if (retryBudget?.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
166
+ const ready = Promise.withResolvers<void>();
167
+ const queuedAt = performance.now();
168
+ SessionStateLockTestHooks.afterLocalTransitionQueued?.(transitionDir);
169
+ queue.waiters.push(ready.resolve);
170
+ await ready.promise;
171
+ if (retryBudget) retryBudget.startedAt += Math.max(0, performance.now() - queuedAt);
172
+ return queue;
173
+ }
174
+
175
+ function claimLocalTransitionQueue(key: string): LocalTransitionQueue {
176
+ const queue: LocalTransitionQueue = { waiters: [] };
177
+ localTransitionQueues.set(key, queue);
178
+ return queue;
179
+ }
180
+
181
+ function releaseLocalTransitionQueue(key: string, queue: LocalTransitionQueue): void {
182
+ const next = queue.waiters.shift();
183
+ if (next) {
184
+ next();
185
+ return;
186
+ }
187
+ if (localTransitionQueues.get(key) === queue) localTransitionQueues.delete(key);
188
+ }
150
189
 
151
190
  function clearPendingTransitionRelease(key: string, pending?: PendingTransitionRelease): void {
152
191
  const current = pendingTransitionReleases.get(key);
@@ -413,6 +452,8 @@ export const SessionStateLockTestHooks: {
413
452
  forcedQuarantineName?: string;
414
453
  /** @internal Observes bounded acquisition retries without changing their timing. */
415
454
  afterAcquireContention?: (lockFile: string, attempt: number, elapsedMs: number) => void;
455
+ /** @internal Observes process-local admission without altering on-disk ownership. */
456
+ afterLocalTransitionQueued?: (transitionDir: string) => void;
416
457
  /** @internal Runs after transition mkdir contention and before stale-claim inspection. */
417
458
  afterTransitionClaimContention?: (transitionDir: string) => void | Promise<void>;
418
459
  } = {};
@@ -1781,6 +1822,7 @@ async function recoverPendingTransitionRelease(
1781
1822
  transitionDir: string,
1782
1823
  recoveryKey: string,
1783
1824
  quarantineName: string,
1825
+ nonBlocking = false,
1784
1826
  ): Promise<boolean> {
1785
1827
  let pendingKey = recoveryKey;
1786
1828
  let pending = pendingTransitionReleases.get(pendingKey);
@@ -1813,7 +1855,10 @@ async function recoverPendingTransitionRelease(
1813
1855
  // for a contender while that release is still in progress. The owner that armed
1814
1856
  // it flips this bit only when a phase actually throws.
1815
1857
  if (!pending.recoverable) return false;
1816
- if (pending.recovery) return await pending.recovery;
1858
+ if (pending.recovery) {
1859
+ if (nonBlocking) throw SESSION_STATE_LOCK_BUSY;
1860
+ return await pending.recovery;
1861
+ }
1817
1862
  const recovery = (async (): Promise<boolean> => {
1818
1863
  const ownerFile = `${transitionDir}.owner`;
1819
1864
  if (!pending.generation) {
@@ -1993,7 +2038,14 @@ async function recoverPendingTransitionRelease(
1993
2038
  }
1994
2039
  }
1995
2040
 
1996
- /** Run one pathname transition under an atomic `mkdir`/`rmdir` claim. */
2041
+ /**
2042
+ * Run one pathname transition under an atomic `mkdir`/`rmdir` claim.
2043
+ *
2044
+ * Same-process contenders join the current claim owner's lifetime before consuming their
2045
+ * bounded filesystem-contention budget. The on-disk protocol remains authoritative across
2046
+ * processes; this queue only prevents a descheduled local owner from timing out its own
2047
+ * successor while it still legitimately holds the claim.
2048
+ */
1997
2049
  async function withLockPathTransition<T>(
1998
2050
  lockFile: string,
1999
2051
  transition: () => Promise<T>,
@@ -2003,25 +2055,75 @@ async function withLockPathTransition<T>(
2003
2055
  if (ownerAccessStrategy() === "unsupported")
2004
2056
  throw new SessionStateLockUnavailableError(new Error("Safe transition ownership is unsupported."));
2005
2057
  const transitionDir = `${lockFile}${LOCK_TRANSITION_RESOURCE_SUFFIX}`;
2006
- const ownerFile = `${transitionDir}.owner`;
2007
2058
  const recoveryKey = await transitionRecoveryKey(transitionDir);
2059
+ const localKey = recoveryKey;
2008
2060
  const owner = await newLockOwner();
2009
2061
  const budget = retryBudget ?? lockRetryBudget();
2062
+ let localTurn = await joinLocalTransitionQueue(localKey, transitionDir, budget);
2063
+ const claimLocalTurn = (): void => {
2064
+ localTurn ??= claimLocalTransitionQueue(localKey);
2065
+ };
2066
+ const joinLocalTurn = async (): Promise<boolean> => {
2067
+ if (localTurn) return false;
2068
+ const joined = await joinLocalTransitionQueue(localKey, transitionDir, budget);
2069
+ if (!joined) return false;
2070
+ localTurn = joined;
2071
+ return true;
2072
+ };
2073
+ try {
2074
+ return await runLockPathTransition(
2075
+ lockFile,
2076
+ recoveryKey,
2077
+ owner,
2078
+ budget,
2079
+ claimLocalTurn,
2080
+ joinLocalTurn,
2081
+ transition,
2082
+ quarantineName,
2083
+ );
2084
+ } finally {
2085
+ if (localTurn) releaseLocalTransitionQueue(localKey, localTurn);
2086
+ }
2087
+ }
2088
+
2089
+ async function runLockPathTransition<T>(
2090
+ lockFile: string,
2091
+ recoveryKey: string,
2092
+ owner: SessionStateLockOwner,
2093
+ budget: LockRetryBudget,
2094
+ claimLocalTurn: () => void,
2095
+ joinLocalTurn: () => Promise<boolean>,
2096
+ transition: () => Promise<T>,
2097
+ quarantineName = lockQuarantineName(),
2098
+ ): Promise<T> {
2099
+ if (ownerAccessStrategy() === "unsupported")
2100
+ throw new SessionStateLockUnavailableError(new Error("Safe transition ownership is unsupported."));
2101
+ const transitionDir = `${lockFile}${LOCK_TRANSITION_RESOURCE_SUFFIX}`;
2102
+ const ownerFile = `${transitionDir}.owner`;
2103
+
2010
2104
  for (;;) {
2011
2105
  if (ownerAccessStrategy() === "unsupported" && fsSync.existsSync(transitionDir))
2012
2106
  throw new SessionStateLockUnavailableError(new Error("Safe transition ownership is unsupported."));
2013
- if (await recoverPendingTransitionRelease(transitionDir, recoveryKey, quarantineName)) {
2107
+ if (await recoverPendingTransitionRelease(transitionDir, recoveryKey, quarantineName, budget.nonBlocking)) {
2014
2108
  // The claim this process stranded in an earlier failed release is gone;
2015
2109
  // fall through and retry the mkdir immediately.
2016
2110
  }
2111
+ if (await joinLocalTurn()) continue;
2017
2112
  try {
2018
2113
  await fs.mkdir(transitionDir, { mode: 0o700 });
2114
+ claimLocalTurn();
2019
2115
  await fs.chmod(transitionDir, 0o700);
2020
2116
  } catch (error) {
2021
2117
  const code = (error as NodeJS.ErrnoException).code;
2022
- if (code !== "EEXIST" && !isTransientLockError(error)) throw new SessionStateLockUnavailableError(error);
2118
+ if (code !== "EEXIST" && (budget.nonBlocking || !isTransientLockError(error)))
2119
+ throw new SessionStateLockUnavailableError(error);
2120
+ if (await joinLocalTurn()) continue;
2023
2121
  await SessionStateLockTestHooks.afterTransitionClaimContention?.(transitionDir);
2024
- if (await reclaimStaleTransitionClaim(transitionDir, quarantineName)) {
2122
+ const reclaimed = await reclaimStaleTransitionClaim(transitionDir, quarantineName);
2123
+ // One safe reclaim may make progress, but a try-acquire never follows a
2124
+ // succession of contenders. A later maintenance pass can take the freed claim.
2125
+ if (budget.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
2126
+ if (reclaimed) {
2025
2127
  if (lockRetryExhausted(budget))
2026
2128
  throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
2027
2129
  continue;
@@ -2461,8 +2563,33 @@ export async function reclaimStaleSessionStateLock(lockFile: string, quarantineN
2461
2563
  * lock file itself and may run for as long as it needs.
2462
2564
  */
2463
2565
  export async function withSessionStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
2566
+ return await runWithSessionStateFileLock(stateFile, operation, lockRetryBudget());
2567
+ }
2568
+
2569
+ /**
2570
+ * Try one admission without joining a live owner's queue or backing off on contention.
2571
+ * A stale reclaim may return `acquired: false` after making the next attempt possible.
2572
+ * Once admitted, the operation and identity-safe release use the normal lock protocol;
2573
+ * their failures propagate rather than being reported as contention.
2574
+ */
2575
+ export async function tryWithSessionStateFileLock<T>(
2576
+ stateFile: string,
2577
+ operation: () => Promise<T>,
2578
+ ): Promise<{ acquired: true; value: T } | { acquired: false }> {
2579
+ try {
2580
+ return { acquired: true, value: await runWithSessionStateFileLock(stateFile, operation, lockRetryBudget(true)) };
2581
+ } catch (error) {
2582
+ if (error === SESSION_STATE_LOCK_BUSY) return { acquired: false };
2583
+ throw error;
2584
+ }
2585
+ }
2586
+
2587
+ async function runWithSessionStateFileLock<T>(
2588
+ stateFile: string,
2589
+ operation: () => Promise<T>,
2590
+ budget: LockRetryBudget,
2591
+ ): Promise<T> {
2464
2592
  const lockFile = `${stateFile}.lock`;
2465
- const budget = lockRetryBudget();
2466
2593
  let owner: SessionStateLockOwner;
2467
2594
  try {
2468
2595
  owner = await newLockOwner();
@@ -2518,6 +2645,11 @@ export async function withSessionStateFileLock<T>(stateFile: string, operation:
2518
2645
  if (callbackFailure && error === callbackError) throw error;
2519
2646
  // A fault after the lock was taken belongs to the operation, not to acquisition.
2520
2647
  if (held) throw lockDiagnostic(error, lockFile, "lock_release_failed");
2648
+ if (error === SESSION_STATE_LOCK_BUSY) throw error;
2649
+ // Typed protocol failures include transition setup/release faults. A
2650
+ // try-acquire must not downgrade those into a successful busy response.
2651
+ if (budget.nonBlocking && error instanceof SessionStateLockUnavailableError)
2652
+ throw lockDiagnostic(error, lockFile, "lock_inspection_failed", budget);
2521
2653
  // Without a safe owner-record access strategy, retrying cannot make the
2522
2654
  // transition claim removable. Preserve it as a fail-closed fence instead
2523
2655
  // of spinning until the acquisition budget expires.
@@ -2540,6 +2672,7 @@ export async function withSessionStateFileLock<T>(stateFile: string, operation:
2540
2672
  );
2541
2673
  if (reclaim === "owner_unprovenanced")
2542
2674
  throw lockUnavailable(lockFile, "lock_owner_record_unprovenanced", budget);
2675
+ if (budget.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
2543
2676
  if (reclaim === "legacy_directory_unprovenanced") lastReason = "legacy_directory_owner_unprovenanced";
2544
2677
  else if (reclaim === "owner_live_or_unverifiable") lastReason = "lock_owner_live_or_unverifiable";
2545
2678
  else if (reclaim === "owner_record_fresh") lastReason = "lock_owner_record_fresh";
@@ -1004,9 +1004,13 @@ export function eventAffectsCoordinatorRuntimeState(event: RuntimeStateEvent): b
1004
1004
  }
1005
1005
 
1006
1006
  class PreviousRuntimeStateReadError extends Error {
1007
- constructor(cause?: unknown) {
1007
+ constructor(cause?: unknown, detail?: string) {
1008
1008
  const lockDetail = cause instanceof SessionStateLockUnavailableError ? ` ${cause.message}` : "";
1009
- super(`Existing runtime state marker is invalid or unreadable; refusing to overwrite.${lockDetail}`);
1009
+ super(
1010
+ detail
1011
+ ? `Existing runtime state marker violates the lifecycle contract: ${detail}; refusing to overwrite.`
1012
+ : `Existing runtime state marker is invalid or unreadable; refusing to overwrite.${lockDetail}`,
1013
+ );
1010
1014
  this.name = "PreviousRuntimeStateReadError";
1011
1015
  if (cause !== undefined) this.cause = cause;
1012
1016
  }
@@ -1016,7 +1020,7 @@ class PreviousRuntimeStateReadError extends Error {
1016
1020
  * A readable, well-formed marker recorded against a different workspace path.
1017
1021
  *
1018
1022
  * Separate from {@link PreviousRuntimeStateReadError} because the two demand different
1019
- * operator responses: an unreadable marker points at file damage, while a foreign marker
1023
+ * operator responses: an invalid marker points at file damage or lifecycle contradictions, while a foreign marker
1020
1024
  * points at the same session directory being reachable from two checkouts.
1021
1025
  */
1022
1026
  class ForeignRuntimeStateError extends Error {
@@ -1084,11 +1088,29 @@ function isAbsentStateFileError(error: unknown): boolean {
1084
1088
 
1085
1089
  function parsePreviousPayload(raw: string): Record<string, unknown> {
1086
1090
  const payload: unknown = JSON.parse(raw);
1087
- if (!validPreviousRuntimeStatePayload(payload)) throw new PreviousRuntimeStateReadError();
1091
+ if (!validPreviousRuntimeStateShape(payload)) throw new PreviousRuntimeStateReadError();
1092
+ // Structural validation above bounds every interpolated value to a known state
1093
+ // or boolean; never include arbitrary marker contents in a terminal diagnostic.
1094
+ if (payload.ready_for_input !== undefined) {
1095
+ const expectedReady = payload.state === "ready_for_input";
1096
+ if (payload.ready_for_input !== expectedReady)
1097
+ throw new PreviousRuntimeStateReadError(
1098
+ undefined,
1099
+ `ready_for_input must be ${expectedReady} when state is ${payload.state} (received ${payload.ready_for_input})`,
1100
+ );
1101
+ }
1102
+ if (payload.live !== undefined && payload.live !== null) {
1103
+ const expectedLive = payload.state === "running";
1104
+ if (payload.live !== expectedLive)
1105
+ throw new PreviousRuntimeStateReadError(
1106
+ undefined,
1107
+ `live must be ${expectedLive} when state is ${payload.state} (received ${payload.live})`,
1108
+ );
1109
+ }
1088
1110
  return payload;
1089
1111
  }
1090
1112
 
1091
- function validPreviousRuntimeStatePayload(value: unknown): value is Record<string, unknown> {
1113
+ function validPreviousRuntimeStateShape(value: unknown): value is Record<string, unknown> {
1092
1114
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1093
1115
  const payload = value as Record<string, unknown>;
1094
1116
  if (
@@ -1125,12 +1147,6 @@ function validPreviousRuntimeStatePayload(value: unknown): value is Record<strin
1125
1147
  (typeof payload.updated_at !== "string" || !Number.isFinite(Date.parse(payload.updated_at)))
1126
1148
  )
1127
1149
  return false;
1128
- if (payload.ready_for_input !== undefined) {
1129
- const expectedReady = payload.state === "ready_for_input";
1130
- if (payload.ready_for_input !== expectedReady) return false;
1131
- }
1132
- if (payload.live !== undefined && payload.live !== null && payload.live !== (payload.state === "running"))
1133
- return false;
1134
1150
  return true;
1135
1151
  }
1136
1152
 
@@ -533,40 +533,41 @@ async function writeGuardedResolvedJsonAtomic(
533
533
  value: unknown,
534
534
  options: GuardedStateWriterOptions,
535
535
  ): Promise<GuardedWriteResult> {
536
- return lockResolvedWorkflowTarget(
537
- filePath,
538
- async () => {
539
- const current = await readJsonIfPresentTolerant(filePath);
540
- const currentRevision = persistedStateRevision(current);
541
-
542
- if (options.policy === "source") {
543
- if (options.expectedRevision !== undefined && options.expectedRevision !== currentRevision) {
544
- throw new StateWriteConflictError(filePath, options.expectedRevision, currentRevision);
545
- }
546
- const next = stampStateRevision(withWorkflowReceipt(value, buildReceipt(options)), currentRevision + 1);
547
- await atomicWrite(filePath, jsonText(next));
548
- await maybeAudit(filePath, options);
549
- return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
550
- }
536
+ const write = async (): Promise<GuardedWriteResult> => {
537
+ const current = await readJsonIfPresentTolerant(filePath);
538
+ const currentRevision = persistedStateRevision(current);
551
539
 
552
- const valueSourceRevision = isPlainObject(value) ? persistedSourceRevision(value) : 0;
553
- const incomingSourceRevision =
554
- options.sourceRevision ??
555
- (valueSourceRevision || (options.advanceSourceRevision ? persistedSourceRevision(current) + 1 : 0));
556
- if (current !== undefined && incomingSourceRevision <= persistedSourceRevision(current)) {
557
- return { path: filePath, written: false, reason: "stale-skip", revision: currentRevision };
540
+ if (options.policy === "source") {
541
+ if (options.expectedRevision !== undefined && options.expectedRevision !== currentRevision) {
542
+ throw new StateWriteConflictError(filePath, options.expectedRevision, currentRevision);
558
543
  }
559
- const next = stampStateRevision(
560
- withWorkflowReceipt(value, buildReceipt(options)),
561
- currentRevision + 1,
562
- incomingSourceRevision,
563
- );
544
+ const next = stampStateRevision(withWorkflowReceipt(value, buildReceipt(options)), currentRevision + 1);
564
545
  await atomicWrite(filePath, jsonText(next));
565
546
  await maybeAudit(filePath, options);
566
547
  return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
567
- },
568
- options.lock,
569
- );
548
+ }
549
+
550
+ const valueSourceRevision = isPlainObject(value) ? persistedSourceRevision(value) : 0;
551
+ const incomingSourceRevision =
552
+ options.sourceRevision ??
553
+ (valueSourceRevision || (options.advanceSourceRevision ? persistedSourceRevision(current) + 1 : 0));
554
+ if (current !== undefined && incomingSourceRevision <= persistedSourceRevision(current)) {
555
+ return { path: filePath, written: false, reason: "stale-skip", revision: currentRevision };
556
+ }
557
+ const next = stampStateRevision(
558
+ withWorkflowReceipt(value, buildReceipt(options)),
559
+ currentRevision + 1,
560
+ incomingSourceRevision,
561
+ );
562
+ await atomicWrite(filePath, jsonText(next));
563
+ await maybeAudit(filePath, options);
564
+ return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
565
+ };
566
+ // `withFileLock` deliberately serializes same-process callers too, so a caller
567
+ // that already holds this target's lock (via `withWorkflowStateLock`) must say
568
+ // so rather than deadlocking against itself — the same `lockHeld` contract the
569
+ // envelope writers already use.
570
+ return options.lockHeld ? write() : lockResolvedWorkflowTarget(filePath, write, options.lock);
570
571
  }
571
572
 
572
573
  export async function writeGuardedJsonAtomic(