@bitkyc08/opencodex 2.12.0 → 2.13.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 (173) hide show
  1. package/README.md +1 -1
  2. package/gui/dist/assets/index-BHldBl6_.js +76 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/anthropic-output-schema.ts +137 -0
  6. package/src/adapters/anthropic.ts +15 -0
  7. package/src/adapters/google-antigravity-replay.ts +269 -5
  8. package/src/adapters/openai-chat.ts +25 -18
  9. package/src/adapters/openai-responses.ts +63 -17
  10. package/src/bridge.ts +11 -5
  11. package/src/claude/inbound.ts +24 -3
  12. package/src/cli/account-api.ts +2 -0
  13. package/src/cli/account-extended.ts +262 -1
  14. package/src/cli/account.ts +3 -1
  15. package/src/cli/dispatch.ts +532 -0
  16. package/src/cli/doctor.ts +26 -21
  17. package/src/cli/help.ts +9 -278
  18. package/src/cli/index.ts +45 -506
  19. package/src/cli/lab.ts +171 -65
  20. package/src/cli/registry.ts +384 -0
  21. package/src/cli/root.ts +86 -0
  22. package/src/cli/status.ts +9 -2
  23. package/src/cli/v2.ts +37 -2
  24. package/src/codex/account-label.ts +14 -1
  25. package/src/codex/account-store.ts +12 -1
  26. package/src/codex/admission.ts +2 -1
  27. package/src/codex/auth-api.ts +3 -2
  28. package/src/codex/catalog/metadata.ts +6 -4
  29. package/src/codex/catalog/parsing.ts +31 -7
  30. package/src/codex/catalog/provider-fetch.ts +2 -1
  31. package/src/codex/catalog/sync.ts +65 -11
  32. package/src/codex/features.ts +437 -31
  33. package/src/codex/history-job.ts +6 -11
  34. package/src/codex/history-provider.ts +10 -9
  35. package/src/codex/inject.ts +14 -1
  36. package/src/codex/native-profile-startup.ts +49 -3
  37. package/src/codex/native-residue.ts +14 -2
  38. package/src/codex/paths.ts +75 -1
  39. package/src/codex/reset-credit-recovery.ts +1044 -0
  40. package/src/codex/shim.ts +876 -16
  41. package/src/codex/sync.ts +27 -2
  42. package/src/config.ts +148 -10
  43. package/src/generated/compatibility-version.json +2572 -0
  44. package/src/images/loop.ts +1 -1
  45. package/src/lab/artifacts/sanitize.ts +3 -3
  46. package/src/lab/automation/budgets.ts +78 -0
  47. package/src/lab/automation/config-persistence.ts +256 -0
  48. package/src/lab/automation/constants.ts +39 -0
  49. package/src/lab/automation/cooldown.ts +103 -0
  50. package/src/lab/automation/dispatch.ts +211 -0
  51. package/src/lab/automation/index.ts +13 -0
  52. package/src/lab/automation/orchestrator.ts +480 -0
  53. package/src/lab/automation/persistence.ts +512 -0
  54. package/src/lab/automation/planner.ts +371 -0
  55. package/src/lab/automation/policy.ts +136 -0
  56. package/src/lab/automation/queue.ts +191 -0
  57. package/src/lab/automation/recovery.ts +24 -0
  58. package/src/lab/automation/route-context.ts +21 -0
  59. package/src/lab/automation/run-key.ts +44 -0
  60. package/src/lab/automation/runs-query.ts +34 -0
  61. package/src/lab/automation/types.ts +160 -0
  62. package/src/lab/conformance/types.ts +23 -0
  63. package/src/lab/events/types.ts +2 -1
  64. package/src/lab/events/validate.ts +2 -1
  65. package/src/lab/fabric/constants.ts +40 -0
  66. package/src/lab/fabric/executor.ts +492 -0
  67. package/src/lab/fabric/index.ts +80 -0
  68. package/src/lab/fabric/manifest.ts +222 -0
  69. package/src/lab/fabric/observe.ts +454 -0
  70. package/src/lab/fabric/patch.ts +79 -0
  71. package/src/lab/fabric/producer-child.ts +139 -0
  72. package/src/lab/fabric/producer-isolate.ts +276 -0
  73. package/src/lab/fabric/producer-protocol.ts +61 -0
  74. package/src/lab/fabric/scratch.ts +439 -0
  75. package/src/lab/fabric/subject.ts +106 -0
  76. package/src/lab/fabric/types.ts +134 -0
  77. package/src/lab/fabric/verifier.ts +98 -0
  78. package/src/lab/index.ts +2 -0
  79. package/src/lab/ledger/store.ts +173 -0
  80. package/src/lab/live/executor.ts +29 -4
  81. package/src/lab/live/transport.ts +3 -1
  82. package/src/lab/observe/from-conformance.ts +12 -57
  83. package/src/lab/observe/from-live.ts +9 -1
  84. package/src/lab/paths.ts +74 -9
  85. package/src/lab/projection/schema.ts +14 -1
  86. package/src/lab/projection/verdicts.ts +89 -53
  87. package/src/lab/projection/verification.ts +83 -3
  88. package/src/lab/query/catalog.ts +34 -5
  89. package/src/lab/query/freshness.ts +53 -0
  90. package/src/lab/query/index.ts +11 -0
  91. package/src/lab/query/latest-observation.ts +59 -0
  92. package/src/lab/query/passive-production.ts +159 -0
  93. package/src/lab/subject/installation-salt.ts +24 -4
  94. package/src/lab/subject/protocol-subject.ts +80 -0
  95. package/src/lab/subject/route-subject.ts +2 -1
  96. package/src/lib/fabric-task-execution-authority.ts +7 -0
  97. package/src/lib/fabric-task-host.ts +29 -0
  98. package/src/lib/lab-live-route-production.ts +130 -0
  99. package/src/lib/local-management-capability.ts +100 -0
  100. package/src/lib/server-resource-ownership.ts +71 -0
  101. package/src/lib/windows-elevation.ts +8 -1
  102. package/src/lib/windows-user-principal.ts +62 -2
  103. package/src/lib/winsw.ts +8 -2
  104. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  105. package/src/oauth/account-import/index.ts +15 -0
  106. package/src/oauth/account-import/parser.ts +83 -0
  107. package/src/oauth/account-import/registry.ts +18 -0
  108. package/src/oauth/account-import/service.ts +75 -0
  109. package/src/oauth/account-import/types.ts +91 -0
  110. package/src/oauth/anthropic.ts +12 -1
  111. package/src/oauth/callback-server.ts +8 -2
  112. package/src/oauth/chatgpt.ts +12 -1
  113. package/src/oauth/google-antigravity.ts +27 -0
  114. package/src/oauth/health.ts +12 -48
  115. package/src/oauth/index.ts +122 -0
  116. package/src/oauth/kimi.ts +16 -2
  117. package/src/oauth/nous.ts +749 -0
  118. package/src/oauth/store.ts +56 -3
  119. package/src/providers/codex-capacity.ts +1 -0
  120. package/src/providers/registry.ts +161 -16
  121. package/src/responses/custom-tool-compat.ts +182 -0
  122. package/src/responses/parser.ts +22 -0
  123. package/src/responses/reasoning-replay-cache.ts +168 -11
  124. package/src/responses/schema.ts +1 -1
  125. package/src/router.ts +5 -20
  126. package/src/routing/capability.ts +9 -3
  127. package/src/routing/compatibility/assemble.ts +159 -0
  128. package/src/routing/compatibility/behavior.ts +224 -0
  129. package/src/routing/compatibility/catalog.ts +99 -0
  130. package/src/routing/compatibility/endpoint.ts +52 -0
  131. package/src/routing/compatibility/policy.ts +181 -0
  132. package/src/routing/compatibility/reader.ts +110 -0
  133. package/src/routing/compatibility/subject.ts +191 -0
  134. package/src/routing/compatibility/types.ts +64 -0
  135. package/src/routing/compatibility/version.ts +104 -0
  136. package/src/routing/evaluator.ts +27 -32
  137. package/src/routing/profile.ts +118 -4
  138. package/src/routing/trace.ts +82 -28
  139. package/src/server/auth-cors.ts +7 -0
  140. package/src/server/background-lifecycle.ts +20 -7
  141. package/src/server/chat-completions.ts +2 -2
  142. package/src/server/claude-messages.ts +6 -3
  143. package/src/server/direct-local-http.ts +343 -0
  144. package/src/server/index.ts +80 -18
  145. package/src/server/lifecycle.ts +12 -4
  146. package/src/server/local-management-read-client.ts +90 -0
  147. package/src/server/management/agent-settings-routes.ts +27 -3
  148. package/src/server/management/lab-automation-routes.ts +206 -0
  149. package/src/server/management/lab-routes.ts +13 -0
  150. package/src/server/management/logs-usage-routes.ts +1 -0
  151. package/src/server/management/oauth-account-routes.ts +57 -1
  152. package/src/server/management/provider-routes.ts +16 -0
  153. package/src/server/management/routing-profile-routes.ts +12 -24
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/management-auth.ts +68 -3
  156. package/src/server/proxy-liveness.ts +3 -2
  157. package/src/server/request-decompress.ts +9 -3
  158. package/src/server/request-log.ts +53 -4
  159. package/src/server/responses/collaboration.ts +93 -17
  160. package/src/server/responses/compact.ts +3 -0
  161. package/src/server/responses/core.ts +202 -15
  162. package/src/server/responses/policy-fallback.ts +1 -0
  163. package/src/server/responses-custom-tool-repair.ts +280 -0
  164. package/src/server/responses-terminal-repair.ts +342 -0
  165. package/src/server/startup-action-control.ts +8 -1
  166. package/src/service.ts +278 -12
  167. package/src/types.ts +49 -3
  168. package/src/usage/expected-prices.ts +30 -0
  169. package/src/usage/log.ts +30 -4
  170. package/src/usage/summary.ts +153 -2
  171. package/src/web-search/loop.ts +1 -1
  172. package/gui/dist/assets/index-Cw1_mxO8.js +0 -70
  173. package/src/cli/internal-dispatch.ts +0 -20
