@inhuman.tools/turn 0.1.0 → 0.3.0

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 (284) hide show
  1. package/README.md +38 -0
  2. package/dist/agent-calls/acceptance.d.ts +1 -1
  3. package/dist/agent-calls/acceptance.d.ts.map +1 -1
  4. package/dist/agent-calls/acceptance.js +1 -1
  5. package/dist/agent-calls/acceptance.js.map +1 -1
  6. package/dist/agent-calls/callbacks.d.ts +1 -1
  7. package/dist/agent-calls/callbacks.d.ts.map +1 -1
  8. package/dist/agent-calls/callbacks.js +1 -1
  9. package/dist/agent-calls/callbacks.js.map +1 -1
  10. package/dist/agent-calls/delivery.d.ts +1 -1
  11. package/dist/agent-calls/delivery.d.ts.map +1 -1
  12. package/dist/agent-calls/delivery.js +1 -1
  13. package/dist/agent-calls/delivery.js.map +1 -1
  14. package/dist/agent-calls/operator.d.ts +1 -1
  15. package/dist/agent-calls/operator.d.ts.map +1 -1
  16. package/dist/agent-calls/operator.js +1 -1
  17. package/dist/agent-calls/operator.js.map +1 -1
  18. package/dist/agent-calls/runtime.d.ts +4 -2
  19. package/dist/agent-calls/runtime.d.ts.map +1 -1
  20. package/dist/agent-calls/runtime.js +17 -11
  21. package/dist/agent-calls/runtime.js.map +1 -1
  22. package/dist/agent-calls/types.d.ts +1 -1
  23. package/dist/agent-calls/types.d.ts.map +1 -1
  24. package/dist/context/compact-floors.d.ts +1 -1
  25. package/dist/context/compact-floors.d.ts.map +1 -1
  26. package/dist/context/compact.d.ts +1 -1
  27. package/dist/context/compact.d.ts.map +1 -1
  28. package/dist/context/compact.js +5 -4
  29. package/dist/context/compact.js.map +1 -1
  30. package/dist/context/history.d.ts +1 -1
  31. package/dist/context/history.d.ts.map +1 -1
  32. package/dist/context/history.js +35 -3
  33. package/dist/context/history.js.map +1 -1
  34. package/dist/context/retention/defaults.js +1 -1
  35. package/dist/context/retention/defaults.js.map +1 -1
  36. package/dist/context/retention/resolve.js +1 -1
  37. package/dist/context/retention/resolve.js.map +1 -1
  38. package/dist/context/retention/schema.js +1 -1
  39. package/dist/context/retention/schema.js.map +1 -1
  40. package/dist/context/retention/stamp.d.ts +1 -1
  41. package/dist/context/retention/stamp.d.ts.map +1 -1
  42. package/dist/context/retention/stamp.js +5 -2
  43. package/dist/context/retention/stamp.js.map +1 -1
  44. package/dist/context/retention/step.d.ts +1 -1
  45. package/dist/context/retention/step.d.ts.map +1 -1
  46. package/dist/context/retention/step.js +7 -4
  47. package/dist/context/retention/step.js.map +1 -1
  48. package/dist/context/retention/view.d.ts +1 -1
  49. package/dist/context/retention/view.d.ts.map +1 -1
  50. package/dist/context/retention/view.js +1 -1
  51. package/dist/context/retention/view.js.map +1 -1
  52. package/dist/context/spawn-history.d.ts +13 -0
  53. package/dist/context/spawn-history.d.ts.map +1 -0
  54. package/dist/context/spawn-history.js +82 -0
  55. package/dist/context/spawn-history.js.map +1 -0
  56. package/dist/context/squeeze.d.ts +1 -1
  57. package/dist/context/squeeze.d.ts.map +1 -1
  58. package/dist/context/squeeze.js +1 -1
  59. package/dist/context/squeeze.js.map +1 -1
  60. package/dist/context/tools.d.ts +2 -2
  61. package/dist/context/tools.d.ts.map +1 -1
  62. package/dist/context/tools.js +2 -2
  63. package/dist/context/tools.js.map +1 -1
  64. package/dist/context/transcript.d.ts +3 -1
  65. package/dist/context/transcript.d.ts.map +1 -1
  66. package/dist/context/transcript.js +17 -4
  67. package/dist/context/transcript.js.map +1 -1
  68. package/dist/policy-processor/classifier.d.ts.map +1 -1
  69. package/dist/policy-processor/classifier.js +3 -1
  70. package/dist/policy-processor/classifier.js.map +1 -1
  71. package/dist/policy-processor/scope.d.ts +1 -0
  72. package/dist/policy-processor/scope.d.ts.map +1 -1
  73. package/dist/policy-processor/scope.js +9 -2
  74. package/dist/policy-processor/scope.js.map +1 -1
  75. package/dist/policy-processor/unattended-guard.js +8 -2
  76. package/dist/policy-processor/unattended-guard.js.map +1 -1
  77. package/dist/resources/compact-assets.d.ts +5 -0
  78. package/dist/resources/compact-assets.d.ts.map +1 -0
  79. package/dist/resources/compact-assets.js +6 -0
  80. package/dist/resources/compact-assets.js.map +1 -0
  81. package/dist/resources/compact-snapshot.d.ts +21 -0
  82. package/dist/resources/compact-snapshot.d.ts.map +1 -0
  83. package/dist/resources/compact-snapshot.js +44 -0
  84. package/dist/resources/compact-snapshot.js.map +1 -0
  85. package/dist/resources/index.d.ts +6 -0
  86. package/dist/resources/index.d.ts.map +1 -0
  87. package/dist/resources/index.js +19 -0
  88. package/dist/resources/index.js.map +1 -0
  89. package/dist/resources/module-bundle.d.ts +16 -0
  90. package/dist/resources/module-bundle.d.ts.map +1 -0
  91. package/dist/resources/module-bundle.js +78 -0
  92. package/dist/resources/module-bundle.js.map +1 -0
  93. package/dist/runtime/ambient-compact.d.ts +38 -0
  94. package/dist/runtime/ambient-compact.d.ts.map +1 -0
  95. package/dist/runtime/ambient-compact.js +123 -0
  96. package/dist/runtime/ambient-compact.js.map +1 -0
  97. package/dist/runtime/index.d.ts +9 -0
  98. package/dist/runtime/index.d.ts.map +1 -0
  99. package/dist/runtime/index.js +8 -0
  100. package/dist/runtime/index.js.map +1 -0
  101. package/dist/runtime/ports.d.ts +33 -0
  102. package/dist/runtime/ports.d.ts.map +1 -0
  103. package/dist/runtime/ports.js +2 -0
  104. package/dist/runtime/ports.js.map +1 -0
  105. package/dist/runtime/prompt-message-of.d.ts +4 -0
  106. package/dist/runtime/prompt-message-of.d.ts.map +1 -0
  107. package/dist/runtime/prompt-message-of.js +8 -0
  108. package/dist/runtime/prompt-message-of.js.map +1 -0
  109. package/dist/runtime/re-act-loop-intent-detected-trigger.d.ts +32 -0
  110. package/dist/runtime/re-act-loop-intent-detected-trigger.d.ts.map +1 -0
  111. package/dist/runtime/re-act-loop-intent-detected-trigger.js +132 -0
  112. package/dist/runtime/re-act-loop-intent-detected-trigger.js.map +1 -0
  113. package/dist/runtime/round-boundary.d.ts +13 -0
  114. package/dist/runtime/round-boundary.d.ts.map +1 -0
  115. package/dist/runtime/round-boundary.js +210 -0
  116. package/dist/runtime/round-boundary.js.map +1 -0
  117. package/dist/runtime/round-stop.d.ts +9 -0
  118. package/dist/runtime/round-stop.d.ts.map +1 -0
  119. package/dist/runtime/round-stop.js +19 -0
  120. package/dist/runtime/round-stop.js.map +1 -0
  121. package/dist/runtime/tool-call.d.ts +4 -0
  122. package/dist/runtime/tool-call.d.ts.map +1 -0
  123. package/dist/runtime/tool-call.js +8 -0
  124. package/dist/runtime/tool-call.js.map +1 -0
  125. package/dist/runtime/turn-iterator.d.ts +32 -0
  126. package/dist/runtime/turn-iterator.d.ts.map +1 -0
  127. package/dist/runtime/turn-iterator.js +251 -0
  128. package/dist/runtime/turn-iterator.js.map +1 -0
  129. package/dist/runtime/turn-resolvers.d.ts +17 -0
  130. package/dist/runtime/turn-resolvers.d.ts.map +1 -0
  131. package/dist/runtime/turn-resolvers.js +29 -0
  132. package/dist/runtime/turn-resolvers.js.map +1 -0
  133. package/dist/session/cancel.d.ts +4 -1
  134. package/dist/session/cancel.d.ts.map +1 -1
  135. package/dist/session/cancel.js +10 -2
  136. package/dist/session/cancel.js.map +1 -1
  137. package/dist/session/commands.d.ts +1 -1
  138. package/dist/session/commands.d.ts.map +1 -1
  139. package/dist/session/commands.js +4 -2
  140. package/dist/session/commands.js.map +1 -1
  141. package/dist/session/compact-actor.d.ts +12 -5
  142. package/dist/session/compact-actor.d.ts.map +1 -1
  143. package/dist/session/compact-actor.js +64 -12
  144. package/dist/session/compact-actor.js.map +1 -1
  145. package/dist/session/compact-env.d.ts +31 -5
  146. package/dist/session/compact-env.d.ts.map +1 -1
  147. package/dist/session/compact-env.js +23 -13
  148. package/dist/session/compact-env.js.map +1 -1
  149. package/dist/session/compact.d.ts +6 -3
  150. package/dist/session/compact.d.ts.map +1 -1
  151. package/dist/session/compact.js.map +1 -1
  152. package/dist/session/index.d.ts +2 -2
  153. package/dist/session/index.d.ts.map +1 -1
  154. package/dist/session/index.js +2 -2
  155. package/dist/session/index.js.map +1 -1
  156. package/dist/session/park.d.ts +1 -1
  157. package/dist/session/park.d.ts.map +1 -1
  158. package/dist/session/park.js +1 -1
  159. package/dist/session/park.js.map +1 -1
  160. package/dist/session/side-channel-reminder.d.ts +1 -1
  161. package/dist/session/side-channel-reminder.d.ts.map +1 -1
  162. package/dist/session/side-channel-reminder.js +1 -1
  163. package/dist/session/side-channel-reminder.js.map +1 -1
  164. package/dist/session/types.d.ts +1 -1
  165. package/dist/session/types.d.ts.map +1 -1
  166. package/dist/session-runtime/ask-line.d.ts +7 -0
  167. package/dist/session-runtime/ask-line.d.ts.map +1 -0
  168. package/dist/session-runtime/ask-line.js +36 -0
  169. package/dist/session-runtime/ask-line.js.map +1 -0
  170. package/dist/session-runtime/durability.d.ts +25 -0
  171. package/dist/session-runtime/durability.d.ts.map +1 -0
  172. package/dist/session-runtime/durability.js +18 -0
  173. package/dist/session-runtime/durability.js.map +1 -0
  174. package/dist/session-runtime/effects.d.ts +53 -0
  175. package/dist/session-runtime/effects.d.ts.map +1 -0
  176. package/dist/session-runtime/effects.js +18 -0
  177. package/dist/session-runtime/effects.js.map +1 -0
  178. package/dist/session-runtime/index.d.ts +8 -0
  179. package/dist/session-runtime/index.d.ts.map +1 -0
  180. package/dist/session-runtime/index.js +8 -0
  181. package/dist/session-runtime/index.js.map +1 -0
  182. package/dist/session-runtime/interrupt.d.ts +3 -0
  183. package/dist/session-runtime/interrupt.d.ts.map +1 -0
  184. package/dist/session-runtime/interrupt.js +33 -0
  185. package/dist/session-runtime/interrupt.js.map +1 -0
  186. package/dist/session-runtime/recovery.d.ts +17 -0
  187. package/dist/session-runtime/recovery.d.ts.map +1 -0
  188. package/dist/session-runtime/recovery.js +119 -0
  189. package/dist/session-runtime/recovery.js.map +1 -0
  190. package/dist/session-runtime/runtime.d.ts +50 -0
  191. package/dist/session-runtime/runtime.d.ts.map +1 -0
  192. package/dist/session-runtime/runtime.js +533 -0
  193. package/dist/session-runtime/runtime.js.map +1 -0
  194. package/dist/session-runtime/summary.d.ts +26 -0
  195. package/dist/session-runtime/summary.d.ts.map +1 -0
  196. package/dist/session-runtime/summary.js +33 -0
  197. package/dist/session-runtime/summary.js.map +1 -0
  198. package/dist/tool-catalog/bindings.d.ts +6 -0
  199. package/dist/tool-catalog/bindings.d.ts.map +1 -0
  200. package/dist/tool-catalog/bindings.js +37 -0
  201. package/dist/tool-catalog/bindings.js.map +1 -0
  202. package/dist/tool-catalog/catalog.d.ts +1 -3
  203. package/dist/tool-catalog/catalog.d.ts.map +1 -1
  204. package/dist/tool-catalog/catalog.js +6 -34
  205. package/dist/tool-catalog/catalog.js.map +1 -1
  206. package/dist/tool-catalog/types.d.ts +8 -0
  207. package/dist/tool-catalog/types.d.ts.map +1 -1
  208. package/dist/tool-catalog/types.js.map +1 -1
  209. package/dist/tool-runtime/approval/approved.d.ts +1 -1
  210. package/dist/tool-runtime/approval/approved.d.ts.map +1 -1
  211. package/dist/tool-runtime/approval/approved.js +1 -1
  212. package/dist/tool-runtime/approval/approved.js.map +1 -1
  213. package/dist/tool-runtime/handler.d.ts.map +1 -1
  214. package/dist/tool-runtime/handler.js +291 -237
  215. package/dist/tool-runtime/handler.js.map +1 -1
  216. package/dist/tool-runtime/run-system-tool.d.ts +1 -0
  217. package/dist/tool-runtime/run-system-tool.d.ts.map +1 -1
  218. package/dist/tool-runtime/run-system-tool.js +30 -10
  219. package/dist/tool-runtime/run-system-tool.js.map +1 -1
  220. package/dist/tool-runtime/worker-tools.d.ts.map +1 -1
  221. package/dist/tool-runtime/worker-tools.js +2 -2
  222. package/dist/tool-runtime/worker-tools.js.map +1 -1
  223. package/package.json +31 -18
  224. package/src/agent-calls/acceptance.ts +1 -1
  225. package/src/agent-calls/callbacks.ts +1 -1
  226. package/src/agent-calls/delivery.ts +1 -1
  227. package/src/agent-calls/operator.ts +7 -1
  228. package/src/agent-calls/runtime.ts +23 -11
  229. package/src/agent-calls/types.ts +1 -1
  230. package/src/context/compact-floors.ts +1 -1
  231. package/src/context/compact.ts +4 -5
  232. package/src/context/history.ts +46 -4
  233. package/src/context/retention/defaults.ts +1 -1
  234. package/src/context/retention/resolve.ts +1 -1
  235. package/src/context/retention/schema.ts +1 -1
  236. package/src/context/retention/stamp.ts +7 -2
  237. package/src/context/retention/step.ts +7 -4
  238. package/src/context/retention/view.ts +1 -1
  239. package/src/context/spawn-history.ts +95 -0
  240. package/src/context/squeeze.ts +1 -1
  241. package/src/context/tools.ts +3 -2
  242. package/src/context/transcript.ts +15 -4
  243. package/src/policy-processor/README.md +1 -1
  244. package/src/policy-processor/classifier.ts +3 -1
  245. package/src/policy-processor/scope.ts +7 -2
  246. package/src/policy-processor/unattended-guard.ts +5 -2
  247. package/src/resources/compact-assets.ts +9 -0
  248. package/src/resources/compact-snapshot.ts +51 -0
  249. package/src/resources/index.ts +27 -0
  250. package/src/resources/module-bundle.ts +90 -0
  251. package/src/runtime/ambient-compact.ts +147 -0
  252. package/src/runtime/index.ts +17 -0
  253. package/src/runtime/ports.ts +28 -0
  254. package/src/runtime/prompt-message-of.ts +14 -0
  255. package/src/runtime/re-act-loop-intent-detected-trigger.ts +177 -0
  256. package/src/runtime/round-boundary.ts +248 -0
  257. package/src/runtime/round-stop.ts +24 -0
  258. package/src/runtime/tool-call.ts +10 -0
  259. package/src/runtime/turn-iterator.ts +312 -0
  260. package/src/runtime/turn-resolvers.ts +43 -0
  261. package/src/session/cancel.ts +11 -3
  262. package/src/session/commands.ts +5 -2
  263. package/src/session/compact-actor.ts +76 -16
  264. package/src/session/compact-env.ts +27 -18
  265. package/src/session/compact.ts +8 -3
  266. package/src/session/index.ts +7 -2
  267. package/src/session/park.ts +7 -1
  268. package/src/session/side-channel-reminder.ts +1 -1
  269. package/src/session/types.ts +1 -1
  270. package/src/session-runtime/ask-line.ts +39 -0
  271. package/src/session-runtime/durability.ts +41 -0
  272. package/src/session-runtime/effects.ts +35 -0
  273. package/src/session-runtime/index.ts +19 -0
  274. package/src/session-runtime/interrupt.ts +34 -0
  275. package/src/session-runtime/recovery.ts +145 -0
  276. package/src/session-runtime/runtime.ts +535 -0
  277. package/src/session-runtime/summary.ts +67 -0
  278. package/src/tool-catalog/bindings.ts +37 -0
  279. package/src/tool-catalog/catalog.ts +6 -31
  280. package/src/tool-catalog/types.ts +9 -0
  281. package/src/tool-runtime/approval/approved.ts +1 -1
  282. package/src/tool-runtime/handler.ts +40 -24
  283. package/src/tool-runtime/run-system-tool.ts +27 -11
  284. package/src/tool-runtime/worker-tools.ts +2 -2
