@gajae-code/coding-agent 0.18.6 → 0.18.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 (302) hide show
  1. package/CHANGELOG.md +318 -1
  2. package/dist/types/async/job-manager.d.ts +1 -1
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/capability/skill.d.ts +2 -0
  5. package/dist/types/capability/types.d.ts +6 -0
  6. package/dist/types/cli/public-command-errors.d.ts +3 -1
  7. package/dist/types/cli/setup-cli.d.ts +1 -1
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  10. package/dist/types/config/file-lock.d.ts +1 -0
  11. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  12. package/dist/types/config/model-profile-activation.d.ts +11 -3
  13. package/dist/types/config/model-registry.d.ts +4 -9
  14. package/dist/types/config/settings.d.ts +4 -0
  15. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  16. package/dist/types/cursor-delete-path.d.ts +1 -0
  17. package/dist/types/cursor-mcp-name.d.ts +8 -0
  18. package/dist/types/customization/inventory.d.ts +2 -0
  19. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  22. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  23. package/dist/types/discovery/helpers.d.ts +8 -3
  24. package/dist/types/export/html/css-color.d.ts +2 -0
  25. package/dist/types/export/html/template.generated.d.ts +1 -1
  26. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  27. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  28. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  29. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  30. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  31. package/dist/types/extensibility/skill-management.d.ts +3 -1
  32. package/dist/types/extensibility/skills.d.ts +2 -0
  33. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  34. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  35. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  36. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  37. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  38. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  39. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  40. package/dist/types/gjc-runtime/state-writer.d.ts +11 -1
  41. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  42. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  43. package/dist/types/lsp/workspace-path.d.ts +1 -0
  44. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  45. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  46. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  47. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  48. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  49. package/dist/types/modes/interactive-mode.d.ts +1 -0
  50. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  51. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  52. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  53. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  54. package/dist/types/progress/progress-contract.d.ts +154 -0
  55. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  56. package/dist/types/progress/project-progress.d.ts +141 -0
  57. package/dist/types/progress/render-progress.d.ts +28 -0
  58. package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
  59. package/dist/types/runtime-mcp/oauth-public-url.d.ts +1 -0
  60. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  61. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  62. package/dist/types/runtime-mcp/types.d.ts +6 -0
  63. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  64. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  65. package/dist/types/sdk/broker/broker.d.ts +18 -1
  66. package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
  67. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  68. package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
  69. package/dist/types/sdk/bus/index.d.ts +12 -1
  70. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  71. package/dist/types/sdk/bus/telegram-daemon.d.ts +3 -2
  72. package/dist/types/sdk/client/client.d.ts +2 -0
  73. package/dist/types/sdk/host/host.d.ts +6 -0
  74. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  75. package/dist/types/sdk/index.d.ts +1 -0
  76. package/dist/types/sdk/router/session-router.d.ts +9 -0
  77. package/dist/types/sdk/session-list.d.ts +1 -1
  78. package/dist/types/sdk/session.d.ts +8 -0
  79. package/dist/types/session/agent-session.d.ts +38 -9
  80. package/dist/types/session/artifacts.d.ts +2 -2
  81. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  82. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  83. package/dist/types/session/internal/managed-session-storage.d.ts +36 -1
  84. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  85. package/dist/types/session/internal/task-artifact-owner-access.d.ts +19 -0
  86. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +11 -0
  87. package/dist/types/session/session-manager.d.ts +31 -5
  88. package/dist/types/session/session-retirement.d.ts +71 -0
  89. package/dist/types/session/session-storage.d.ts +62 -48
  90. package/dist/types/session/session-transcript-header.d.ts +2 -0
  91. package/dist/types/session/task-artifact-owner-codec.d.ts +109 -0
  92. package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
  93. package/dist/types/session/task-artifact-owner.d.ts +10 -0
  94. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  95. package/dist/types/setup/credential-import.d.ts +6 -4
  96. package/dist/types/skill-state/active-state.d.ts +38 -0
  97. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  98. package/dist/types/task/executor.d.ts +3 -0
  99. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  100. package/dist/types/tools/index.d.ts +3 -0
  101. package/dist/types/tools/python.d.ts +9 -1
  102. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  103. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  104. package/dist/types/tools/todo-write.d.ts +6 -0
  105. package/dist/types/utils/tools-checksum.d.ts +7 -0
  106. package/package.json +7 -7
  107. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  108. package/scripts/resident-memory-bench.ts +174 -20
  109. package/scripts/verify-managed-empty-stop.ts +28 -0
  110. package/src/async/job-manager.ts +228 -122
  111. package/src/capability/index.ts +18 -0
  112. package/src/capability/mcp.ts +2 -0
  113. package/src/capability/skill.ts +2 -0
  114. package/src/capability/types.ts +6 -0
  115. package/src/cli/auth-broker-cli.ts +1 -1
  116. package/src/cli/config-cli.ts +8 -18
  117. package/src/cli/customize-doctor.ts +15 -2
  118. package/src/cli/public-command-errors.ts +21 -1
  119. package/src/cli/setup-cli.ts +4 -4
  120. package/src/cli/skills-cli.ts +5 -0
  121. package/src/cli-main.ts +7 -8
  122. package/src/commands/deep-interview.ts +47 -8
  123. package/src/commands/sdk.ts +140 -29
  124. package/src/config/autorouting-tier-map.ts +117 -7
  125. package/src/config/file-lock.ts +307 -68
  126. package/src/config/mcp-schema.json +4 -0
  127. package/src/config/model-bindings-applier.ts +20 -0
  128. package/src/config/model-profile-activation.ts +240 -46
  129. package/src/config/model-registry.ts +47 -153
  130. package/src/config/settings.ts +44 -4
  131. package/src/config/skill-settings-defaults.ts +7 -0
  132. package/src/coordinator-mcp/policy.ts +3 -2
  133. package/src/cursor-delete-path.ts +10 -0
  134. package/src/cursor-mcp-name.ts +15 -0
  135. package/src/cursor.ts +13 -2
  136. package/src/customization/inventory.ts +3 -0
  137. package/src/debug/report-bundle-parent.ts +22 -0
  138. package/src/debug/report-bundle.ts +3 -0
  139. package/src/debug/system-info.ts +13 -1
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  141. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  142. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  143. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  144. package/src/defaults/gjc-defaults.test.ts +18 -1
  145. package/src/defaults/gjc-defaults.ts +3 -0
  146. package/src/defaults/gjc-skills.generated.ts +58 -36
  147. package/src/discovery/agents.ts +6 -1
  148. package/src/discovery/builtin.ts +10 -0
  149. package/src/discovery/helpers.ts +168 -23
  150. package/src/discovery/mcp-json.ts +6 -0
  151. package/src/discovery/ssh.ts +9 -4
  152. package/src/discovery/substitute-plugin-root.ts +3 -1
  153. package/src/edit/file-read-cache.ts +1 -1
  154. package/src/eval/py/executor.ts +380 -120
  155. package/src/export/html/css-color.ts +9 -0
  156. package/src/export/html/index.ts +5 -4
  157. package/src/export/html/template.generated.ts +1 -1
  158. package/src/extensibility/extensions/runner.ts +4 -0
  159. package/src/extensibility/extensions/types.ts +7 -0
  160. package/src/extensibility/gjc-plugins/runtime-adapters.ts +73 -8
  161. package/src/extensibility/plugins/manager.ts +3 -2
  162. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  163. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  164. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  165. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  166. package/src/extensibility/skill-management.ts +6 -2
  167. package/src/extensibility/skills.ts +6 -1
  168. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  169. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  170. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  171. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  172. package/src/gjc-runtime/gc-runtime.ts +206 -8
  173. package/src/gjc-runtime/launch-worktree.ts +14 -4
  174. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  175. package/src/gjc-runtime/state-argv.ts +2 -0
  176. package/src/gjc-runtime/state-migrations.ts +91 -29
  177. package/src/gjc-runtime/state-runtime.ts +3716 -135
  178. package/src/gjc-runtime/state-writer.ts +83 -8
  179. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  180. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  181. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  182. package/src/harness-control-plane/operate.ts +12 -4
  183. package/src/harness-control-plane/owner.ts +5 -1
  184. package/src/hashline/parser.ts +48 -9
  185. package/src/hashline/recovery.ts +13 -4
  186. package/src/hindsight/config.ts +9 -3
  187. package/src/internal-urls/docs-index.generated.ts +19 -18
  188. package/src/internal-urls/skill-path.ts +19 -0
  189. package/src/internal-urls/skill-protocol.ts +2 -0
  190. package/src/lsp/edits.ts +6 -0
  191. package/src/lsp/workspace-path.ts +51 -0
  192. package/src/main.ts +195 -31
  193. package/src/modes/acp/acp-agent.ts +163 -7
  194. package/src/modes/components/custom-editor.ts +14 -0
  195. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  196. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  197. package/src/modes/components/extensions/state-manager.ts +15 -5
  198. package/src/modes/components/model-selector.ts +1 -4
  199. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  200. package/src/modes/components/status-line/segments.ts +4 -0
  201. package/src/modes/controllers/event-controller.ts +11 -4
  202. package/src/modes/controllers/input-controller.ts +74 -29
  203. package/src/modes/controllers/runtime-mcp-command-controller.ts +8 -3
  204. package/src/modes/controllers/selector-controller.ts +28 -15
  205. package/src/modes/interactive-mode.ts +4 -0
  206. package/src/modes/runtime-init.ts +4 -0
  207. package/src/modes/utils/abort-message.ts +3 -1
  208. package/src/modes/utils/interrupt-ownership.ts +12 -0
  209. package/src/modes/utils/keybinding-matchers.ts +4 -0
  210. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  211. package/src/progress/collect-project-progress.ts +153 -0
  212. package/src/progress/progress-contract.ts +172 -0
  213. package/src/progress/progress-snapshot.ts +140 -0
  214. package/src/progress/project-progress.ts +446 -0
  215. package/src/progress/render-progress.ts +209 -0
  216. package/src/prompts/system/master-mode.md +1 -0
  217. package/src/prompts/tools/skill-discovery.md +1 -1
  218. package/src/runtime-mcp/client.ts +13 -4
  219. package/src/runtime-mcp/config-writer.ts +10 -6
  220. package/src/runtime-mcp/config.ts +1 -0
  221. package/src/runtime-mcp/elicitation.ts +142 -0
  222. package/src/runtime-mcp/oauth-flow.ts +5 -0
  223. package/src/runtime-mcp/oauth-public-url.ts +8 -0
  224. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  225. package/src/runtime-mcp/smithery-registry.ts +22 -3
  226. package/src/runtime-mcp/transports/http.ts +2 -2
  227. package/src/runtime-mcp/types.ts +6 -0
  228. package/src/sdk/acp/adapter.ts +93 -8
  229. package/src/sdk/broker/broker-exit.ts +52 -23
  230. package/src/sdk/broker/broker.ts +83 -7
  231. package/src/sdk/broker/ensure.ts +2 -0
  232. package/src/sdk/broker/lifecycle.ts +820 -73
  233. package/src/sdk/broker/session-index.ts +191 -81
  234. package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
  235. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  236. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  237. package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
  238. package/src/sdk/bus/index.ts +241 -46
  239. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  240. package/src/sdk/bus/telegram-daemon.ts +4 -2
  241. package/src/sdk/cli/session-cli.ts +17 -3
  242. package/src/sdk/client/client.ts +28 -8
  243. package/src/sdk/host/host.ts +6 -0
  244. package/src/sdk/host/query/handlers.ts +9 -1
  245. package/src/sdk/host/session-runtime.test.ts +2805 -411
  246. package/src/sdk/host/session-runtime.ts +428 -111
  247. package/src/sdk/host/surface-policy.ts +1 -0
  248. package/src/sdk/index.ts +8 -0
  249. package/src/sdk/lifecycle-session.ts +3 -1
  250. package/src/sdk/mcp/server.ts +15 -3
  251. package/src/sdk/prompt-deadline-manager.ts +15 -0
  252. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  253. package/src/sdk/protocol/operation-registry.ts +9 -2
  254. package/src/sdk/router/session-router.ts +94 -14
  255. package/src/sdk/session-list.ts +1 -0
  256. package/src/sdk/session.ts +81 -38
  257. package/src/session/agent-session.ts +674 -224
  258. package/src/session/artifacts.ts +174 -70
  259. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  260. package/src/session/internal/managed-session-scope.ts +3457 -462
  261. package/src/session/internal/managed-session-storage.ts +482 -61
  262. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  263. package/src/session/internal/task-artifact-owner-access.ts +245 -0
  264. package/src/session/internal/task-artifact-owner-transcript.ts +614 -0
  265. package/src/session/session-manager.ts +939 -133
  266. package/src/session/session-retirement.ts +714 -0
  267. package/src/session/session-storage.ts +418 -174
  268. package/src/session/session-transcript-header.ts +17 -0
  269. package/src/session/task-artifact-owner-codec.ts +641 -0
  270. package/src/session/task-artifact-owner-retirement.ts +477 -0
  271. package/src/session/task-artifact-owner.ts +289 -0
  272. package/src/setup/credential-auto-import.ts +12 -3
  273. package/src/setup/credential-import.ts +176 -5
  274. package/src/setup/paseo/announce.ts +6 -1
  275. package/src/skill-state/active-state.ts +132 -2
  276. package/src/slash-commands/builtin-registry.ts +39 -9
  277. package/src/slash-commands/helpers/progress-report.ts +23 -0
  278. package/src/stt/setup.ts +3 -5
  279. package/src/task/commands.ts +3 -1
  280. package/src/task/executor.ts +4 -0
  281. package/src/task/index.ts +6 -0
  282. package/src/tools/ask.ts +205 -6
  283. package/src/tools/ast-edit.ts +4 -0
  284. package/src/tools/bash-allowed-prefixes.ts +15 -0
  285. package/src/tools/bash-skill-urls.ts +56 -3
  286. package/src/tools/bash.ts +48 -79
  287. package/src/tools/descriptors.ts +5 -1
  288. package/src/tools/eval.ts +56 -36
  289. package/src/tools/image-gen.ts +1 -1
  290. package/src/tools/index.ts +3 -0
  291. package/src/tools/python.ts +187 -66
  292. package/src/tools/recipe/index.ts +9 -0
  293. package/src/tools/recipe/recipe-guard.ts +19 -0
  294. package/src/tools/restricted-cwd.ts +10 -0
  295. package/src/tools/skill-discovery.ts +5 -0
  296. package/src/tools/skill.ts +28 -1
  297. package/src/tools/todo-write.ts +10 -1
  298. package/src/tools/tool-catalog.generated.ts +1 -1
  299. package/src/tools/write.ts +1 -0
  300. package/src/utils/tools-checksum.ts +34 -0
  301. package/src/utils/tools-manager.ts +24 -6
  302. package/src/web/search/providers/codex.ts +3 -2
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { logger, resolveEquivalentPath } from "@gajae-code/utils";
5
5
  import { nativeProcessBindings } from "@gajae-code/utils/native-process";
