@twelvehart/orcats 0.0.0-seed.0 → 0.2.3

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 (275) hide show
  1. package/LICENSE +48 -0
  2. package/NOTICE +5 -0
  3. package/README.md +399 -0
  4. package/bin/orcats +4 -0
  5. package/dist/backends/acp-client.d.ts +74 -0
  6. package/dist/backends/acp-client.d.ts.map +1 -0
  7. package/dist/backends/acp-run.d.ts +31 -0
  8. package/dist/backends/acp-run.d.ts.map +1 -0
  9. package/dist/backends/claude-run.d.ts +35 -0
  10. package/dist/backends/claude-run.d.ts.map +1 -0
  11. package/dist/backends/claude-stream-json.d.ts +15 -0
  12. package/dist/backends/claude-stream-json.d.ts.map +1 -0
  13. package/dist/backends/codex-jsonl.d.ts +34 -0
  14. package/dist/backends/codex-jsonl.d.ts.map +1 -0
  15. package/dist/backends/codex-run.d.ts +40 -0
  16. package/dist/backends/codex-run.d.ts.map +1 -0
  17. package/dist/backends/codex.d.ts +5 -0
  18. package/dist/backends/codex.d.ts.map +1 -0
  19. package/dist/backends/conversation-config.d.ts +12 -0
  20. package/dist/backends/conversation-config.d.ts.map +1 -0
  21. package/dist/backends/index.d.ts +14 -0
  22. package/dist/backends/index.d.ts.map +1 -0
  23. package/dist/backends/opencode-run.d.ts +48 -0
  24. package/dist/backends/opencode-run.d.ts.map +1 -0
  25. package/dist/backends/opencode-sse.d.ts +35 -0
  26. package/dist/backends/opencode-sse.d.ts.map +1 -0
  27. package/dist/backends/pi-rpc.d.ts +21 -0
  28. package/dist/backends/pi-rpc.d.ts.map +1 -0
  29. package/dist/backends/pi-run.d.ts +21 -0
  30. package/dist/backends/pi-run.d.ts.map +1 -0
  31. package/dist/backends/select.d.ts +17 -0
  32. package/dist/backends/select.d.ts.map +1 -0
  33. package/dist/backends/subprocess-run.d.ts +68 -0
  34. package/dist/backends/subprocess-run.d.ts.map +1 -0
  35. package/dist/backends/types.d.ts +17 -0
  36. package/dist/backends/types.d.ts.map +1 -0
  37. package/dist/backends/unsupported.d.ts +9 -0
  38. package/dist/backends/unsupported.d.ts.map +1 -0
  39. package/dist/baseline/index.d.ts +56 -0
  40. package/dist/baseline/index.d.ts.map +1 -0
  41. package/dist/cli/args.d.ts +22 -0
  42. package/dist/cli/args.d.ts.map +1 -0
  43. package/dist/cli/embedded.d.ts +7 -0
  44. package/dist/cli/embedded.d.ts.map +1 -0
  45. package/dist/cli/main.d.ts +6 -0
  46. package/dist/cli/main.d.ts.map +1 -0
  47. package/dist/cli/version.d.ts +2 -0
  48. package/dist/cli/version.d.ts.map +1 -0
  49. package/dist/conversation/ask-user.d.ts +17 -0
  50. package/dist/conversation/ask-user.d.ts.map +1 -0
  51. package/dist/conversation/conversation.d.ts +46 -0
  52. package/dist/conversation/conversation.d.ts.map +1 -0
  53. package/dist/conversation/harness.d.ts +14 -0
  54. package/dist/conversation/harness.d.ts.map +1 -0
  55. package/dist/conversation/index.d.ts +5 -0
  56. package/dist/conversation/index.d.ts.map +1 -0
  57. package/dist/conversation/queue.d.ts +15 -0
  58. package/dist/conversation/queue.d.ts.map +1 -0
  59. package/dist/flow/accessors.d.ts +13 -0
  60. package/dist/flow/accessors.d.ts.map +1 -0
  61. package/dist/flow/approvals.d.ts +6 -0
  62. package/dist/flow/approvals.d.ts.map +1 -0
  63. package/dist/flow/args.d.ts +4 -0
  64. package/dist/flow/args.d.ts.map +1 -0
  65. package/dist/flow/context.d.ts +36 -0
  66. package/dist/flow/context.d.ts.map +1 -0
  67. package/dist/flow/index.d.ts +5 -0
  68. package/dist/flow/index.d.ts.map +1 -0
  69. package/dist/index.d.ts +16 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/loop/builder/index.d.ts +17 -0
  72. package/dist/loop/builder/index.d.ts.map +1 -0
  73. package/dist/loop/builder/types.d.ts +97 -0
  74. package/dist/loop/builder/types.d.ts.map +1 -0
  75. package/dist/loop/context/compaction.d.ts +38 -0
  76. package/dist/loop/context/compaction.d.ts.map +1 -0
  77. package/dist/loop/context/index.d.ts +4 -0
  78. package/dist/loop/context/index.d.ts.map +1 -0
  79. package/dist/loop/context/offload.d.ts +34 -0
  80. package/dist/loop/context/offload.d.ts.map +1 -0
  81. package/dist/loop/context/types.d.ts +13 -0
  82. package/dist/loop/context/types.d.ts.map +1 -0
  83. package/dist/loop/engine/bridge.d.ts +21 -0
  84. package/dist/loop/engine/bridge.d.ts.map +1 -0
  85. package/dist/loop/engine/index.d.ts +73 -0
  86. package/dist/loop/engine/index.d.ts.map +1 -0
  87. package/dist/loop/execution.d.ts +68 -0
  88. package/dist/loop/execution.d.ts.map +1 -0
  89. package/dist/loop/fanout.d.ts +148 -0
  90. package/dist/loop/fanout.d.ts.map +1 -0
  91. package/dist/loop/firing.d.ts +54 -0
  92. package/dist/loop/firing.d.ts.map +1 -0
  93. package/dist/loop/graph/analyze.d.ts +8 -0
  94. package/dist/loop/graph/analyze.d.ts.map +1 -0
  95. package/dist/loop/graph/index.d.ts +3 -0
  96. package/dist/loop/graph/index.d.ts.map +1 -0
  97. package/dist/loop/graph/types.d.ts +35 -0
  98. package/dist/loop/graph/types.d.ts.map +1 -0
  99. package/dist/loop/index.d.ts +7 -0
  100. package/dist/loop/index.d.ts.map +1 -0
  101. package/dist/loop/io/index.d.ts +4 -0
  102. package/dist/loop/io/index.d.ts.map +1 -0
  103. package/dist/loop/io/linear.d.ts +142 -0
  104. package/dist/loop/io/linear.d.ts.map +1 -0
  105. package/dist/loop/io/sink.d.ts +47 -0
  106. package/dist/loop/io/sink.d.ts.map +1 -0
  107. package/dist/loop/io/source.d.ts +64 -0
  108. package/dist/loop/io/source.d.ts.map +1 -0
  109. package/dist/loop/serve.d.ts +97 -0
  110. package/dist/loop/serve.d.ts.map +1 -0
  111. package/dist/loop/state/index.d.ts +5 -0
  112. package/dist/loop/state/index.d.ts.map +1 -0
  113. package/dist/loop/state/manifest.d.ts +36 -0
  114. package/dist/loop/state/manifest.d.ts.map +1 -0
  115. package/dist/loop/state/port.d.ts +33 -0
  116. package/dist/loop/state/port.d.ts.map +1 -0
  117. package/dist/loop/state/snapshot.d.ts +15 -0
  118. package/dist/loop/state/snapshot.d.ts.map +1 -0
  119. package/dist/loop/state/sqlite.d.ts +31 -0
  120. package/dist/loop/state/sqlite.d.ts.map +1 -0
  121. package/dist/loop/termination-contract.d.ts +28 -0
  122. package/dist/loop/termination-contract.d.ts.map +1 -0
  123. package/dist/loop/termination.d.ts +33 -0
  124. package/dist/loop/termination.d.ts.map +1 -0
  125. package/dist/model/backend-config.d.ts +30 -0
  126. package/dist/model/backend-config.d.ts.map +1 -0
  127. package/dist/model/brand.d.ts +9 -0
  128. package/dist/model/brand.d.ts.map +1 -0
  129. package/dist/model/errors.d.ts +11 -0
  130. package/dist/model/errors.d.ts.map +1 -0
  131. package/dist/model/index.d.ts +8 -0
  132. package/dist/model/index.d.ts.map +1 -0
  133. package/dist/model/json-schema.d.ts +4 -0
  134. package/dist/model/json-schema.d.ts.map +1 -0
  135. package/dist/model/result.d.ts +3 -0
  136. package/dist/model/result.d.ts.map +1 -0
  137. package/dist/model/schemas.d.ts +435 -0
  138. package/dist/model/schemas.d.ts.map +1 -0
  139. package/dist/model/structured.d.ts +9 -0
  140. package/dist/model/structured.d.ts.map +1 -0
  141. package/dist/monitor/index.d.ts +139 -0
  142. package/dist/monitor/index.d.ts.map +1 -0
  143. package/dist/plan/index.d.ts +3 -0
  144. package/dist/plan/index.d.ts.map +1 -0
  145. package/dist/plan/persistent.d.ts +23 -0
  146. package/dist/plan/persistent.d.ts.map +1 -0
  147. package/dist/plan/verdict.d.ts +9 -0
  148. package/dist/plan/verdict.d.ts.map +1 -0
  149. package/dist/review/index.d.ts +4 -0
  150. package/dist/review/index.d.ts.map +1 -0
  151. package/dist/review/loop.d.ts +117 -0
  152. package/dist/review/loop.d.ts.map +1 -0
  153. package/dist/review/reviewers.d.ts +13 -0
  154. package/dist/review/reviewers.d.ts.map +1 -0
  155. package/dist/review/tool.d.ts +20 -0
  156. package/dist/review/tool.d.ts.map +1 -0
  157. package/dist/run-output/index.d.ts +112 -0
  158. package/dist/run-output/index.d.ts.map +1 -0
  159. package/dist/runner/index.d.ts +2 -0
  160. package/dist/runner/index.d.ts.map +1 -0
  161. package/dist/runner/typecheck.d.ts +23 -0
  162. package/dist/runner/typecheck.d.ts.map +1 -0
  163. package/dist/test-utils/fakes.d.ts +76 -0
  164. package/dist/test-utils/fakes.d.ts.map +1 -0
  165. package/dist/test-utils/fixtures.d.ts +4 -0
  166. package/dist/test-utils/fixtures.d.ts.map +1 -0
  167. package/dist/test-utils/index.d.ts +4 -0
  168. package/dist/test-utils/index.d.ts.map +1 -0
  169. package/dist/test-utils/tier2.d.ts +8 -0
  170. package/dist/test-utils/tier2.d.ts.map +1 -0
  171. package/dist/tools/fs.d.ts +11 -0
  172. package/dist/tools/fs.d.ts.map +1 -0
  173. package/dist/tools/git.d.ts +10 -0
  174. package/dist/tools/git.d.ts.map +1 -0
  175. package/dist/tools/github.d.ts +13 -0
  176. package/dist/tools/github.d.ts.map +1 -0
  177. package/dist/tools/index.d.ts +7 -0
  178. package/dist/tools/index.d.ts.map +1 -0
  179. package/dist/tools/linear.d.ts +117 -0
  180. package/dist/tools/linear.d.ts.map +1 -0
  181. package/dist/tools/process.d.ts +40 -0
  182. package/dist/tools/process.d.ts.map +1 -0
  183. package/dist/tools/terminal.d.ts +23 -0
  184. package/dist/tools/terminal.d.ts.map +1 -0
  185. package/package.json +89 -9
  186. package/src/backends/acp-client.ts +324 -0
  187. package/src/backends/acp-run.ts +636 -0
  188. package/src/backends/claude-run.ts +226 -0
  189. package/src/backends/claude-stream-json.ts +273 -0
  190. package/src/backends/codex-jsonl.ts +339 -0
  191. package/src/backends/codex-run.ts +177 -0
  192. package/src/backends/codex.ts +84 -0
  193. package/src/backends/conversation-config.ts +26 -0
  194. package/src/backends/index.ts +13 -0
  195. package/src/backends/opencode-run.ts +432 -0
  196. package/src/backends/opencode-sse.ts +360 -0
  197. package/src/backends/pi-rpc.ts +242 -0
  198. package/src/backends/pi-run.ts +154 -0
  199. package/src/backends/select.ts +77 -0
  200. package/src/backends/subprocess-run.ts +305 -0
  201. package/src/backends/types.ts +29 -0
  202. package/src/backends/unsupported.ts +48 -0
  203. package/src/baseline/index.ts +437 -0
  204. package/src/cli/args.ts +109 -0
  205. package/src/cli/embedded.ts +206 -0
  206. package/src/cli/main.ts +332 -0
  207. package/src/cli/version.ts +1 -0
  208. package/src/conversation/ask-user.ts +105 -0
  209. package/src/conversation/conversation.ts +109 -0
  210. package/src/conversation/harness.ts +39 -0
  211. package/src/conversation/index.ts +4 -0
  212. package/src/conversation/queue.ts +86 -0
  213. package/src/flow/accessors.ts +43 -0
  214. package/src/flow/approvals.ts +10 -0
  215. package/src/flow/args.ts +26 -0
  216. package/src/flow/context.ts +106 -0
  217. package/src/flow/index.ts +4 -0
  218. package/src/index.ts +17 -0
  219. package/src/loop/builder/index.ts +346 -0
  220. package/src/loop/builder/types.ts +131 -0
  221. package/src/loop/context/compaction.ts +175 -0
  222. package/src/loop/context/index.ts +3 -0
  223. package/src/loop/context/offload.ts +79 -0
  224. package/src/loop/context/types.ts +17 -0
  225. package/src/loop/engine/bridge.ts +61 -0
  226. package/src/loop/engine/index.ts +166 -0
  227. package/src/loop/execution.ts +453 -0
  228. package/src/loop/fanout.ts +444 -0
  229. package/src/loop/firing.ts +188 -0
  230. package/src/loop/graph/analyze.ts +128 -0
  231. package/src/loop/graph/index.ts +2 -0
  232. package/src/loop/graph/types.ts +45 -0
  233. package/src/loop/index.ts +11 -0
  234. package/src/loop/io/index.ts +3 -0
  235. package/src/loop/io/linear.ts +714 -0
  236. package/src/loop/io/sink.ts +170 -0
  237. package/src/loop/io/source.ts +255 -0
  238. package/src/loop/serve.ts +307 -0
  239. package/src/loop/state/index.ts +4 -0
  240. package/src/loop/state/manifest.ts +56 -0
  241. package/src/loop/state/port.ts +43 -0
  242. package/src/loop/state/snapshot.ts +129 -0
  243. package/src/loop/state/sqlite.ts +265 -0
  244. package/src/loop/termination-contract.ts +89 -0
  245. package/src/loop/termination.ts +156 -0
  246. package/src/model/backend-config.ts +34 -0
  247. package/src/model/brand.ts +11 -0
  248. package/src/model/errors.ts +28 -0
  249. package/src/model/index.ts +7 -0
  250. package/src/model/json-schema.ts +14 -0
  251. package/src/model/result.ts +28 -0
  252. package/src/model/schemas.ts +218 -0
  253. package/src/model/structured.ts +26 -0
  254. package/src/monitor/index.ts +367 -0
  255. package/src/plan/index.ts +2 -0
  256. package/src/plan/persistent.ts +98 -0
  257. package/src/plan/verdict.ts +3 -0
  258. package/src/review/index.ts +3 -0
  259. package/src/review/loop.ts +365 -0
  260. package/src/review/reviewers.ts +92 -0
  261. package/src/review/tool.ts +41 -0
  262. package/src/run-output/index.ts +319 -0
  263. package/src/runner/index.ts +1 -0
  264. package/src/runner/typecheck.ts +72 -0
  265. package/src/test-utils/fakes.ts +260 -0
  266. package/src/test-utils/fixtures.ts +33 -0
  267. package/src/test-utils/index.ts +3 -0
  268. package/src/test-utils/tier2.ts +12 -0
  269. package/src/tools/fs.ts +44 -0
  270. package/src/tools/git.ts +34 -0
  271. package/src/tools/github.ts +25 -0
  272. package/src/tools/index.ts +6 -0
  273. package/src/tools/linear.ts +428 -0
  274. package/src/tools/process.ts +156 -0
  275. package/src/tools/terminal.ts +116 -0