@@ -0,0 +1,145 @@
1
+ /** Session job recovery is shared by local and embedded hosts. */
2
+ import {
3
+ CallJob,
4
+ ApprovableJob,
5
+ CompletionJob,
6
+ FailureOutcome,
7
+ MCPToolJob,
8
+ ProgrammaticJob,
9
+ cancelUnstarted,
10
+ SessionRoot,
11
+ SessionTurn,
12
+ ToolCallJob,
13
+ WebExtractToolJob,
14
+ WebSearchToolJob,
15
+ } from "@inhuman.tools/harness-model/session";
16
+ import { Job, WorkExpectation } from "@inhuman.tools/plexus-expectations";
17
+ import { runInAction } from "mobx";
18
+
19
+ import { restorePendingRound, settleCancelledTurn, type CallsCatalog } from "../agent-calls/index.js";
20
+
21
+ const UNKNOWN_TEXT = "interrupted by restart; execution outcome unknown";
22
+
23
+ type JobRecoveryAction = "settled" | "dispatch" | "unknown" | "cancel" | "resume-inner";
24
+
25
+ export type RecoveredJob = {
26
+ readonly job: Job;
27
+ readonly kind: string;
28
+ readonly action: JobRecoveryAction;
29
+ };
30
+
31
+ export type JobRecoveryOptions = {
32
+ readonly catalog?: CallsCatalog;
33
+ readonly releaseCall?: (call: CallJob) => void;
34
+ };
35
+
36
+ function hasSettlement(job: Job): boolean {
37
+ if (job.completed) return true;
38
+ if (job instanceof ToolCallJob || job instanceof ProgrammaticJob) {
39
+ return job.receiptJson != null && job.receiptJson !== "";
40
+ }
41
+ if (job instanceof CompletionJob) return job.expectation.isDone;
42
+ return false;
43
+ }
44
+
45
+ function leftoverInFlight(job: Job): boolean {
46
+ if (job instanceof MCPToolJob && job.dispatched) return true;
47
+ if (job instanceof MCPToolJob || job instanceof WebSearchToolJob || job instanceof WebExtractToolJob) {
48
+ const attempt = job.expectation;
49
+ return !attempt.isDone && attempt.startedAtMs != null;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ function hasRecordedIntent(job: Job): boolean {
55
+ return job.orchestrated || leftoverInFlight(job);
56
+ }
57
+
58
+ function failOpen(job: Job, code: "crash" | "cancel", text: string): void {
59
+ for (const node of job.work) {
60
+ if (node instanceof WorkExpectation && !node.isDone) node.fail(code, text, "null");
61
+ }
62
+ }
63
+
64
+ function stampUnknown(job: Job): void {
65
+ if (job instanceof CallJob) {
66
+ job.settleResult(new FailureOutcome({ code: "error", message: UNKNOWN_TEXT }));
67
+ return;
68
+ }
69
+ if (job instanceof ToolCallJob || job instanceof ProgrammaticJob) {
70
+ job.outcomeSnap ??= { ok: false, text: UNKNOWN_TEXT };
71
+ }
72
+ failOpen(job, "crash", UNKNOWN_TEXT);
73
+ if (!job.completed) job.markComplete();
74
+ }
75
+
76
+ /** A cancelled job the crash left open closes with a cancel receipt and no open attempt. */
77
+ function settleCancelled(job: Job): void {
78
+ if (job instanceof ApprovableJob) cancelUnstarted(job);
79
+ else if (job instanceof ProgrammaticJob) job.outcomeSnap ??= { ok: false, text: "cancelled" };
80
+ failOpen(job, "cancel", "terminated by recovery; outcome unknown");
81
+ if (!job.completed) job.markComplete();
82
+ }
83
+
84
+ /** Classify one job from its effect fields. */
85
+ function classifyJobRecovery(job: Job): JobRecoveryAction {
86
+ // A closed turn whose cancel never landed can still hold an open completion.
87
+ if (job instanceof SessionTurn && job.cancelRequested) return "cancel";
88
+ if (job.completed) return "settled";
89
+ if (job.cancelled) return "cancel";
90
+ switch (job.unknownRecovery) {
91
+ case "unknown-no-replay":
92
+ if (hasSettlement(job)) return "settled";
93
+ return hasRecordedIntent(job) ? "unknown" : "dispatch";
94
+ case "resume-inner":
95
+ return "resume-inner";
96
+ case "resume-queued":
97
+ return "dispatch";
98
+ default:
99
+ // A contract-less job is not ours to replay.
100
+ return hasRecordedIntent(job) ? "unknown" : "dispatch";
101
+ }
102
+ }
103
+
104
+ function applyJobRecovery(
105
+ job: Job,
106
+ action: JobRecoveryAction,
107
+ catalog: CallsCatalog | undefined,
108
+ releaseCall?: (call: CallJob) => void,
109
+ session?: SessionRoot,
110
+ ): void {
111
+ switch (action) {
112
+ case "settled":
113
+ case "dispatch":
114
+ return;
115
+ case "unknown":
116
+ stampUnknown(job);
117
+ return;
118
+ case "cancel":
119
+ if (job instanceof SessionTurn) settleCancelledTurn(job, catalog, releaseCall, session);
120
+ else settleCancelled(job);
121
+ return;
122
+ case "resume-inner":
123
+ if (job instanceof SessionTurn) restorePendingRound(job);
124
+ return;
125
+ }
126
+ }
127
+
128
+ export function recoverSessionJobs(root: SessionRoot, options: JobRecoveryOptions = {}): RecoveredJob[] {
129
+ const nodes: Job[] = [];
130
+ for (const node of root.motionWork()) {
131
+ if (node instanceof Job) nodes.push(node);
132
+ }
133
+ // A turn re-enters before its children are classified: it reads the round
134
+ // the interruption left open, and the child's recovery closes that round.
135
+ nodes.sort((a, b) => Number(b instanceof SessionTurn) - Number(a instanceof SessionTurn));
136
+ const recovered: RecoveredJob[] = [];
137
+ runInAction(() => {
138
+ for (const job of nodes) {
139
+ const action = classifyJobRecovery(job);
140
+ applyJobRecovery(job, action, options.catalog, options.releaseCall, root);
141
+ recovered.push({ job, kind: job.__type__, action });
142
+ }
143
+ });
144
+ return recovered;
145
+ }
@@ -0,0 +1,535 @@
1
+ import { SessionPlexus, SessionTurn, resolvedTip, type SessionRoot } from "@inhuman.tools/harness-model/session";
2
+ import { computed, observable, runInAction, when } from "mobx";
3
+ import * as Y from "yjs";
4
+ import { z } from "zod";
5
+
6
+ import {
7
+ receiptCovers,
8
+ SessionOwnershipError,
9
+ SessionPersistenceError,
10
+ type SessionCheckpoint,
11
+ type SessionDurability,
12
+ type SessionReceipt,
13
+ } from "./durability.js";
14
+ import {
15
+ effectRecordSchema,
16
+ sessionEffectOutcomeSchema,
17
+ type SessionEffectOutcome,
18
+ type SessionEffectPort,
19
+ type SessionEffectRequest,
20
+ } from "./effects.js";
21
+ import { interruptSession } from "./interrupt.js";
22
+ import { recoverSessionJobs, type JobRecoveryOptions, type RecoveredJob } from "./recovery.js";
23
+ import { iterateTurn, type TurnIteratorHost } from "../runtime/turn-iterator.js";
24
+ import {
25
+ admitCompact,
26
+ admitTurn,
27
+ cancelTurnCommand,
28
+ drainPromptQueue,
29
+ retryTurn,
30
+ rewindTurn,
31
+ type CancelTurnArgs,
32
+ type CompactAdmitResult,
33
+ type RewindResult,
34
+ type TurnAdmitResult,
35
+ } from "../session/commands.js";
36
+ import type { SessionCommandHost } from "../session/types.js";
37
+
38
+ type RuntimePhase = "hydrated" | "ready" | "active" | "parked" | "disposed";
39
+
40
+ export type SessionRecoveryPolicy =
41
+ { readonly kind: "resume" } | { readonly kind: "interrupt"; readonly message: string };
42
+
43
+ type Binding = { readonly incarnation: string; revoke(): void };
44
+ const bindings = new WeakMap<Y.Doc, Binding>();
45
+
46
+ const commandResultSchema = z.union([
47
+ z.object({ ok: z.literal(true), action: z.enum(["noop", "mint", "steer", "queue", "continue"]) }),
48
+ z.object({ ok: z.literal(false), reason: z.string(), invalid: z.boolean().optional() }),
49
+ ]);
50
+ const commandRecordSchema = z.object({ input: z.string(), result: commandResultSchema });
51
+
52
+ export class SessionRuntime implements AsyncDisposable {
53
+ readonly session: SessionPlexus;
54
+ readonly incarnation = crypto.randomUUID();
55
+ readonly #durability: SessionDurability;
56
+ readonly #controller = new AbortController();
57
+ readonly #ownershipController = new AbortController();
58
+ readonly #operations = new Map<string, { readonly input: string; readonly run: Promise<SessionEffectOutcome> }>();
59
+ readonly #runs = new Map<SessionTurn, AbortController>();
60
+ readonly #stops: Array<() => void> = [];
61
+ #revision = 0;
62
+ #commands: Promise<unknown> = Promise.resolve();
63
+ #flushes: Promise<unknown> = Promise.resolve();
64
+ #blocked: SessionCheckpoint | null = null;
65
+ @observable accessor phase: RuntimePhase = "hydrated";
66
+ @observable accessor #holds = 0;
67
+
68
+ constructor(session: SessionPlexus, durability: SessionDurability) {
69
+ this.session = session;
70
+ this.#durability = durability;
71
+ if (session.doc.isDestroyed) throw new Error("cannot bind a destroyed session document");
72
+ const version = session.doc.getMap("harness:session-runtime").get("version");
73
+ if (version !== undefined && version !== 1) throw new Error("unsupported session runtime state version");
74
+ const previous = bindings.get(session.doc);
75
+ previous?.revoke();
76
+ bindings.set(session.doc, { incarnation: this.incarnation, revoke: () => this.#revoke("replaced") });
77
+ const update = (): void => {
78
+ this.#revision += 1;
79
+ };
80
+ session.doc.on("update", update);
81
+ this.#stops.push(() => session.doc.off("update", update));
82
+ const destroy = (): void => this.#revoke("destroyed");
83
+ session.doc.on("destroy", destroy);
84
+ this.#stops.push(() => session.doc.off("destroy", destroy));
85
+ }
86
+
87
+ static create(sessionId: string, durability: SessionDurability, workspacePath = ""): SessionRuntime {
88
+ return new SessionRuntime(SessionPlexus.create(sessionId, workspacePath), durability);
89
+ }
90
+
91
+ static restore(sessionId: string, state: Uint8Array, durability: SessionDurability): SessionRuntime {
92
+ const doc = new Y.Doc({ guid: `harness:session:${sessionId}` });
93
+ Y.applyUpdate(doc, state);
94
+ const session = SessionPlexus.connect(doc, { undo: "stub" });
95
+ if (session.root.sessionId !== sessionId) {
96
+ session.destroy();
97
+ throw new Error("session checkpoint identity does not match its document");
98
+ }
99
+ return new SessionRuntime(session, durability);
100
+ }
101
+
102
+ get root(): SessionRoot {
103
+ return this.session.root;
104
+ }
105
+ get signal(): AbortSignal {
106
+ return this.#controller.signal;
107
+ }
108
+
109
+ get isCurrent(): boolean {
110
+ return bindings.get(this.session.doc)?.incarnation === this.incarnation && !this.session.doc.isDestroyed;
111
+ }
112
+
113
+ get ownershipSignal(): AbortSignal {
114
+ return this.#ownershipController.signal;
115
+ }
116
+
117
+ @computed
118
+ get admissionHeld(): boolean {
119
+ return this.#holds > 0;
120
+ }
121
+
122
+ recover(policy: SessionRecoveryPolicy = { kind: "resume" }, options: JobRecoveryOptions = {}): RecoveredJob[] {
123
+ this.#assertOwned();
124
+ if (this.phase !== "hydrated") return [];
125
+ const recovered = policy.kind === "resume" ? recoverSessionJobs(this.root, options) : [];
126
+ runInAction(() => {
127
+ if (policy.kind === "interrupt") interruptSession(this.root, policy.message);
128
+ const tip = resolvedTip(this.root.head, this.root.tip);
129
+ if (tip !== this.root.tip) this.root.tip = tip;
130
+ this.phase = "ready";
131
+ });
132
+ return recovered;
133
+ }
134
+
135
+ activate(): void {
136
+ this.#assertOwned();
137
+ if (this.phase === "hydrated") throw new Error("recover session before activation");
138
+ if (this.phase === "parked" || this.phase === "disposed") throw new SessionOwnershipError();
139
+ runInAction(() => {
140
+ this.phase = "active";
141
+ });
142
+ }
143
+
144
+ checkpoint(): SessionCheckpoint {
145
+ this.#assertOwned();
146
+ return {
147
+ sessionId: this.root.sessionId,
148
+ incarnation: this.incarnation,
149
+ revision: this.#revision,
150
+ state: Y.encodeStateAsUpdate(this.session.doc),
151
+ };
152
+ }
153
+
154
+ flush(): Promise<SessionReceipt> {
155
+ const next = this.#flushes.then(
156
+ () => this.#flush(),
157
+ () => this.#flush(),
158
+ );
159
+ this.#flushes = next.catch(() => undefined);
160
+ return next;
161
+ }
162
+
163
+ async #flush(): Promise<SessionReceipt> {
164
+ this.#assertOwned();
165
+ let checkpoint = this.#blocked ?? this.checkpoint();
166
+ runInAction(() => {
167
+ this.#holds = 1;
168
+ });
169
+ for (;;) {
170
+ this.#blocked = checkpoint;
171
+ const receipt =
172
+ this.#durability.kind === "memory"
173
+ ? { sessionId: checkpoint.sessionId, incarnation: checkpoint.incarnation, revision: checkpoint.revision }
174
+ : await this.#durability.commit(checkpoint);
175
+ this.#assertOwned();
176
+ if (!receiptCovers(receipt, checkpoint))
177
+ throw new SessionPersistenceError("receipt does not cover session checkpoint");
178
+ if (this.#revision > checkpoint.revision) {
179
+ checkpoint = this.checkpoint();
180
+ continue;
181
+ }
182
+ this.#blocked = null;
183
+ runInAction(() => {
184
+ this.#holds = 0;
185
+ });
186
+ return receipt;
187
+ }
188
+ }
189
+
190
+ commitMutation(
191
+ operationId: string,
192
+ input: string,
193
+ mutate: () => void,
194
+ signal?: AbortSignal,
195
+ ): Promise<SessionReceipt> {
196
+ return this.#serialize(async () => {
197
+ this.#assertOwned();
198
+ signal?.throwIfAborted();
199
+ const records = this.session.doc.getMap<string>("harness:session-commits");
200
+ const previous = records.get(operationId);
201
+ if (previous !== undefined) {
202
+ if (previous !== input) throw new Error("commit operation id was already used for different input");
203
+ return this.flush();
204
+ }
205
+ if (this.#blocked !== null)
206
+ throw new SessionPersistenceError("retry the pending checkpoint before another mutation");
207
+ runInAction(() => {
208
+ this.#holds += 1;
209
+ });
210
+ try {
211
+ this.session.doc.transact(() => {
212
+ runInAction(mutate);
213
+ this.session.doc.getMap("harness:session-runtime").set("version", 1);
214
+ records.set(operationId, input);
215
+ });
216
+ } catch (error) {
217
+ this.#revoke("mutation-failed");
218
+ runInAction(() => {
219
+ this.#holds = 0;
220
+ this.phase = "parked";
221
+ });
222
+ throw error;
223
+ }
224
+ this.#blocked = this.checkpoint();
225
+ return this.flush();
226
+ });
227
+ }
228
+
229
+ admit(host: SessionCommandHost, args: Parameters<typeof admitTurn>[1], commandId?: string): Promise<TurnAdmitResult> {
230
+ return this.#serialize(async () => {
231
+ this.#assertHost(host);
232
+ const input = JSON.stringify({ prompt: args.prompt, kind: args.kind ?? null, visuals: args.visuals ?? [] });
233
+ const commands = this.session.doc.getMap<string>("harness:session-commands");
234
+ const previous = commandId == null ? undefined : commands.get(commandId);
235
+ if (previous !== undefined) {
236
+ const saved = commandRecordSchema.parse(JSON.parse(previous));
237
+ if (saved.input !== input) throw new Error("command id was already used for different input");
238
+ await this.flush();
239
+ return saved.result;
240
+ }
241
+ if (this.#blocked !== null)
242
+ throw new SessionPersistenceError("retry the pending checkpoint before admitting new work");
243
+ runInAction(() => {
244
+ this.#holds += 1;
245
+ });
246
+ let result: TurnAdmitResult;
247
+ try {
248
+ result = runInAction(() => admitTurn(host, args));
249
+ this.session.doc.transact(() => {
250
+ this.session.doc.getMap("harness:session-runtime").set("version", 1);
251
+ if (commandId != null) {
252
+ commands.set(commandId, JSON.stringify({ input, result }));
253
+ }
254
+ });
255
+ this.#blocked = this.checkpoint();
256
+ await this.flush();
257
+ return result;
258
+ } catch (error) {
259
+ if (this.#blocked === null) {
260
+ this.#revoke("command-failed");
261
+ runInAction(() => {
262
+ this.#holds = 0;
263
+ this.phase = "parked";
264
+ });
265
+ }
266
+ throw error;
267
+ }
268
+ });
269
+ }
270
+
271
+ compact(host: SessionCommandHost, focus = ""): Promise<CompactAdmitResult> {
272
+ return this.#command(host, () => admitCompact(host, focus));
273
+ }
274
+
275
+ interrupt(operationId: string, message: string): Promise<SessionReceipt> {
276
+ this.#assertOwned();
277
+ if (this.phase !== "active") throw new Error("interrupt requires an active runtime");
278
+ return this.commitMutation(`interrupt:${operationId}`, message, () => {
279
+ for (const run of this.#runs.values()) run.abort("interrupt");
280
+ interruptSession(this.root, message);
281
+ });
282
+ }
283
+
284
+ cancel(host: SessionCommandHost, args: CancelTurnArgs): Promise<{ readonly ok: true }> {
285
+ this.#assertHost(host);
286
+ return this.#serialize(async () => {
287
+ this.#assertHost(host);
288
+ let result: { readonly ok: true };
289
+ try {
290
+ result = runInAction(() => {
291
+ this.#holds += 1;
292
+ const cancellation = cancelTurnCommand(host, args);
293
+ const turn = [...this.#runs.keys()].find((item) =>
294
+ "turnId" in args ? item.uuid === args.turnId : item.evalSpec?.requestId === args.requestId,
295
+ );
296
+ if (turn != null) this.#runs.get(turn)?.abort("cancel");
297
+ return cancellation;
298
+ });
299
+ this.session.doc.getMap("harness:session-runtime").set("version", 1);
300
+ } catch (error) {
301
+ this.#revoke("command-failed");
302
+ runInAction(() => {
303
+ this.#holds = 0;
304
+ this.phase = "parked";
305
+ });
306
+ throw error;
307
+ }
308
+ this.#blocked ??= this.checkpoint();
309
+ await this.flush();
310
+ return result;
311
+ });
312
+ }
313
+
314
+ rewind(host: SessionCommandHost, turnId: string): Promise<RewindResult> {
315
+ return this.#command(host, () => {
316
+ const result = rewindTurn(host, turnId);
317
+ if (result.ok) for (const run of this.#runs.values()) run.abort("rewind");
318
+ return result;
319
+ });
320
+ }
321
+
322
+ retry(host: SessionCommandHost): Promise<{ readonly ok: true }> {
323
+ return this.#command(host, () => retryTurn(host));
324
+ }
325
+
326
+ drain(host: SessionCommandHost): Promise<void> {
327
+ return this.#command(host, () => {
328
+ drainPromptQueue(host);
329
+ });
330
+ }
331
+
332
+ async run(turn: SessionTurn, host: TurnIteratorHost, signal?: AbortSignal): Promise<void> {
333
+ this.#assertOwned();
334
+ if (this.phase !== "active" || this.admissionHeld) throw new Error("session execution is not active");
335
+ if (host.session !== this.root) throw new Error("turn host belongs to another session");
336
+ const pending = this.#runs.get(turn);
337
+ if (pending != null) throw new Error("turn already has an interpreter");
338
+ const controller = new AbortController();
339
+ this.#runs.set(turn, controller);
340
+ const abort =
341
+ signal == null
342
+ ? AbortSignal.any([this.signal, controller.signal])
343
+ : AbortSignal.any([this.signal, controller.signal, signal]);
344
+ const loopLimits = host.loopLimits;
345
+ const remoteMcp = host.remoteMcp;
346
+ try {
347
+ await iterateTurn(
348
+ turn,
349
+ {
350
+ get session() {
351
+ return host.session;
352
+ },
353
+ get resolvers() {
354
+ return host.resolvers;
355
+ },
356
+ get notes() {
357
+ return host.notes;
358
+ },
359
+ get diagnostics() {
360
+ return host.diagnostics;
361
+ },
362
+ get compactKeepTurns() {
363
+ return host.compactKeepTurns;
364
+ },
365
+ get hardCompactTrigger() {
366
+ return host.hardCompactTrigger;
367
+ },
368
+ admit: (call) => host.admit(call),
369
+ openRound: (resumed) => host.openRound(resumed),
370
+ closeRound: (settle) => host.closeRound(settle),
371
+ classifyIntent: (completion, intentSignal) => host.classifyIntent(completion, intentSignal),
372
+ startAmbientCompact: (options) => host.startAmbientCompact(options),
373
+ loopLimits: loopLimits == null ? undefined : (current) => loopLimits.call(host, current),
374
+ remoteMcp: remoteMcp == null ? undefined : (key) => remoteMcp.call(host, key),
375
+ waitInbound: async (inboundSignal) => {
376
+ if (this.admissionHeld) await when(() => !this.admissionHeld, { signal: inboundSignal });
377
+ this.#assertOwned();
378
+ return host.waitInbound(inboundSignal);
379
+ },
380
+ },
381
+ abort,
382
+ );
383
+ } finally {
384
+ if (this.#runs.get(turn) === controller) this.#runs.delete(turn);
385
+ }
386
+ }
387
+
388
+ effect(
389
+ operationId: string,
390
+ method: string,
391
+ payload: string,
392
+ port: SessionEffectPort,
393
+ apply: (outcome: SessionEffectOutcome) => void,
394
+ signal?: AbortSignal,
395
+ ): Promise<SessionEffectOutcome> {
396
+ this.#assertOwned();
397
+ if (this.phase !== "active") return Promise.reject(new SessionOwnershipError());
398
+ const input = JSON.stringify({ method, payload });
399
+ const pending = this.#operations.get(operationId);
400
+ if (pending != null) {
401
+ if (pending.input !== input)
402
+ return Promise.reject(new Error("effect operation id was already used for different input"));
403
+ return pending.run;
404
+ }
405
+ const request: SessionEffectRequest = {
406
+ sessionId: this.root.sessionId,
407
+ operationId,
408
+ incarnation: this.incarnation,
409
+ method,
410
+ payload,
411
+ };
412
+ const run = this.#effect(request, port, apply, signal);
413
+ this.#operations.set(operationId, { input, run });
414
+ void run
415
+ .finally(() => {
416
+ if (this.#operations.get(operationId)?.run === run) this.#operations.delete(operationId);
417
+ })
418
+ .catch(() => undefined);
419
+ return run;
420
+ }
421
+
422
+ async #effect(
423
+ request: SessionEffectRequest,
424
+ port: SessionEffectPort,
425
+ apply: (outcome: SessionEffectOutcome) => void,
426
+ signal?: AbortSignal,
427
+ ): Promise<SessionEffectOutcome> {
428
+ const abort = signal == null ? this.signal : AbortSignal.any([this.signal, signal]);
429
+ const records = this.session.doc.getMap<string>("harness:session-effects");
430
+ const raw = records.get(request.operationId);
431
+ const saved = raw == null ? null : effectRecordSchema.parse(JSON.parse(raw));
432
+ if (saved != null && (saved.request.method !== request.method || saved.request.payload !== request.payload)) {
433
+ throw new Error("effect operation id was already used for different input");
434
+ }
435
+ if (saved?.phase === "result") {
436
+ await this.flush();
437
+ return saved.outcome;
438
+ }
439
+ let outcome: SessionEffectOutcome;
440
+ if (saved != null) {
441
+ outcome =
442
+ port.reconcile == null
443
+ ? { kind: "unknown", message: "interrupted effect; dispatch outcome is unknown" }
444
+ : await port.reconcile(saved.request, abort);
445
+ } else {
446
+ await this.commitMutation(`effect-intent:${request.operationId}`, `${request.method}\n${request.payload}`, () => {
447
+ records.set(request.operationId, JSON.stringify({ phase: "intent", request }));
448
+ });
449
+ this.#assertOwned();
450
+ abort.throwIfAborted();
451
+ outcome = await port.invoke(request, abort);
452
+ }
453
+ this.#assertOwned();
454
+ abort.throwIfAborted();
455
+ const checked = sessionEffectOutcomeSchema.parse(outcome);
456
+ await this.commitMutation(
457
+ `effect-result:${request.operationId}`,
458
+ JSON.stringify(checked),
459
+ () => {
460
+ apply(checked);
461
+ records.set(request.operationId, JSON.stringify({ phase: "result", request, outcome: checked }));
462
+ },
463
+ abort,
464
+ );
465
+ return checked;
466
+ }
467
+
468
+ park(): void {
469
+ if (this.phase === "disposed" || this.phase === "parked") return;
470
+ this.#revoke("park");
471
+ runInAction(() => {
472
+ this.phase = "parked";
473
+ });
474
+ }
475
+
476
+ async [Symbol.asyncDispose](): Promise<void> {
477
+ this.park();
478
+ this.#ownershipController.abort("disposed");
479
+ for (const stop of this.#stops.splice(0)) stop();
480
+ if (bindings.get(this.session.doc)?.incarnation === this.incarnation) bindings.delete(this.session.doc);
481
+ runInAction(() => {
482
+ this.phase = "disposed";
483
+ });
484
+ }
485
+
486
+ #assertHost(host: SessionCommandHost): void {
487
+ this.#assertOwned();
488
+ if (this.phase !== "active") throw new Error("session commands require an active runtime");
489
+ if (host.session !== this.root) throw new Error("command host belongs to another session");
490
+ }
491
+
492
+ #assertOwned(): void {
493
+ if (this.signal.aborted || bindings.get(this.session.doc)?.incarnation !== this.incarnation) {
494
+ throw new SessionOwnershipError();
495
+ }
496
+ }
497
+
498
+ #revoke(reason: string): void {
499
+ if (reason === "replaced" || reason === "destroyed") this.#ownershipController.abort(reason);
500
+ this.#controller.abort(reason);
501
+ for (const run of this.#runs.values()) run.abort(reason);
502
+ }
503
+
504
+ #serialize<T>(run: () => Promise<T>): Promise<T> {
505
+ const next = this.#commands.then(run, run);
506
+ this.#commands = next.catch(() => undefined);
507
+ return next;
508
+ }
509
+
510
+ #command<T>(host: SessionCommandHost, apply: () => T): Promise<T> {
511
+ return this.#serialize(async () => {
512
+ this.#assertHost(host);
513
+ if (this.#blocked !== null)
514
+ throw new SessionPersistenceError("retry the pending checkpoint before admitting new work");
515
+ runInAction(() => {
516
+ this.#holds += 1;
517
+ });
518
+ let result: T;
519
+ try {
520
+ result = runInAction(apply);
521
+ this.session.doc.getMap("harness:session-runtime").set("version", 1);
522
+ } catch (error) {
523
+ this.#revoke("command-failed");
524
+ runInAction(() => {
525
+ this.#holds = 0;
526
+ this.phase = "parked";
527
+ });
528
+ throw error;
529
+ }
530
+ this.#blocked = this.checkpoint();
531
+ await this.flush();
532
+ return result;
533
+ });
534
+ }
535
+ }