@@ -0,0 +1,480 @@
1
+ import { readConfigDiagnostics } from "../../config";
2
+ import { registerCurrentServerResourceCleanup } from "../../lib/server-resource-ownership";
3
+ import { queryLabStatus } from "../query";
4
+ import { rebuildLabProjection } from "../projection/rebuild";
5
+ import { planLabAutomationRuns } from "./planner";
6
+ import {
7
+ loadLabAutomationState,
8
+ mutateLabAutomationState,
9
+ } from "./persistence";
10
+ import { loadLabAutomationConfig } from "./config-persistence";
11
+ import {
12
+ rollBudgetWindow,
13
+ runBudgetRemaining,
14
+ liveRequestBudgetRemaining,
15
+ isRunBudgetExhausted,
16
+ isLiveRequestBudgetExhausted,
17
+ } from "./budgets";
18
+ import {
19
+ canReserveScheduledCooldown,
20
+ clearCooldown,
21
+ cooldownForFailure,
22
+ setCooldown,
23
+ } from "./cooldown";
24
+ import {
25
+ enqueuePlannedRuns,
26
+ selectDispatchableRuns,
27
+ transitionRun,
28
+ trimTerminalRuns,
29
+ countRunsByState,
30
+ } from "./queue";
31
+ import { dispatchLabAutomationRun, type DispatchResult } from "./dispatch";
32
+ import { recoverLabAutomationState } from "./recovery";
33
+ import type {
34
+ AutomationDispatchDeps,
35
+ LabAutomationPolicyV1,
36
+ LabAutomationRoutesV1,
37
+ LabAutomationRunRecordV1,
38
+ LabAutomationStatusV1,
39
+ } from "./types";
40
+ import { LabAutomationError } from "./types";
41
+ import { LAB_AUTOMATION_HARD_MAX } from "./constants";
42
+ import { cancelQueuedRun } from "./queue";
43
+
44
+ type SchedulerOwner = {
45
+ timer: ReturnType<typeof setInterval>;
46
+ ownerToken: symbol;
47
+ };
48
+
49
+ type DispatchOwner = {
50
+ token: symbol;
51
+ deps: AutomationDispatchDeps;
52
+ };
53
+
54
+ const schedulerTimers = new Map<string, SchedulerOwner>();
55
+ const ticksInProgress = new Set<string>();
56
+ let shutdownRequested = false;
57
+ const dispatchDepsByConfigDir = new Map<string, DispatchOwner>();
58
+ const inFlightControllers = new Map<string, AbortController>();
59
+ const cancellingRunIds = new Set<string>();
60
+
61
+ function configKey(configDir?: string): string {
62
+ return configDir ?? "";
63
+ }
64
+
65
+ function dispatchDepsFor(configDir?: string): AutomationDispatchDeps {
66
+ return dispatchDepsByConfigDir.get(configKey(configDir))?.deps ?? {};
67
+ }
68
+
69
+ function cancellationCooldownUntil(policy: LabAutomationPolicyV1, now: number): number {
70
+ return now + Math.max(policy.failureCooldownMs, LAB_AUTOMATION_HARD_MAX.schedulerTickMs);
71
+ }
72
+
73
+ /**
74
+ * Register process-local dispatch authority for one runtime owner.
75
+ *
76
+ * The returned receipt releases only the authority that this call installed. If a same-root
77
+ * successor has already replaced it, releasing the predecessor is a no-op for the successor.
78
+ */
79
+ export function setLabAutomationDispatchDeps(deps: AutomationDispatchDeps): () => void {
80
+ const key = configKey(deps.configDir);
81
+ if (Object.keys(deps).length === 0) {
82
+ dispatchDepsByConfigDir.delete(key);
83
+ return () => {};
84
+ }
85
+
86
+ const token = Symbol("lab-automation-runtime-owner");
87
+ dispatchDepsByConfigDir.set(key, { token, deps });
88
+ const existingScheduler = schedulerTimers.get(key);
89
+ if (existingScheduler) existingScheduler.ownerToken = token;
90
+
91
+ let released = false;
92
+ let detachServerCleanup = () => {};
93
+ const release = () => {
94
+ if (released) return;
95
+ released = true;
96
+ detachServerCleanup();
97
+ const current = dispatchDepsByConfigDir.get(key);
98
+ if (current?.token !== token) return;
99
+ dispatchDepsByConfigDir.delete(key);
100
+ const scheduler = schedulerTimers.get(key);
101
+ if (scheduler?.ownerToken === token) {
102
+ clearInterval(scheduler.timer);
103
+ schedulerTimers.delete(key);
104
+ }
105
+ };
106
+ detachServerCleanup = registerCurrentServerResourceCleanup(release);
107
+ return release;
108
+ }
109
+
110
+ export function isLabAutomationSchedulerRunning(configDir?: string): boolean {
111
+ return schedulerTimers.has(configKey(configDir));
112
+ }
113
+
114
+ export function requestLabAutomationShutdown(): void {
115
+ shutdownRequested = true;
116
+ for (const controller of inFlightControllers.values()) {
117
+ controller.abort(new Error("cancelled"));
118
+ }
119
+ }
120
+
121
+ export function buildLabAutomationStatus(configDir?: string): LabAutomationStatusV1 {
122
+ const { policy, routes } = loadLabAutomationConfig(configDir);
123
+ const state = loadLabAutomationState(configDir);
124
+ const now = Date.now();
125
+ const hourAgo = now - LAB_AUTOMATION_HARD_MAX.budgetWindowMs;
126
+ const completedLastHour = state.runs.filter((row) => row.state === "completed" && (row.completedAt ?? 0) >= hourAgo).length;
127
+ const blockedLastHour = state.runs.filter((row) => row.state === "blocked" && (row.completedAt ?? 0) >= hourAgo).length;
128
+ return {
129
+ policy,
130
+ routes,
131
+ counters: {
132
+ queued: countRunsByState(state, "queued"),
133
+ running: countRunsByState(state, "running"),
134
+ completedLastHour,
135
+ blockedLastHour,
136
+ remainingRunBudget: runBudgetRemaining(policy, state, now),
137
+ remainingLiveRequestBudget: liveRequestBudgetRemaining(policy, state, now),
138
+ },
139
+ schedulerRunning: isLabAutomationSchedulerRunning(configDir),
140
+ };
141
+ }
142
+
143
+ function scheduledEligibilityCode(
144
+ policy: LabAutomationPolicyV1,
145
+ routes: LabAutomationRoutesV1,
146
+ run: LabAutomationRunRecordV1,
147
+ ): string | null {
148
+ if (run.trigger !== "scheduled") return null;
149
+ if (!policy.enabled) return "automation_disabled";
150
+ switch (run.evidenceLayer) {
151
+ case "protocol_conformance":
152
+ return policy.layers.protocolConformance ? null : "layer_disabled";
153
+ case "live_route_compatibility": {
154
+ if (!policy.layers.liveRouteCompatibility) return "layer_disabled";
155
+ if (!run.providerName || !run.modelId) return "route_ineligible";
156
+ const enrolled = routes.routes.some((route) =>
157
+ route.providerName === run.providerName && route.modelId === run.modelId
158
+ );
159
+ return enrolled ? null : "route_ineligible";
160
+ }
161
+ case "task_effectiveness":
162
+ if (!policy.layers.taskEffectiveness) return "layer_disabled";
163
+ return policy.taskEffectivenessBackgroundEnabled ? null : "task_background_disabled";
164
+ }
165
+ }
166
+
167
+ function reconcileQueuedState(
168
+ state: ReturnType<typeof loadLabAutomationState>,
169
+ policy: LabAutomationPolicyV1,
170
+ routes: LabAutomationRoutesV1,
171
+ now: number,
172
+ ): ReturnType<typeof loadLabAutomationState> {
173
+ let next = state;
174
+ for (const run of state.runs) {
175
+ if (run.state !== "queued" || run.trigger !== "scheduled") continue;
176
+ const code = scheduledEligibilityCode(policy, routes, run);
177
+ if (code) next = transitionRun(next, run.runId, "cancelled", now, code);
178
+ }
179
+ return next;
180
+ }
181
+
182
+ /** Apply current policy/route enrollment to already queued scheduled work without executing it. */
183
+ export function reconcileLabAutomationQueue(configDir?: string): void {
184
+ const { policy, routes } = loadLabAutomationConfig(configDir);
185
+ const now = Date.now();
186
+ mutateLabAutomationState(configDir, (state) => ({
187
+ state: trimTerminalRuns(reconcileQueuedState(state, policy, routes, now), now),
188
+ value: undefined,
189
+ }));
190
+ }
191
+
192
+ function loadPlannerConfig(configDir?: string): import("../../types").OcxConfig | undefined {
193
+ const runtimeDeps = dispatchDepsFor(configDir);
194
+ try {
195
+ return runtimeDeps.loadConfig?.() ?? readConfigDiagnostics().config;
196
+ } catch {
197
+ return undefined;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Scheduled planning is projection-backed. Rebuild once when the disposable SQLite projection
203
+ * is missing/incompatible; if replay/rebuild cannot establish a readable projection, fail closed
204
+ * instead of treating every scenario as missing and generating provider traffic.
205
+ */
206
+ function ensureAutomationProjection(configDir?: string): boolean {
207
+ const status = queryLabStatus(configDir);
208
+ if (status.projectionAvailable) return true;
209
+ try {
210
+ rebuildLabProjection(configDir);
211
+ } catch {
212
+ return false;
213
+ }
214
+ return queryLabStatus(configDir).projectionAvailable;
215
+ }
216
+
217
+ function claimNextRun(
218
+ policy: LabAutomationPolicyV1,
219
+ routes: LabAutomationRoutesV1,
220
+ planned: ReturnType<typeof planLabAutomationRuns>,
221
+ enqueuePlan: boolean,
222
+ configDir: string | undefined,
223
+ now: number,
224
+ manualRunId?: string,
225
+ ): LabAutomationRunRecordV1 | null {
226
+ return mutateLabAutomationState(configDir, (loaded) => {
227
+ let state = rollBudgetWindow(loaded, now);
228
+ if (policy.enabled && manualRunId === undefined && enqueuePlan) {
229
+ state = enqueuePlannedRuns(state, planned, "scheduled", now);
230
+ }
231
+ state = reconcileQueuedState(state, policy, routes, now);
232
+ if (isRunBudgetExhausted(policy, state, now)) {
233
+ return { state: trimTerminalRuns(state, now), value: null };
234
+ }
235
+ const predicate = (run: LabAutomationRunRecordV1): boolean => {
236
+ if (manualRunId !== undefined) return run.runId === manualRunId && run.trigger === "manual";
237
+ return policy.enabled && run.trigger === "scheduled";
238
+ };
239
+ const dispatchable = selectDispatchableRuns(policy, state, now, (run) => {
240
+ if (!predicate(run)) return false;
241
+ if (run.trigger === "scheduled" && !canReserveScheduledCooldown(state, run.runKey, now)) {
242
+ return false;
243
+ }
244
+ if (run.evidenceLayer === "live_route_compatibility" && isLiveRequestBudgetExhausted(policy, state, now)) {
245
+ return false;
246
+ }
247
+ return scheduledEligibilityCode(policy, routes, run) === null;
248
+ });
249
+ const selected = dispatchable[0];
250
+ if (!selected) return { state: trimTerminalRuns(state, now), value: null };
251
+ state = transitionRun(state, selected.runId, "running", now);
252
+ const running = state.runs.find((run) => run.runId === selected.runId) ?? null;
253
+ return { state: trimTerminalRuns(state, now), value: running };
254
+ });
255
+ }
256
+
257
+ function finalizeRun(
258
+ run: LabAutomationRunRecordV1,
259
+ policy: LabAutomationPolicyV1,
260
+ configDir: string | undefined,
261
+ result: DispatchResult | null,
262
+ error: unknown,
263
+ cancelled: boolean,
264
+ ): void {
265
+ const completedAt = Date.now();
266
+ mutateLabAutomationState(configDir, (state) => {
267
+ const current = state.runs.find((row) => row.runId === run.runId);
268
+ if (!current || current.state !== "running") {
269
+ return { state: trimTerminalRuns(state, completedAt), value: undefined };
270
+ }
271
+ let next = state;
272
+ if (cancelled || (error instanceof LabAutomationError && error.code === "cancelled")) {
273
+ next = transitionRun(next, run.runId, "cancelled", completedAt, "cancelled");
274
+ if (run.trigger === "scheduled") {
275
+ next = setCooldown(
276
+ next,
277
+ run.runKey,
278
+ cancellationCooldownUntil(policy, completedAt),
279
+ completedAt,
280
+ );
281
+ }
282
+ return { state: trimTerminalRuns(next, completedAt), value: undefined };
283
+ }
284
+ if (result) {
285
+ next = transitionRun(next, run.runId, result.terminalState, completedAt, result.terminalCode);
286
+ if (result.terminalState === "completed") {
287
+ next = clearCooldown(next, run.runKey);
288
+ } else {
289
+ next = setCooldown(
290
+ next,
291
+ run.runKey,
292
+ cooldownForFailure(policy, result.cooldownCode, completedAt),
293
+ completedAt,
294
+ );
295
+ }
296
+ return { state: trimTerminalRuns(next, completedAt), value: undefined };
297
+ }
298
+ const terminalCode = error instanceof LabAutomationError ? error.code : "dispatch_failure";
299
+ next = transitionRun(next, run.runId, "failed", completedAt, terminalCode);
300
+ next = setCooldown(
301
+ next,
302
+ run.runKey,
303
+ cooldownForFailure(policy, "harness_failure", completedAt),
304
+ completedAt,
305
+ );
306
+ return { state: trimTerminalRuns(next, completedAt), value: undefined };
307
+ });
308
+ }
309
+
310
+ async function runDispatchBatch(
311
+ configDir?: string,
312
+ options: { manualRunId?: string; abortSignal?: AbortSignal } = {},
313
+ ): Promise<void> {
314
+ if (shutdownRequested) return;
315
+ const initialConfig = loadLabAutomationConfig(configDir);
316
+ const initialPolicy = initialConfig.policy;
317
+ const initialRoutes = initialConfig.routes;
318
+ if (initialPolicy.enabled && options.manualRunId === undefined && !ensureAutomationProjection(configDir)) {
319
+ reconcileLabAutomationQueue(configDir);
320
+ return;
321
+ }
322
+ const config = loadPlannerConfig(configDir);
323
+ const snapshot = loadLabAutomationState(configDir);
324
+ const planned = initialPolicy.enabled && options.manualRunId === undefined
325
+ ? planLabAutomationRuns({
326
+ policy: initialPolicy,
327
+ routes: initialRoutes,
328
+ state: snapshot,
329
+ now: Date.now(),
330
+ config,
331
+ configDir,
332
+ })
333
+ : [];
334
+ let enqueuePlan = true;
335
+ // Dispatch is intentionally sequential inside one tick. The concurrency fields still bound
336
+ // atomic selection against already-running work and future parallel workers/processes.
337
+ const maxDispatches = options.manualRunId ? 1 : Math.max(1, initialPolicy.maxConcurrentRuns);
338
+ for (let dispatched = 0; dispatched < maxDispatches; dispatched += 1) {
339
+ if (shutdownRequested) break;
340
+ const { policy, routes } = loadLabAutomationConfig(configDir);
341
+ const run = claimNextRun(
342
+ policy,
343
+ routes,
344
+ planned,
345
+ enqueuePlan,
346
+ configDir,
347
+ Date.now(),
348
+ options.manualRunId,
349
+ );
350
+ enqueuePlan = false;
351
+ if (!run) break;
352
+ const controller = new AbortController();
353
+ const abortFromCaller = () => controller.abort(options.abortSignal?.reason ?? new Error("cancelled"));
354
+ if (options.abortSignal?.aborted) abortFromCaller();
355
+ else options.abortSignal?.addEventListener("abort", abortFromCaller, { once: true });
356
+ inFlightControllers.set(run.runId, controller);
357
+ let result: DispatchResult | null = null;
358
+ let error: unknown;
359
+ try {
360
+ const runtimeDeps = dispatchDepsFor(configDir);
361
+ const deps: AutomationDispatchDeps = {
362
+ ...runtimeDeps,
363
+ configDir,
364
+ loadConfig: runtimeDeps.loadConfig ?? (() => readConfigDiagnostics().config),
365
+ abortSignal: controller.signal,
366
+ enforceRunIdentity: true,
367
+ };
368
+ result = await dispatchLabAutomationRun(run, deps);
369
+ } catch (caught) {
370
+ error = caught;
371
+ } finally {
372
+ options.abortSignal?.removeEventListener("abort", abortFromCaller);
373
+ const cancelled = cancellingRunIds.has(run.runId) || controller.signal.aborted;
374
+ finalizeRun(run, policy, configDir, result, error, cancelled);
375
+ inFlightControllers.delete(run.runId);
376
+ cancellingRunIds.delete(run.runId);
377
+ }
378
+ if (options.manualRunId !== undefined) break;
379
+ }
380
+ }
381
+
382
+ /** Single bounded scheduler tick — plan, enqueue, dispatch within concurrency limits. */
383
+ export async function runLabAutomationTick(configDir?: string): Promise<void> {
384
+ const key = configKey(configDir);
385
+ if (ticksInProgress.has(key)) return;
386
+ ticksInProgress.add(key);
387
+ try {
388
+ await runDispatchBatch(configDir);
389
+ } finally {
390
+ ticksInProgress.delete(key);
391
+ }
392
+ }
393
+
394
+ export function startLabAutomationScheduler(configDir?: string): void {
395
+ const key = configKey(configDir);
396
+ const currentOwner = dispatchDepsByConfigDir.get(key)?.token;
397
+ const existing = schedulerTimers.get(key);
398
+ if (existing) {
399
+ if (currentOwner) existing.ownerToken = currentOwner;
400
+ return;
401
+ }
402
+ shutdownRequested = false;
403
+ const { policy, routes } = loadLabAutomationConfig(configDir);
404
+ const now = Date.now();
405
+ mutateLabAutomationState(configDir, (state) => {
406
+ let next = recoverLabAutomationState(policy, state, now);
407
+ next = reconcileQueuedState(next, policy, routes, now);
408
+ return { state: trimTerminalRuns(next, now), value: undefined };
409
+ });
410
+ const timer = setInterval(() => {
411
+ void runLabAutomationTick(configDir);
412
+ }, LAB_AUTOMATION_HARD_MAX.schedulerTickMs);
413
+ timer.unref?.();
414
+ schedulerTimers.set(key, {
415
+ timer,
416
+ ownerToken: currentOwner ?? Symbol("lab-automation-manual-scheduler"),
417
+ });
418
+ }
419
+
420
+ /** Stop periodic scheduling only. Process shutdown is a separate explicit signal. */
421
+ export function stopLabAutomationScheduler(configDir?: string): void {
422
+ if (configDir !== undefined) {
423
+ const key = configKey(configDir);
424
+ const scheduler = schedulerTimers.get(key);
425
+ if (scheduler) clearInterval(scheduler.timer);
426
+ schedulerTimers.delete(key);
427
+ return;
428
+ }
429
+ for (const scheduler of schedulerTimers.values()) clearInterval(scheduler.timer);
430
+ schedulerTimers.clear();
431
+ }
432
+
433
+ /** Test-only reset of scheduler globals between isolated automation tests. */
434
+ export function resetLabAutomationSchedulerStateForTests(): void {
435
+ stopLabAutomationScheduler();
436
+ shutdownRequested = false;
437
+ ticksInProgress.clear();
438
+ dispatchDepsByConfigDir.clear();
439
+ inFlightControllers.clear();
440
+ cancellingRunIds.clear();
441
+ }
442
+
443
+ export async function enqueueManualLabRun(
444
+ planned: import("./types").PlannedLabRunV1,
445
+ configDir?: string,
446
+ abortSignal?: AbortSignal,
447
+ ): Promise<LabAutomationRunRecordV1 | null> {
448
+ const now = Date.now();
449
+ const created = mutateLabAutomationState(configDir, (state) => {
450
+ const next = enqueuePlannedRuns(state, [planned], "manual", now);
451
+ const run = next.runs.find((row) =>
452
+ row.runKey === planned.runKey && row.trigger === "manual" && row.state === "queued"
453
+ ) ?? null;
454
+ return { state: next, value: run };
455
+ });
456
+ if (!created) return null;
457
+ // Manual execution is independent of automation enablement/layer toggles.
458
+ await runDispatchBatch(configDir, { manualRunId: created.runId, abortSignal });
459
+ return loadLabAutomationState(configDir).runs.find((row) => row.runId === created.runId) ?? created;
460
+ }
461
+
462
+ export function cancelLabAutomationRun(runId: string, configDir?: string): boolean {
463
+ const controller = inFlightControllers.get(runId);
464
+ if (controller) {
465
+ cancellingRunIds.add(runId);
466
+ controller.abort(new Error("cancelled"));
467
+ return true;
468
+ }
469
+ const policy = loadLabAutomationConfig(configDir).policy;
470
+ const now = Date.now();
471
+ return mutateLabAutomationState(configDir, (state) => {
472
+ const run = state.runs.find((row) => row.runId === runId);
473
+ if (!run || run.state !== "queued") return { state, value: false };
474
+ let next = cancelQueuedRun(state, runId, now);
475
+ if (run.trigger === "scheduled") {
476
+ next = setCooldown(next, run.runKey, cancellationCooldownUntil(policy, now), now);
477
+ }
478
+ return { state: next, value: true };
479
+ });
480
+ }