@@ -0,0 +1,444 @@
1
+ import { err, ok, type Result } from "neverthrow";
2
+
3
+ import type { JoinPolicy } from "./builder/types.ts";
4
+ import { runBoundedBranches } from "./engine/index.ts";
5
+ import type { RuntimeError, Usage } from "../model/index.ts";
6
+ import type { BranchWritableStateStore, StateHash, StateReducer, StateStore } from "./state/index.ts";
7
+
8
+ // Opt-in fan-out / fan-in combinators (spec loop-builder "Bounded fan-out and join-policy fan-in
9
+ // are opt-in combinators"; design D9; tasks 6.1–6.4). Effect-FREE by mandate (design D2 facade
10
+ // gate): all concurrency goes through the engine's plain `runBoundedBranches` seam, so no Effect
11
+ // type appears here or in the generated loop declaration. Importing nothing of these leaves the
12
+ // single-cycle loop() authoring surface from L06 unchanged.
13
+ //
14
+ // One machinery, four policies (design D9): `fanOut` COLLECTS branch results under a concurrency
15
+ // cap with an isolated state copy per branch; `fanIn` APPLIES a join policy to choose which
16
+ // successes count, then a reducer — the ONLY place branch state recombines (design D4
17
+ // StateStore.merge; never shared-mutable) — under a partial-failure policy.
18
+
19
+ /** Default per-branch summary budget: ~1.5k tokens at ~4 chars/token (task 6.3). */
20
+ const DEFAULT_SUMMARY_BUDGET_CHARS = 6000;
21
+
22
+ /**
23
+ * What an isolated branch returns: a bounded prose summary (NEVER the raw branch state, task 6.3)
24
+ * plus an optional small structured payload the reducer folds. `fanOut` truncates `summary` to the
25
+ * configured char budget so one runaway branch cannot blow the parent loop's context window.
26
+ */
27
+ export interface BranchSummary<D = unknown> {
28
+ readonly summary: string;
29
+ readonly data?: D;
30
+ }
31
+
32
+ /** A branch: pure work over an ISOLATED copy of the input state, returning a condensed summary. */
33
+ export type Branch<S, D> = (isolated: S) => Promise<BranchSummary<D>> | BranchSummary<D>;
34
+
35
+ /** A branch that threw/rejected; the message is preserved for the partial-failure report. */
36
+ export interface BranchFailure {
37
+ readonly message: string;
38
+ }
39
+
40
+ /**
41
+ * Per-branch outcome captured by `fanOut`. Failure is DATA, not a thrown error, so `fanIn`'s
42
+ * partial-failure policy can weigh it; `elapsedMs` lets the `race` policy pick the fastest success.
43
+ */
44
+ export type BranchOutcome<D = unknown> =
45
+ | { readonly index: number; readonly ok: true; readonly summary: BranchSummary<D>; readonly elapsedMs: number }
46
+ | { readonly index: number; readonly ok: false; readonly error: BranchFailure; readonly elapsedMs: number };
47
+
48
+ type SuccessOutcome<D> = Extract<BranchOutcome<D>, { ok: true }>;
49
+ type SelectableSuccess<D> = { readonly ok: true; readonly summary: BranchSummary<D>; readonly elapsedMs: number };
50
+
51
+ export interface FanOutSpec<S, D> {
52
+ /** Shared input state; each branch receives an isolated `structuredClone`, never the original. */
53
+ readonly state: S;
54
+ readonly branches: readonly Branch<S, D>[];
55
+ /** Concurrency cap (design D2/D8: in-process, ~3–10): at most this many branches run at once. */
56
+ readonly maxConcurrency: number;
57
+ /** Per-branch summary char budget; defaults to ~1.5k tokens. */
58
+ readonly summaryBudgetChars?: number;
59
+ /** Aborting interrupts in-flight branches and fails the fan-out with `cancelled`. */
60
+ readonly signal?: AbortSignal;
61
+ }
62
+
63
+ /**
64
+ * `fanOut` fails as a whole only on misconfiguration or hard interruption — never on a branch
65
+ * failure, which is captured per-branch for `fanIn`'s partial-failure policy to decide.
66
+ */
67
+ export type FanOutError =
68
+ | { readonly kind: "misconfigured"; readonly reason: string }
69
+ | { readonly kind: "cancelled"; readonly reason: string };
70
+
71
+ /** The merge reducer — the ONLY point where branch results recombine into one state. */
72
+ export type FanInReducer<S, D> = (summaries: readonly BranchSummary<D>[]) => S;
73
+
74
+ /** What to do when some branches failed. */
75
+ export type PartialFailurePolicy =
76
+ | { readonly kind: "fail-fast" } // any branch failure fails the fan-in
77
+ | { readonly kind: "tolerate"; readonly minSuccess: number }; // proceed if at least this many succeeded
78
+
79
+ export interface FanInOptions<S, D> {
80
+ readonly reducer: FanInReducer<S, D>;
81
+ /** Defaults per policy: `barrier` ⇒ fail-fast; `quorum` ⇒ tolerate(quorum); `race`/`reduce` ⇒ tolerate(1). */
82
+ readonly onPartialFailure?: PartialFailurePolicy;
83
+ /** Required for `quorum`: proceed once this many branches AGREE. */
84
+ readonly quorum?: number;
85
+ /** Agreement key for `quorum`; defaults to the branch's `data`, falling back to its `summary`. */
86
+ readonly agreeBy?: (summary: BranchSummary<D>) => unknown;
87
+ }
88
+
89
+ export type FanInError =
90
+ | {
91
+ readonly kind: "partial-failure";
92
+ readonly succeeded: number;
93
+ readonly failed: number;
94
+ readonly failures: readonly BranchFailure[];
95
+ }
96
+ | { readonly kind: "no-success"; readonly failures: readonly BranchFailure[] }
97
+ | { readonly kind: "no-quorum"; readonly required: number; readonly largestAgreement: number }
98
+ | { readonly kind: "misconfigured"; readonly reason: string };
99
+
100
+ export interface StoreBackedBranchContext {
101
+ readonly index: number;
102
+ readonly branchHash: StateHash;
103
+ }
104
+
105
+ export interface StoreBackedBranchResult<S, D = unknown> {
106
+ readonly state: S;
107
+ readonly summary?: BranchSummary<D>;
108
+ readonly usage?: Usage;
109
+ }
110
+
111
+ export type StoreBackedBranch<S, D = unknown> = (
112
+ state: S,
113
+ context: StoreBackedBranchContext,
114
+ ) => Promise<StoreBackedBranchResult<S, D>> | StoreBackedBranchResult<S, D>;
115
+
116
+ export interface StoreBackedFanOutSpec<S, D = unknown> {
117
+ readonly store: BranchWritableStateStore<S>;
118
+ readonly from: StateHash;
119
+ readonly branches: readonly StoreBackedBranch<S, D>[];
120
+ readonly maxConcurrency: number;
121
+ readonly summaryBudgetChars?: number;
122
+ readonly signal?: AbortSignal;
123
+ }
124
+
125
+ export type StoreBackedBranchOutcome<D = unknown> =
126
+ | {
127
+ readonly index: number;
128
+ readonly ok: true;
129
+ readonly branchHash: StateHash;
130
+ readonly stateHash: StateHash;
131
+ readonly summary: BranchSummary<D>;
132
+ readonly elapsedMs: number;
133
+ readonly usage?: Usage;
134
+ }
135
+ | {
136
+ readonly index: number;
137
+ readonly ok: false;
138
+ readonly branchHash?: StateHash;
139
+ readonly error: BranchFailure;
140
+ readonly elapsedMs: number;
141
+ readonly usage?: Usage;
142
+ };
143
+
144
+ export interface StoreBackedFanInOptions<S, D = unknown> {
145
+ readonly store: StateStore<S>;
146
+ readonly reducer: StateReducer<S>;
147
+ readonly onPartialFailure?: PartialFailurePolicy;
148
+ readonly quorum?: number;
149
+ readonly agreeBy?: (summary: BranchSummary<D>) => unknown;
150
+ }
151
+
152
+ /**
153
+ * Bounded fan-out (tasks 6.1, 6.3). Runs each branch over an isolated `structuredClone` of `state`
154
+ * (copy-on-fanout: no shared-mutable across branches) with at most `maxConcurrency` running at
155
+ * once, and returns every branch's outcome — successes carry a condensed summary, failures carry a
156
+ * message — for `fanIn` to combine. The whole fan-out errs only on misconfiguration or abort.
157
+ */
158
+ export async function fanOut<S, D = unknown>(
159
+ spec: FanOutSpec<S, D>,
160
+ ): Promise<Result<readonly BranchOutcome<D>[], FanOutError>> {
161
+ if (spec.branches.length === 0) {
162
+ return err({ kind: "misconfigured", reason: "fanOut requires at least one branch" });
163
+ }
164
+ if (!Number.isFinite(spec.maxConcurrency) || spec.maxConcurrency < 1) {
165
+ return err({
166
+ kind: "misconfigured",
167
+ reason: `fanOut maxConcurrency must be a finite integer >= 1 (got ${String(spec.maxConcurrency)})`,
168
+ });
169
+ }
170
+ const budget = spec.summaryBudgetChars ?? DEFAULT_SUMMARY_BUDGET_CHARS;
171
+
172
+ const thunks = spec.branches.map((branch, index) => async (): Promise<BranchOutcome<D>> => {
173
+ const isolated = structuredClone(spec.state); // copy-on-fanout — a branch sees only its own copy
174
+ const start = Date.now();
175
+ try {
176
+ const summary = condense(await branch(isolated), budget);
177
+ return { index, ok: true, summary, elapsedMs: Date.now() - start };
178
+ } catch (cause) {
179
+ const message = cause instanceof Error ? cause.message : String(cause);
180
+ return { index, ok: false, error: { message }, elapsedMs: Date.now() - start };
181
+ }
182
+ });
183
+
184
+ const collected = await runBoundedBranches(thunks, Math.floor(spec.maxConcurrency), spec.signal);
185
+ if (collected.isErr()) {
186
+ return err({ kind: "cancelled", reason: collected.error.message });
187
+ }
188
+ return ok(collected.value);
189
+ }
190
+
191
+ export async function storeBackedFanOut<S, D = unknown>(
192
+ spec: StoreBackedFanOutSpec<S, D>,
193
+ ): Promise<Result<readonly StoreBackedBranchOutcome<D>[], FanOutError>> {
194
+ if (spec.branches.length === 0) {
195
+ return err({ kind: "misconfigured", reason: "storeBackedFanOut requires at least one branch" });
196
+ }
197
+ if (!Number.isFinite(spec.maxConcurrency) || spec.maxConcurrency < 1) {
198
+ return err({
199
+ kind: "misconfigured",
200
+ reason: `storeBackedFanOut maxConcurrency must be a finite integer >= 1 (got ${String(spec.maxConcurrency)})`,
201
+ });
202
+ }
203
+ const budget = spec.summaryBudgetChars ?? DEFAULT_SUMMARY_BUDGET_CHARS;
204
+
205
+ const thunks = spec.branches.map((branch, index) => async (): Promise<StoreBackedBranchOutcome<D>> => {
206
+ const start = Date.now();
207
+ let branchHash: StateHash | undefined;
208
+ try {
209
+ const branched = await spec.store.branch(spec.from);
210
+ if (branched.isErr()) {
211
+ return branchFailure(index, start, runtimeErrorMessage(branched.error));
212
+ }
213
+ branchHash = branched.value;
214
+ const loaded = await spec.store.load(branchHash);
215
+ if (loaded.isErr()) {
216
+ return branchFailure(index, start, runtimeErrorMessage(loaded.error), branchHash);
217
+ }
218
+ const result = await branch(loaded.value, { index, branchHash });
219
+ const saved = await spec.store.saveBranch(branchHash, result.state);
220
+ if (saved.isErr()) {
221
+ return branchFailure(index, start, runtimeErrorMessage(saved.error), branchHash, result.usage);
222
+ }
223
+ return {
224
+ index,
225
+ ok: true,
226
+ branchHash,
227
+ stateHash: saved.value,
228
+ summary: condense(result.summary ?? { summary: `branch ${String(index)} completed` }, budget),
229
+ elapsedMs: Date.now() - start,
230
+ ...(result.usage === undefined ? {} : { usage: result.usage }),
231
+ };
232
+ } catch (cause) {
233
+ const message = cause instanceof Error ? cause.message : String(cause);
234
+ return branchFailure(index, start, message, branchHash);
235
+ }
236
+ });
237
+
238
+ const collected = await runBoundedBranches(thunks, Math.floor(spec.maxConcurrency), spec.signal);
239
+ if (collected.isErr()) {
240
+ return err({ kind: "cancelled", reason: collected.error.message });
241
+ }
242
+ return ok(collected.value);
243
+ }
244
+
245
+ export async function storeBackedFanIn<S, D = unknown>(
246
+ policy: JoinPolicy,
247
+ outcomes: readonly StoreBackedBranchOutcome<D>[],
248
+ options: StoreBackedFanInOptions<S, D>,
249
+ ): Promise<Result<S, FanInError | RuntimeError>> {
250
+ const successes = outcomes.filter((outcome): outcome is Extract<StoreBackedBranchOutcome<D>, { ok: true }> => outcome.ok);
251
+ const failures = outcomes.flatMap((outcome) => (outcome.ok ? [] : [outcome.error]));
252
+
253
+ const gate = applyPartialFailurePolicy(policy, successes.length, failures, options.onPartialFailure, options.quorum);
254
+ if (gate.isErr()) {
255
+ return err(gate.error);
256
+ }
257
+
258
+ const selected = selectByPolicy(policy, successes, options.quorum, options.agreeBy);
259
+ if (selected.isErr()) {
260
+ return err(selected.error);
261
+ }
262
+
263
+ const merged = await options.store.merge(
264
+ selected.value.map((outcome) => outcome.stateHash),
265
+ options.reducer,
266
+ );
267
+ if (merged.isErr()) {
268
+ return err(merged.error);
269
+ }
270
+ return ok(merged.value);
271
+ }
272
+
273
+ /**
274
+ * Join-policy fan-in (tasks 6.2, 6.4). Applies the partial-failure gate, then the join policy
275
+ * selects which successful summaries count, then the reducer — the ONLY merge point — folds them
276
+ * into one state. `barrier`/`reduce` fold every success; `race` folds the fastest; `quorum` folds
277
+ * the first group of `quorum` branches to agree.
278
+ */
279
+ export function fanIn<S, D = unknown>(
280
+ policy: JoinPolicy,
281
+ outcomes: readonly BranchOutcome<D>[],
282
+ options: FanInOptions<S, D>,
283
+ ): Result<S, FanInError> {
284
+ const successes = outcomes.filter((outcome): outcome is SuccessOutcome<D> => outcome.ok);
285
+ const failures = outcomes.flatMap((outcome) => (outcome.ok ? [] : [outcome.error]));
286
+
287
+ const gate = applyPartialFailurePolicy(policy, successes.length, failures, options.onPartialFailure, options.quorum);
288
+ if (gate.isErr()) {
289
+ return err(gate.error);
290
+ }
291
+
292
+ const selected = selectByPolicy(policy, successes, options.quorum, options.agreeBy);
293
+ if (selected.isErr()) {
294
+ return err(selected.error);
295
+ }
296
+ return ok(options.reducer(selected.value.map((outcome) => outcome.summary)));
297
+ }
298
+
299
+ /** Truncate an over-budget summary so a branch cannot return more than its share of context. */
300
+ function condense<D>(summary: BranchSummary<D>, budgetChars: number): BranchSummary<D> {
301
+ if (summary.summary.length <= budgetChars) {
302
+ return summary;
303
+ }
304
+ const truncation = "…[truncated]";
305
+ const head = summary.summary.slice(0, Math.max(0, budgetChars - truncation.length));
306
+ return { ...summary, summary: head + truncation };
307
+ }
308
+
309
+ /** Enough branches succeeded to proceed? Shared by every policy (design D9). */
310
+ function applyPartialFailurePolicy(
311
+ policy: JoinPolicy,
312
+ succeeded: number,
313
+ failures: readonly BranchFailure[],
314
+ onPartialFailure: PartialFailurePolicy | undefined,
315
+ quorum: number | undefined,
316
+ ): Result<void, FanInError> {
317
+ if (succeeded === 0) {
318
+ return err({ kind: "no-success", failures });
319
+ }
320
+ const resolved = onPartialFailure ?? defaultPartialFailure(policy, quorum);
321
+ if (resolved.kind === "fail-fast" && failures.length > 0) {
322
+ return err({ kind: "partial-failure", succeeded, failed: failures.length, failures });
323
+ }
324
+ if (resolved.kind === "tolerate" && succeeded < resolved.minSuccess) {
325
+ return err({ kind: "partial-failure", succeeded, failed: failures.length, failures });
326
+ }
327
+ return ok(undefined);
328
+ }
329
+
330
+ function defaultPartialFailure(policy: JoinPolicy, quorum: number | undefined): PartialFailurePolicy {
331
+ switch (policy) {
332
+ case "barrier":
333
+ return { kind: "fail-fast" };
334
+ case "quorum":
335
+ return { kind: "tolerate", minSuccess: quorum ?? 1 };
336
+ case "race":
337
+ case "reduce":
338
+ return { kind: "tolerate", minSuccess: 1 };
339
+ }
340
+ }
341
+
342
+ /** Which successful branches the reducer folds — guaranteed non-empty by the partial-failure gate. */
343
+ function selectByPolicy<D, T extends SelectableSuccess<D>>(
344
+ policy: JoinPolicy,
345
+ successes: readonly T[],
346
+ quorum: number | undefined,
347
+ agreeBy: ((summary: BranchSummary<D>) => unknown) | undefined,
348
+ ): Result<readonly T[], FanInError> {
349
+ switch (policy) {
350
+ case "barrier":
351
+ case "reduce":
352
+ return ok(successes);
353
+ case "race":
354
+ return ok([successes.reduce((fastest, next) => (next.elapsedMs < fastest.elapsedMs ? next : fastest))]);
355
+ case "quorum":
356
+ return selectQuorum(successes, quorum, agreeBy);
357
+ }
358
+ }
359
+
360
+ /** Self-consistency vote (design D9): the first group of `quorum` branches to agree wins. */
361
+ function selectQuorum<D, T extends SelectableSuccess<D>>(
362
+ successes: readonly T[],
363
+ quorum: number | undefined,
364
+ agreeBy: ((summary: BranchSummary<D>) => unknown) | undefined,
365
+ ): Result<readonly T[], FanInError> {
366
+ if (quorum === undefined || quorum < 1) {
367
+ return err({ kind: "misconfigured", reason: "quorum policy requires options.quorum >= 1" });
368
+ }
369
+ const keyOf = agreeBy ?? ((summary: BranchSummary<D>) => (summary.data === undefined ? summary.summary : summary.data));
370
+ const groups = new Map<string, T[]>();
371
+ let largestAgreement = 0;
372
+ for (const outcome of successes) {
373
+ const key = stableKey(keyOf(outcome.summary));
374
+ const group = groups.get(key) ?? [];
375
+ group.push(outcome);
376
+ groups.set(key, group);
377
+ largestAgreement = Math.max(largestAgreement, group.length);
378
+ if (group.length >= quorum) {
379
+ return ok(group); // proceed as soon as a quorum agrees, without waiting on the rest
380
+ }
381
+ }
382
+ return err({ kind: "no-quorum", required: quorum, largestAgreement });
383
+ }
384
+
385
+ function branchFailure<D>(
386
+ index: number,
387
+ start: number,
388
+ message: string,
389
+ branchHash?: StateHash,
390
+ usage?: Usage,
391
+ ): StoreBackedBranchOutcome<D> {
392
+ return {
393
+ index,
394
+ ok: false,
395
+ error: { message },
396
+ elapsedMs: Date.now() - start,
397
+ ...(branchHash === undefined ? {} : { branchHash }),
398
+ ...(usage === undefined ? {} : { usage }),
399
+ };
400
+ }
401
+
402
+ function runtimeErrorMessage(error: RuntimeError): string {
403
+ switch (error._tag) {
404
+ case "BackendFailed":
405
+ case "FileSystemError":
406
+ case "IoFailed":
407
+ return error.message;
408
+ case "UnsupportedFeature":
409
+ return error.reason;
410
+ case "CommandFailed":
411
+ return error.stderr || error.stdout || `${error.command} failed`;
412
+ case "TypecheckFailed":
413
+ return error.stderr || error.stdout || "typecheck failed";
414
+ case "PushRejected":
415
+ return error.stderr;
416
+ case "BranchAlreadyExists":
417
+ return `branch already exists: ${error.branch}`;
418
+ case "NothingToCommit":
419
+ return "nothing to commit";
420
+ case "StructuredOutputValidationFailed":
421
+ return error.issues.join("; ");
422
+ }
423
+ }
424
+
425
+ /** Order-independent serialization of an agreement key, so object field order never splits a vote. */
426
+ function stableKey(value: unknown): string {
427
+ // Coalesce an undefined key to null first, so JSON.stringify always yields a string to group on.
428
+ return JSON.stringify(sortKeys(value) ?? null);
429
+ }
430
+
431
+ function sortKeys(value: unknown): unknown {
432
+ if (Array.isArray(value)) {
433
+ return value.map(sortKeys);
434
+ }
435
+ if (value !== null && typeof value === "object") {
436
+ const record = value as Record<string, unknown>;
437
+ return Object.fromEntries(
438
+ Object.keys(record)
439
+ .sort()
440
+ .map((key) => [key, sortKeys(record[key])]),
441
+ );
442
+ }
443
+ return value;
444
+ }
@@ -0,0 +1,188 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Result } from "neverthrow";
3
+
4
+ import type { LoopOutcome, LoopRunError, LoopStopReason } from "./builder/index.ts";
5
+ import type { LoopDefinition } from "./serve.ts";
6
+ import { withRunReporter, type RunReporter } from "../run-output/index.ts";
7
+
8
+ export const LOOP_EVENT_ENV = "ORCA_LOOP_EVENT";
9
+
10
+ const STOP_EXIT_CODES = {
11
+ converged: 0,
12
+ unfixable: 1,
13
+ stuck: 2,
14
+ timeout: 3,
15
+ ceiling: 4,
16
+ "budget-exhausted": 5,
17
+ cancelled: 6,
18
+ } as const satisfies Record<LoopStopReason, number>;
19
+
20
+ /** What the supervisor hands the spawner for one trigger firing. */
21
+ export interface ChildSpec {
22
+ /** The loop module path or registered name the child `orcats run` resolves. */
23
+ readonly loop: string;
24
+ /** The trigger event, serialized to the child by the default spawner. */
25
+ readonly event: unknown;
26
+ }
27
+
28
+ /** A spawned ephemeral child running one loop firing; independently terminable. */
29
+ export interface ChildHandle {
30
+ /** OS-level termination of a runaway child (default `SIGKILL`). */
31
+ kill(signal?: NodeJS.Signals): void;
32
+ /** Resolves when the child exits with its code (null when terminated by signal). */
33
+ readonly exited: Promise<number | null>;
34
+ }
35
+
36
+ /** Spawns one ephemeral child per trigger firing. Injectable so tests avoid real processes. */
37
+ export type ChildSpawner = (spec: ChildSpec) => ChildHandle;
38
+
39
+ export interface ChildProcessSpec {
40
+ readonly command: string;
41
+ readonly args: readonly string[];
42
+ readonly env: NodeJS.ProcessEnv;
43
+ }
44
+
45
+ export interface ChildProcessSpecOptions {
46
+ readonly argv?: readonly string[];
47
+ readonly env?: NodeJS.ProcessEnv;
48
+ readonly execPath?: string;
49
+ }
50
+
51
+ export interface LoopFiringResult {
52
+ readonly result: Result<LoopOutcome, LoopRunError>;
53
+ readonly exitCode: number;
54
+ readonly diagnostic: string;
55
+ }
56
+
57
+ export interface LoopFiringOptions {
58
+ readonly writeDiagnostic?: (message: string) => void;
59
+ readonly reporter?: RunReporter;
60
+ }
61
+
62
+ export function createLoopChildSpec(loop: string, event: unknown): ChildSpec {
63
+ return { loop, event };
64
+ }
65
+
66
+ export function encodeLoopEvent(event: unknown): string | undefined {
67
+ return JSON.stringify(event);
68
+ }
69
+
70
+ export function decodeLoopEvent(env: NodeJS.ProcessEnv = process.env): unknown {
71
+ const raw = env[LOOP_EVENT_ENV];
72
+ if (raw === undefined) {
73
+ return undefined;
74
+ }
75
+ try {
76
+ const parsed: unknown = JSON.parse(raw);
77
+ return parsed;
78
+ } catch {
79
+ return raw;
80
+ }
81
+ }
82
+
83
+ export function buildChildProcessSpec(
84
+ spec: ChildSpec,
85
+ options: ChildProcessSpecOptions = {},
86
+ ): ChildProcessSpec {
87
+ const argv = options.argv ?? process.argv;
88
+ const entry = childEntrypoint(argv);
89
+ const args =
90
+ entry === undefined ? ["run", "--no-typecheck", spec.loop] : [entry, "run", "--no-typecheck", spec.loop];
91
+ return {
92
+ command: options.execPath ?? process.execPath,
93
+ args,
94
+ env: { ...(options.env ?? process.env), [LOOP_EVENT_ENV]: encodeLoopEvent(spec.event) },
95
+ };
96
+ }
97
+
98
+ function childEntrypoint(argv: readonly string[]): string | undefined {
99
+ const entry = argv[1];
100
+ if (entry === undefined || entry.startsWith("-") || entry === "run" || entry === "serve" || entry === "loops") {
101
+ return undefined;
102
+ }
103
+ return entry;
104
+ }
105
+
106
+ /** Default spawner: re-invoke this CLI as `orcats run --no-typecheck <loop>` in a fresh OS process. */
107
+ export const spawnLoopChild: ChildSpawner = (spec) => {
108
+ const childSpec = buildChildProcessSpec(spec);
109
+ const child = spawn(childSpec.command, [...childSpec.args], {
110
+ stdio: "inherit",
111
+ env: childSpec.env,
112
+ });
113
+ const exited = new Promise<number | null>((resolveExit, rejectExit) => {
114
+ child.on("error", rejectExit);
115
+ child.on("exit", (code) => {
116
+ resolveExit(code);
117
+ });
118
+ });
119
+ return {
120
+ kill(signal = "SIGKILL") {
121
+ child.kill(signal);
122
+ },
123
+ exited,
124
+ };
125
+ };
126
+
127
+ /** Process exit status for a stop reason: `converged` is 0, every other stop is non-zero. */
128
+ export function exitCodeForStop(reason: LoopStopReason): number {
129
+ return STOP_EXIT_CODES[reason];
130
+ }
131
+
132
+ /** Exit status for a completed run: the stop reason's code, or 70 for a build/runtime error. */
133
+ export function exitCodeForRun(result: Result<LoopOutcome, LoopRunError>): number {
134
+ return result.match(
135
+ (outcome) => exitCodeForStop(outcome.stopReason),
136
+ () => 70,
137
+ );
138
+ }
139
+
140
+ export async function runLoopFiring(
141
+ definition: LoopDefinition,
142
+ event: unknown,
143
+ options: LoopFiringOptions = {},
144
+ ): Promise<LoopFiringResult> {
145
+ options.reporter?.emit({ type: "run_started", label: definition.name });
146
+ const result = await withRunReporter(options.reporter, () => definition.run(event));
147
+ const diagnostic = formatLoopFiringDiagnostic(definition.name, result);
148
+ result.match(
149
+ (outcome) =>
150
+ options.reporter?.emit({
151
+ type: "run_finished",
152
+ label: definition.name,
153
+ status: "success",
154
+ stopReason: outcome.stopReason,
155
+ iterations: outcome.iterations,
156
+ }),
157
+ (error) =>
158
+ options.reporter?.emit({
159
+ type: "run_finished",
160
+ label: definition.name,
161
+ status: "failed",
162
+ error: describeLoopRunError(error),
163
+ }),
164
+ );
165
+ options.writeDiagnostic?.(diagnostic);
166
+ return { result, exitCode: exitCodeForRun(result), diagnostic };
167
+ }
168
+
169
+ export function formatLoopFiringDiagnostic(
170
+ name: string,
171
+ result: Result<LoopOutcome, LoopRunError>,
172
+ ): string {
173
+ return result.match(
174
+ (outcome) =>
175
+ `orcats: loop "${name}" stopped (${outcome.stopReason}) after ${String(outcome.iterations)} iteration(s)\n`,
176
+ (error) => `orcats: loop "${name}" failed: ${describeLoopRunError(error)}\n`,
177
+ );
178
+ }
179
+
180
+ export function describeLoopRunError(error: LoopRunError): string {
181
+ if ("message" in error) {
182
+ return error.message;
183
+ }
184
+ if ("reason" in error) {
185
+ return error.reason;
186
+ }
187
+ return JSON.stringify(error);
188
+ }