6
- import { withFileLock } from "../../config/file-lock";
6
+ import { type FileLockOptions, isFileLockAcquireTimeout, withFileLock } from "../../config/file-lock";
7
7
  import { repo } from "../../utils/git";
8
8
  import { endpointIncarnation } from "./endpoint-authority";
9
9
  import { processIncarnation } from "./process-incarnation";
@@ -653,6 +653,7 @@ function reduceEvents(
653
653
  now: number,
654
654
  agentDir: string,
655
655
  probedIncarnations?: ReadonlyMap<string, string | undefined>,
656
+ sessionIds?: ReadonlySet<string>,
656
657
  ): SessionIndexProjection {
657
658
  // Resolved once: the fence-row check runs per root, and only this side of the
658
659
  // comparison is ours to normalize.
@@ -669,7 +670,12 @@ function reduceEvents(
669
670
  }
670
671
  return identity;
671
672
  };
672
- const { admitted } = admitEvents(events);
673
+ // Admission and projection are keyed by sessionId (tuple, tombstone, identity),
674
+ // so a scoped read reduces only the requested sessions' events and skips the
675
+ // other sessions' pid/incarnation probes without changing their rows.
676
+ const { admitted } = admitEvents(
677
+ sessionIds === undefined ? events : events.filter(event => sessionIds.has(event.sessionId)),
678
+ );
673
679
  const latestByIdentity = new Map<string, SessionIndexEvent>();
674
680
  const latestHeartbeatByIdentity = new Map<string, SessionIndexEvent>();
675
681
  for (const event of admitted) {
@@ -797,6 +803,16 @@ const SESSION_INDEX_PROBE_FRESHNESS_MS = 50;
797
803
  * and the next pass re-probes from scratch.
798
804
  */
799
805
  const SESSION_INDEX_REPLAY_FRESHNESS_MS = 2_000;
806
+ /**
807
+ * Fresh re-probe attempts one heartbeat checkpoint pass makes when its
808
+ * observation batch goes stale under lock contention. Each attempt is fully
809
+ * fail-closed; the bound only prevents a contended machine from writing no
810
+ * heartbeat for long enough that live hosts project as dead.
811
+ */
812
+ const SESSION_HEARTBEAT_CHECKPOINT_ATTEMPTS = 4;
813
+ /** One elapsed budget for queueing, probing and every reacquisition in a pass. */
814
+ const SESSION_HEARTBEAT_CHECKPOINT_BUDGET_MS = 15_000;
815
+ const STALE_OBSERVATION: unique symbol = Symbol("stale-heartbeat-observation");
800
816
  /** Maximum distinct live identity probes admitted by one public status query. */
801
817
  const SESSION_GENERATION_PROBE_LIMIT = 32;
802
818
 
@@ -807,10 +823,16 @@ const SESSION_GENERATION_PROBE_LIMIT = 32;
807
823
  * observation window and (b) the lock budget cannot silently drift per call
808
824
  * site.
809
825
  */
810
- function withSessionIndexLock<T>(operation: string, agentDir: string, callback: () => Promise<T>): Promise<T> {
826
+ function withSessionIndexLock<T>(
827
+ operation: string,
828
+ agentDir: string,
829
+ callback: () => Promise<T>,
830
+ options: FileLockOptions = SESSION_INDEX_LOCK_OPTIONS,
831
+ ): Promise<T> {
811
832
  return withFileLock(
812
833
  logFor(agentDir),
813
834
  async () => {
835
+ options.signal?.throwIfAborted();
814
836
  // Armed only once the lock is actually held: queueing time behind another
815
837
  // legitimate holder must not be attributed to this operation.
816
838
  const note = setTimeout(
@@ -826,7 +848,7 @@ function withSessionIndexLock<T>(operation: string, agentDir: string, callback:
826
848
  clearTimeout(note);
827
849
  }
828
850
  },
829
- SESSION_INDEX_LOCK_OPTIONS,
851
+ options,
830
852
  );
831
853
  }
832
854
  function isValidSnapshot(snapshot: unknown): snapshot is { indexSeq: number; events: SessionIndexEvent[] } {
@@ -1195,32 +1217,39 @@ export class SessionIndex {
1195
1217
  * stamp of the last completed locked pass, the in-memory projection is
1196
1218
  * already current and the locked rescan is skipped entirely — this is what
1197
1219
  * keeps an idle SessionRouter reconcile (2s cadence) from re-parsing and
1198
- * re-checksumming the whole index forever. An append committed before the
1199
- * stat always changes the stamp, so a miss is impossible; a change landing
1200
- * after the stat is seen on the next poll, the same TOCTOU envelope a
1201
- * locked read has. A corrupt suffix never takes the fast path: re-scanning
1220
+ * re-checksumming the whole index forever. The unchanged check runs before
1221
+ * the local queue so unrelated heartbeat work cannot delay ordinary creates.
1222
+ * Admission that requires serialized authority must use refresh() instead.
1223
+ * An append committed before the stat always changes
1224
+ * the stamp; a change landing after the stat is seen on the next poll, the
1225
+ * same TOCTOU envelope a locked read has. A corrupt suffix never takes the fast path: re-scanning
1202
1226
  * preserves the existing re-diagnosis behavior. Returns true when state was
1203
1227
  * reloaded. Authority revalidation that needs the strongest available
1204
1228
  * snapshot inside an already-locked write (append, unregister) keeps using
1205
1229
  * the exact locked paths.
1206
1230
  */
1207
1231
  async refreshIfChanged(): Promise<boolean> {
1208
- if (this.#changeStamp !== undefined && !this.#corruptSuffix) {
1232
+ if (this.#changeStamp === undefined || this.#corruptSuffix) {
1233
+ await this.open();
1234
+ await this.refresh();
1235
+ return true;
1236
+ }
1237
+ const stamp = await readIndexChangeStamp(this.#agentDir);
1238
+ if (sameIndexChangeStamp(this.#changeStamp, stamp)) return false;
1239
+ const indexPath = path.resolve(logFor(this.#agentDir));
1240
+ return await SessionIndex.#enqueue(indexPath, async () => {
1241
+ if (this.#changeStamp === undefined || this.#corruptSuffix) {
1242
+ await withSessionIndexLock("poll-refresh", this.#agentDir, () => this.#refreshUnderLock());
1243
+ return true;
1244
+ }
1209
1245
  const stamp = await readIndexChangeStamp(this.#agentDir);
1210
1246
  if (sameIndexChangeStamp(this.#changeStamp, stamp)) return false;
1211
1247
  // A possible change is re-classified UNDER the lock (#4689 review):
1212
1248
  // writers mutate these files only while holding it, so the locked
1213
- // observation is atomic with the tail/replay it selects. The
1214
- // unlocked stamp above is only the cheap "definitely unchanged" cut.
1215
- const indexPath = path.resolve(logFor(this.#agentDir));
1216
- await SessionIndex.#enqueue(indexPath, () =>
1217
- withSessionIndexLock("poll-refresh", this.#agentDir, () => this.#refreshOrReplayUnderLock()),
1218
- );
1249
+ // observation is atomic with the tail/replay it selects.
1250
+ await withSessionIndexLock("poll-refresh", this.#agentDir, () => this.#refreshOrReplayUnderLock());
1219
1251
  return true;
1220
- }
1221
- await this.open();
1222
- await this.refresh();
1223
- return true;
1252
+ });
1224
1253
  }
1225
1254
  /**
1226
1255
  * Locked change classification (#4689). Append-only log growth with an
@@ -1327,6 +1356,10 @@ export class SessionIndex {
1327
1356
  }
1328
1357
  /** Seed the audit dedupe set once, then append records for newly-rejected events. */
1329
1358
  async #writeAuditUnderLock(): Promise<void> {
1359
+ const candidates = auditRecords(this.#events, this.#policy.clock());
1360
+ // The audit log only dedupes rejections, so a reader with none never pays
1361
+ // for loading it.
1362
+ if (candidates.length === 0) return;
1330
1363
  if (this.#auditedSeq === null) {
1331
1364
  this.#auditedSeq = new Set();
1332
1365
  try {
@@ -1344,9 +1377,7 @@ export class SessionIndex {
1344
1377
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1345
1378
  }
1346
1379
  }
1347
- const pending = auditRecords(this.#events, this.#policy.clock()).filter(
1348
- record => !this.#auditedSeq!.has(record.indexSeq),
1349
- );
1380
+ const pending = candidates.filter(record => !this.#auditedSeq!.has(record.indexSeq));
1350
1381
  if (pending.length === 0) return;
1351
1382
  await appendSync(auditFor(this.#agentDir), pending.map(record => JSON.stringify(record)).join("\n"));
1352
1383
  for (const record of pending) this.#auditedSeq.add(record.indexSeq);
@@ -1956,10 +1987,18 @@ export class SessionIndex {
1956
1987
  this.#changeStamp = await readIndexChangeStamp(this.#agentDir);
1957
1988
  }
1958
1989
 
1959
- listSessions(probedIncarnations?: ReadonlyMap<string, string | undefined>): SessionList {
1990
+ /**
1991
+ * Public session rows. `sessionIds` scopes the projection to those sessions:
1992
+ * rows for them are identical to the unscoped read, other sessions are absent.
1993
+ */
1994
+ listSessions(
1995
+ probedIncarnations?: ReadonlyMap<string, string | undefined>,
1996
+ sessionIds?: ReadonlySet<string>,
1997
+ ): SessionList {
1960
1998
  return {
1961
1999
  indexSeq: this.indexSeq,
1962
- sessions: reduceEvents(this.#events, this.#policy.clock(), this.#agentDir, probedIncarnations).sessions,
2000
+ sessions: reduceEvents(this.#events, this.#policy.clock(), this.#agentDir, probedIncarnations, sessionIds)
2001
+ .sessions,
1963
2002
  warnings: this.#warnings,
1964
2003
  };
1965
2004
  }
@@ -2334,9 +2373,64 @@ export class SessionIndex {
2334
2373
  * while the broker was down keep their stale or missing heartbeat and read as
2335
2374
  * unknown/not-live (never fresh forever). Returns the number of checkpoints written.
2336
2375
  */
2337
- async checkpointLiveHeartbeats(now = Date.now()): Promise<number> {
2376
+ async checkpointLiveHeartbeats(
2377
+ now = Date.now(),
2378
+ abortSignal?: AbortSignal,
2379
+ deadlineAt = performance.now() + SESSION_HEARTBEAT_CHECKPOINT_BUDGET_MS,
2380
+ ): Promise<number> {
2381
+ // A stale observation batch fails closed (no heartbeat), but on a busy
2382
+ // machine a single contended lock acquisition is enough to discard the
2383
+ // whole cycle. With the broker's 5s cadence repeatedly losing that race,
2384
+ // every live host's heartbeat aged past the 2-interval freshness window
2385
+ // and the router retired attached sessions as "removed" mid-prompt.
2386
+ // Re-probe from scratch a bounded number of times inside this pass: each
2387
+ // attempt keeps the exact fail-closed contract (fresh probes, freshness
2388
+ // bounds re-checked under the lock); only the starvation is removed.
2389
+ const deadline = Math.min(deadlineAt, performance.now() + SESSION_HEARTBEAT_CHECKPOINT_BUDGET_MS);
2390
+ if (abortSignal?.aborted || performance.now() >= deadline) return 0;
2391
+ const budget = new AbortController();
2392
+ const signal = abortSignal ? AbortSignal.any([abortSignal, budget.signal]) : budget.signal;
2393
+ const timer = setTimeout(() => budget.abort(), deadline - performance.now());
2394
+ timer.unref?.();
2395
+ const checkpoint = async (): Promise<number> => {
2396
+ for (let attempt = 0; ; attempt++) {
2397
+ if (signal.aborted || performance.now() >= deadline) return 0;
2398
+ const result = await this.#checkpointLiveHeartbeatsOnce(now, signal, deadline);
2399
+ if (result !== STALE_OBSERVATION || attempt + 1 >= SESSION_HEARTBEAT_CHECKPOINT_ATTEMPTS)
2400
+ return result === STALE_OBSERVATION ? 0 : result;
2401
+ now = Date.now();
2402
+ }
2403
+ };
2404
+ try {
2405
+ // The deadline aborts queued work and prevents a late callback from
2406
+ // entering its write section, but it cannot cancel an append/fsync that
2407
+ // has already started. Keep the caller tied to the lock-owning operation
2408
+ // so startup readiness never overtakes lock release.
2409
+ return await checkpoint();
2410
+ } catch (error) {
2411
+ if (signal.aborted && error === signal.reason) return 0;
2412
+ if (
2413
+ performance.now() >= deadline &&
2414
+ isFileLockAcquireTimeout(error) &&
2415
+ !error.orphanPath &&
2416
+ !error.removalFailure
2417
+ )
2418
+ return 0;
2419
+ throw error;
2420
+ } finally {
2421
+ clearTimeout(timer);
2422
+ budget.abort();
2423
+ }
2424
+ }
2425
+
2426
+ async #checkpointLiveHeartbeatsOnce(
2427
+ now: number,
2428
+ signal: AbortSignal,
2429
+ deadline: number,
2430
+ ): Promise<number | typeof STALE_OBSERVATION> {
2338
2431
  const indexPath = path.resolve(logFor(this.#agentDir));
2339
2432
  return await SessionIndex.#enqueue(indexPath, async () => {
2433
+ if (signal.aborted || performance.now() >= deadline) return 0;
2340
2434
  // An absent index holds no registration to checkpoint, so this pass must read
2341
2435
  // it as "nothing to do" instead of creating one. Recreating the directory
2342
2436
  // resurrects a state root its owner already retired: the broker's 5s
@@ -2371,62 +2465,78 @@ export class SessionIndex {
2371
2465
  probed.set(`${row.sessionId}\u0000${row.endpointGeneration}\u0000${row.pid}`, processIncarnation(row.pid));
2372
2466
  }
2373
2467
  const probedAt = performance.now();
2374
- return await withSessionIndexLock("heartbeat checkpoint", this.#agentDir, async () => {
2375
- // Contended or delayed acquisition invalidates the observation set:
2376
- // never trust identity evidence gathered meaningfully before the
2377
- // lock was held. The checkpoint is skipped this cycle (fail-closed);
2378
- // the OS is never probed while the machine-global lock is held. The
2379
- // bound tolerates scheduler jitter on an uncontended acquisition —
2380
- // which is the only path where the evidence is trustworthy.
2381
- if (performance.now() - probedAt > SESSION_INDEX_PROBE_FRESHNESS_MS) return 0;
2382
- await this.#replayUnderLock();
2383
- // Recheck AFTER the awaited replay too (#4544 review round 4): the
2384
- // replay re-reads the whole log (up to the 4 MiB rotation bound) and
2385
- // fsyncs pending audit rows, so the probe→write interval can stretch
2386
- // well past the acquisition bound above while the lock is held. The
2387
- // same pid-reuse window exists across that awaited replay; consuming
2388
- // the cached observation then would checkpoint the replacement
2389
- // process as the dead row. The replay bound covers the full locked
2390
- // replay cost envelope of a healthy machine; anything slower fails
2391
- // closed (no heartbeat this cycle) and the next pass re-probes.
2392
- if (performance.now() - probedAt > SESSION_INDEX_REPLAY_FRESHNESS_MS) return 0;
2393
- if (this.#corruptSuffix) return 0;
2394
- const events: SessionIndexEvent[] = [];
2395
- const rows = reduceEvents(this.#events, now, this.#agentDir, probed).sessions;
2396
- for (const row of rows) {
2397
- if (!isSessionAuthorityEligible(row) || row.terminal || row.terminalUncertain) continue;
2398
- if (row.lastHeartbeatAt !== undefined && now - row.lastHeartbeatAt < SESSION_HEARTBEAT_INTERVAL_MS)
2399
- continue;
2400
- if (!alive(row.pid)) continue;
2401
- const recordedIncarnation = row.hostIncarnation ?? row.processIncarnation;
2402
- if (recordedIncarnation === undefined) continue;
2403
- const current = probed.get(`${row.sessionId}\u0000${row.endpointGeneration}\u0000${row.pid}`);
2404
- if (current === undefined || current !== recordedIncarnation) continue;
2405
- const unsigned: Omit<SessionIndexEvent, "checksum"> = {
2406
- version: SESSION_INDEX_EVENT_VERSION,
2407
- indexSeq: this.indexSeq + events.length + 1,
2408
- type: "host_heartbeat",
2409
- sessionId: row.sessionId,
2410
- locator: row.locator,
2411
- endpointGeneration: row.endpointGeneration,
2412
- pid: row.pid,
2413
- ...(row.processIncarnation === undefined ? {} : { processIncarnation: row.processIncarnation }),
2414
- ...(row.hostIncarnation === undefined ? {} : { hostIncarnation: row.hostIncarnation }),
2415
- ...(row.masterRole === undefined ? {} : { masterRole: row.masterRole }),
2416
- // Preserve the host's last observed activity state while this
2417
- // broker-owned heartbeat only renews liveness. A live idle host
2418
- // must not be rewritten as active merely because its pid is alive.
2419
- activity: row.activity ?? { state: "active", at: now },
2420
- ts: now,
2421
- };
2422
- events.push({ ...unsigned, checksum: sessionIndexChecksum(unsigned) });
2423
- }
2424
- if (events.length === 0) return 0;
2425
- for (const event of events) await appendSync(logFor(this.#agentDir), JSON.stringify(event));
2426
- await this.#refreshUnderLock();
2427
- if ((await fs.stat(logFor(this.#agentDir))).size >= ROTATE_BYTES) await this.#rotate();
2428
- return events.length;
2429
- });
2468
+ if (signal.aborted || probedAt >= deadline) return 0;
2469
+ return await withSessionIndexLock(
2470
+ "heartbeat checkpoint",
2471
+ this.#agentDir,
2472
+ async () => {
2473
+ if (signal.aborted || performance.now() >= deadline) return 0;
2474
+ // Contended or delayed acquisition invalidates the observation set:
2475
+ // never trust identity evidence gathered meaningfully before the
2476
+ // lock was held. The checkpoint is skipped this cycle (fail-closed);
2477
+ // the OS is never probed while the machine-global lock is held. The
2478
+ // bound tolerates scheduler jitter on an uncontended acquisition —
2479
+ // which is the only path where the evidence is trustworthy.
2480
+ if (performance.now() - probedAt > SESSION_INDEX_PROBE_FRESHNESS_MS) return STALE_OBSERVATION;
2481
+ await this.#replayUnderLock();
2482
+ if (signal.aborted || performance.now() >= deadline) return 0;
2483
+ // Recheck AFTER the awaited replay too (#4544 review round 4): the
2484
+ // replay re-reads the whole log (up to the 4 MiB rotation bound) and
2485
+ // fsyncs pending audit rows, so the probe→write interval can stretch
2486
+ // well past the acquisition bound above while the lock is held. The
2487
+ // same pid-reuse window exists across that awaited replay; consuming
2488
+ // the cached observation then would checkpoint the replacement
2489
+ // process as the dead row. The replay bound covers the full locked
2490
+ // replay cost envelope of a healthy machine; anything slower fails
2491
+ // closed (no heartbeat this cycle) and the next pass re-probes.
2492
+ if (performance.now() - probedAt > SESSION_INDEX_REPLAY_FRESHNESS_MS) return STALE_OBSERVATION;
2493
+ if (this.#corruptSuffix) return 0;
2494
+ const events: SessionIndexEvent[] = [];
2495
+ const rows = reduceEvents(this.#events, now, this.#agentDir, probed).sessions;
2496
+ for (const row of rows) {
2497
+ if (!isSessionAuthorityEligible(row) || row.terminal || row.terminalUncertain) continue;
2498
+ if (row.lastHeartbeatAt !== undefined && now - row.lastHeartbeatAt < SESSION_HEARTBEAT_INTERVAL_MS)
2499
+ continue;
2500
+ if (!alive(row.pid)) continue;
2501
+ const recordedIncarnation = row.hostIncarnation ?? row.processIncarnation;
2502
+ if (recordedIncarnation === undefined) continue;
2503
+ const current = probed.get(`${row.sessionId}\u0000${row.endpointGeneration}\u0000${row.pid}`);
2504
+ if (current === undefined || current !== recordedIncarnation) continue;
2505
+ const unsigned: Omit<SessionIndexEvent, "checksum"> = {
2506
+ version: SESSION_INDEX_EVENT_VERSION,
2507
+ indexSeq: this.indexSeq + events.length + 1,
2508
+ type: "host_heartbeat",
2509
+ sessionId: row.sessionId,
2510
+ locator: row.locator,
2511
+ endpointGeneration: row.endpointGeneration,
2512
+ pid: row.pid,
2513
+ ...(row.processIncarnation === undefined ? {} : { processIncarnation: row.processIncarnation }),
2514
+ ...(row.hostIncarnation === undefined ? {} : { hostIncarnation: row.hostIncarnation }),
2515
+ ...(row.masterRole === undefined ? {} : { masterRole: row.masterRole }),
2516
+ // Preserve the host's last observed activity state while this
2517
+ // broker-owned heartbeat only renews liveness. A live idle host
2518
+ // must not be rewritten as active merely because its pid is alive.
2519
+ activity: row.activity ?? { state: "active", at: now },
2520
+ ts: now,
2521
+ };
2522
+ events.push({ ...unsigned, checksum: sessionIndexChecksum(unsigned) });
2523
+ }
2524
+ if (events.length === 0) return 0;
2525
+ if (signal.aborted || performance.now() >= deadline) return 0;
2526
+ for (const event of events) await appendSync(logFor(this.#agentDir), JSON.stringify(event));
2527
+ await this.#refreshUnderLock();
2528
+ if ((await fs.stat(logFor(this.#agentDir))).size >= ROTATE_BYTES) await this.#rotate();
2529
+ return events.length;
2530
+ },
2531
+ {
2532
+ ...SESSION_INDEX_LOCK_OPTIONS,
2533
+ retries: Math.min(
2534
+ SESSION_INDEX_LOCK_OPTIONS.retries,
2535
+ Math.max(1, Math.ceil((deadline - performance.now()) / SESSION_INDEX_LOCK_OPTIONS.retryDelayMs)),
2536
+ ),
2537
+ signal,
2538
+ },
2539
+ );
2430
2540
  });
2431
2541
  }
2432
2542
 
@@ -0,0 +1,259 @@
1
+ import type { NativeExactUnlinkResult } from "@gajae-code/natives";
2
+ import {
3
+ assertSessionRoot,
4
+ parseTaskArtifactOwnerDeletionEvidence,
5
+ parseTaskArtifactOwnerRetirementContinuation,
6
+ parseTaskArtifactOwnerRetirementOutcome,
7
+ type TaskArtifactOwnerDeletionEvidence,
8
+ type TaskArtifactOwnerRetirementContinuation,
9
+ type TaskArtifactOwnerRetirementOutcome,
10
+ type TaskArtifactOwnerStorageContext,
11
+ } from "../../session/task-artifact-owner-codec";
12
+
13
+ export type BrokerCleanupPhase = "artifacts" | "transcript" | "metadata" | "lifecycle";
14
+
15
+ /** Exact owner-disposition wire shape. Evidence and continuation are supplied separately by cleanup replay. */
16
+ export type BrokerTaskArtifactOwnerRetirementDisposition =
17
+ | { readonly kind: "completed"; readonly nativeOutcome?: NativeExactUnlinkResult }
18
+ | {
19
+ readonly kind: "payload_retired";
20
+ readonly namespace: "retained";
21
+ readonly nativeOutcome: NativeExactUnlinkResult;
22
+ }
23
+ | { readonly kind: "cleanup_pending"; readonly nativeOutcome?: NativeExactUnlinkResult }
24
+ | { readonly kind: "uncertain"; readonly reason: string; readonly nativeOutcome?: NativeExactUnlinkResult };
25
+
26
+ /** Owner-only projection of durable cleanup state accepted by the pure validator. */
27
+ export interface BrokerTaskArtifactOwnerCleanupFields {
28
+ readonly taskArtifactOwnerDeletionEvidence?: unknown;
29
+ readonly taskArtifactOwnerRetirementContinuation?: unknown;
30
+ readonly taskArtifactOwnerRetirementOutcome?: unknown;
31
+ readonly taskArtifactOwnerPayloadRetired?: unknown;
32
+ readonly taskArtifactOwnerNamespaceRetained?: unknown;
33
+ readonly taskArtifactOwnerRetired?: unknown;
34
+ readonly taskArtifactOwnerTranscriptDeleted?: unknown;
35
+ readonly taskArtifactOwnerCleanupError?: unknown;
36
+ }
37
+
38
+ /** Fully decoded owner-only cleanup state for an authorized session and phase. */
39
+ export interface BrokerTaskArtifactOwnerCleanupValidation {
40
+ readonly deletionEvidence?: TaskArtifactOwnerDeletionEvidence;
41
+ readonly retirementContinuation?: TaskArtifactOwnerRetirementContinuation;
42
+ readonly retirementOutcome?: TaskArtifactOwnerRetirementOutcome;
43
+ readonly payloadRetired?: true;
44
+ readonly namespaceRetained?: true;
45
+ readonly retired?: true;
46
+ readonly transcriptDeleted?: true;
47
+ readonly cleanupDiagnostic?: string;
48
+ }
49
+
50
+ function isRecord(value: unknown): value is Record<string, unknown> {
51
+ return typeof value === "object" && value !== null && !Array.isArray(value);
52
+ }
53
+
54
+ function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
55
+ return Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
56
+ }
57
+
58
+ function invalidDisposition(): never {
59
+ throw new Error("task_artifact_owner_retirement_disposition_invalid");
60
+ }
61
+
62
+ /**
63
+ * Decode the owner-only wire disposition using its exact trusted evidence and continuation.
64
+ * A decoded `completed` value is historical DTO data, not proof of physical retirement or writer closure.
65
+ */
66
+ export function parseBrokerTaskArtifactOwnerRetirementDisposition(
67
+ value: unknown,
68
+ context: TaskArtifactOwnerStorageContext,
69
+ evidence: TaskArtifactOwnerDeletionEvidence,
70
+ continuation: TaskArtifactOwnerRetirementContinuation | undefined,
71
+ ): TaskArtifactOwnerRetirementOutcome {
72
+ assertSessionRoot(context);
73
+ if (!isRecord(value)) return invalidDisposition();
74
+ const kind = value.kind;
75
+ let expectedKeys: readonly string[];
76
+ switch (kind) {
77
+ case "completed":
78
+ case "cleanup_pending":
79
+ expectedKeys = Object.hasOwn(value, "nativeOutcome") ? ["kind", "nativeOutcome"] : ["kind"];
80
+ break;
81
+ case "payload_retired":
82
+ expectedKeys = ["kind", "namespace", "nativeOutcome"];
83
+ break;
84
+ case "uncertain":
85
+ expectedKeys = Object.hasOwn(value, "nativeOutcome")
86
+ ? ["kind", "reason", "nativeOutcome"]
87
+ : ["kind", "reason"];
88
+ break;
89
+ default:
90
+ return invalidDisposition();
91
+ }
92
+ if (!hasExactKeys(value, expectedKeys)) return invalidDisposition();
93
+ const reconstructed = {
94
+ ...value,
95
+ evidence,
96
+ ...(continuation !== undefined ? { continuation } : {}),
97
+ };
98
+ return parseTaskArtifactOwnerRetirementOutcome(context, evidence, reconstructed);
99
+ }
100
+
101
+ function serializeNativeOutcome(outcome: NativeExactUnlinkResult): NativeExactUnlinkResult {
102
+ return Object.freeze({
103
+ ok: outcome.ok,
104
+ ...(outcome.code !== undefined ? { code: outcome.code } : {}),
105
+ ...(outcome.detachedPath !== undefined ? { detachedPath: outcome.detachedPath } : {}),
106
+ ...(outcome.retainedSuccessorPath !== undefined ? { retainedSuccessorPath: outcome.retainedSuccessorPath } : {}),
107
+ ...(outcome.retainedPlaceholderPath !== undefined
108
+ ? { retainedPlaceholderPath: outcome.retainedPlaceholderPath }
109
+ : {}),
110
+ ...(outcome.retainedUnknownPath !== undefined ? { retainedUnknownPath: outcome.retainedUnknownPath } : {}),
111
+ ...(outcome.payloadDurable !== undefined ? { payloadDurable: outcome.payloadDurable } : {}),
112
+ ...(outcome.windowsErrorCode !== undefined ? { windowsErrorCode: outcome.windowsErrorCode } : {}),
113
+ }) as NativeExactUnlinkResult;
114
+ }
115
+
116
+ /** Project an already validated internal outcome to the exact broker wire disposition. */
117
+ export function serializeBrokerTaskArtifactOwnerRetirementDisposition(
118
+ outcome: TaskArtifactOwnerRetirementOutcome,
119
+ ): BrokerTaskArtifactOwnerRetirementDisposition {
120
+ switch (outcome.kind) {
121
+ case "completed":
122
+ return Object.freeze({
123
+ kind: outcome.kind,
124
+ ...(outcome.nativeOutcome ? { nativeOutcome: serializeNativeOutcome(outcome.nativeOutcome) } : {}),
125
+ });
126
+ case "payload_retired":
127
+ return Object.freeze({
128
+ kind: outcome.kind,
129
+ namespace: outcome.namespace,
130
+ nativeOutcome: serializeNativeOutcome(outcome.nativeOutcome),
131
+ });
132
+ case "cleanup_pending":
133
+ return Object.freeze({
134
+ kind: outcome.kind,
135
+ ...(outcome.nativeOutcome ? { nativeOutcome: serializeNativeOutcome(outcome.nativeOutcome) } : {}),
136
+ });
137
+ case "uncertain":
138
+ return Object.freeze({
139
+ kind: outcome.kind,
140
+ reason: outcome.reason,
141
+ ...(outcome.nativeOutcome ? { nativeOutcome: serializeNativeOutcome(outcome.nativeOutcome) } : {}),
142
+ });
143
+ default: {
144
+ const exhaustive: never = outcome;
145
+ throw new Error(`task_artifact_owner_retirement_outcome_unknown:${String(exhaustive)}`);
146
+ }
147
+ }
148
+ }
149
+
150
+ const OWNER_CLEANUP_KEYS = new Set([
151
+ "taskArtifactOwnerDeletionEvidence",
152
+ "taskArtifactOwnerRetirementContinuation",
153
+ "taskArtifactOwnerRetirementOutcome",
154
+ "taskArtifactOwnerPayloadRetired",
155
+ "taskArtifactOwnerNamespaceRetained",
156
+ "taskArtifactOwnerRetired",
157
+ "taskArtifactOwnerTranscriptDeleted",
158
+ "taskArtifactOwnerCleanupError",
159
+ ]);
160
+
161
+ /**
162
+ * Strictly decode the owner-field projection of cleanup state against trusted session, scope context, phase,
163
+ * and artifact-removal state. Empty/legacy owner fields decode to an empty result. A decoded completion
164
+ * remains historical data: any later effect must separately verify current physical absence, fresh scrub
165
+ * proof, native success and current owner-parent/side-path authority before it mutates storage or transcripts.
166
+ */
167
+ export function decodeBrokerTaskArtifactOwnerCleanupFields(
168
+ value: unknown,
169
+ context: TaskArtifactOwnerStorageContext,
170
+ expectedSessionId: string,
171
+ phase: BrokerCleanupPhase,
172
+ artifactsRemoved: boolean | undefined,
173
+ ): BrokerTaskArtifactOwnerCleanupValidation {
174
+ assertSessionRoot(context);
175
+ if (!isRecord(value) || Object.keys(value).some(key => !OWNER_CLEANUP_KEYS.has(key)))
176
+ throw new Error("task_artifact_owner_cleanup_fields_invalid");
177
+ if (expectedSessionId.length === 0 || (artifactsRemoved !== undefined && typeof artifactsRemoved !== "boolean"))
178
+ throw new Error("task_artifact_owner_cleanup_fields_invalid");
179
+
180
+ const has = (key: string) => Object.hasOwn(value, key);
181
+ const trueFlag = (key: string): true | undefined => {
182
+ if (!has(key)) return undefined;
183
+ if (value[key] !== true) throw new Error("task_artifact_owner_cleanup_flags_invalid");
184
+ return true;
185
+ };
186
+ let deletionEvidence: TaskArtifactOwnerDeletionEvidence | undefined;
187
+ if (has("taskArtifactOwnerDeletionEvidence")) {
188
+ deletionEvidence = parseTaskArtifactOwnerDeletionEvidence(value.taskArtifactOwnerDeletionEvidence);
189
+ if (deletionEvidence.sessionId !== expectedSessionId)
190
+ throw new Error("task_artifact_owner_cleanup_session_mismatch");
191
+ }
192
+ let retirementContinuation: TaskArtifactOwnerRetirementContinuation | undefined;
193
+ if (has("taskArtifactOwnerRetirementContinuation")) {
194
+ if (!deletionEvidence) throw new Error("task_artifact_owner_cleanup_evidence_missing");
195
+ retirementContinuation = parseTaskArtifactOwnerRetirementContinuation(
196
+ context,
197
+ deletionEvidence,
198
+ value.taskArtifactOwnerRetirementContinuation,
199
+ );
200
+ }
201
+ let retirementOutcome: TaskArtifactOwnerRetirementOutcome | undefined;
202
+ if (has("taskArtifactOwnerRetirementOutcome")) {
203
+ if (!deletionEvidence) throw new Error("task_artifact_owner_cleanup_evidence_missing");
204
+ retirementOutcome = parseBrokerTaskArtifactOwnerRetirementDisposition(
205
+ value.taskArtifactOwnerRetirementOutcome,
206
+ context,
207
+ deletionEvidence,
208
+ retirementContinuation,
209
+ );
210
+ }
211
+
212
+ const payloadRetired = trueFlag("taskArtifactOwnerPayloadRetired");
213
+ const namespaceRetained = trueFlag("taskArtifactOwnerNamespaceRetained");
214
+ const retired = trueFlag("taskArtifactOwnerRetired");
215
+ const transcriptDeleted = trueFlag("taskArtifactOwnerTranscriptDeleted");
216
+ let cleanupDiagnostic: string | undefined;
217
+ if (has("taskArtifactOwnerCleanupError")) {
218
+ const diagnostic = value.taskArtifactOwnerCleanupError;
219
+ if (typeof diagnostic !== "string" || diagnostic.length === 0 || diagnostic.length > 4096)
220
+ throw new Error("task_artifact_owner_cleanup_diagnostic_invalid");
221
+ cleanupDiagnostic = diagnostic;
222
+ }
223
+
224
+ if (
225
+ (retired !== undefined && (payloadRetired !== undefined || namespaceRetained !== undefined)) ||
226
+ (retired !== undefined && retirementOutcome?.kind !== "completed") ||
227
+ (payloadRetired !== undefined) !== (namespaceRetained !== undefined) ||
228
+ ((retired !== undefined ||
229
+ payloadRetired !== undefined ||
230
+ namespaceRetained !== undefined ||
231
+ transcriptDeleted !== undefined) &&
232
+ (!deletionEvidence || !retirementOutcome)) ||
233
+ (transcriptDeleted !== undefined &&
234
+ (phase !== "artifacts" || artifactsRemoved !== true || retirementOutcome === undefined))
235
+ )
236
+ throw new Error("task_artifact_owner_cleanup_state_invalid");
237
+
238
+ if (retirementOutcome) {
239
+ if (
240
+ (retired !== undefined && retirementOutcome.kind !== "completed") ||
241
+ (retirementOutcome.kind === "completed" && retired === undefined) ||
242
+ (payloadRetired !== undefined &&
243
+ (retirementOutcome.kind !== "payload_retired" || retirementContinuation === undefined)) ||
244
+ (retirementOutcome.kind === "payload_retired" && payloadRetired === undefined)
245
+ )
246
+ throw new Error("task_artifact_owner_cleanup_disposition_mismatch");
247
+ }
248
+
249
+ return Object.freeze({
250
+ deletionEvidence,
251
+ retirementContinuation,
252
+ retirementOutcome,
253
+ payloadRetired,
254
+ namespaceRetained,
255
+ retired,
256
+ transcriptDeleted,
257
+ cleanupDiagnostic,
258
+ });
259
+ }