@orc-brain/core 1.0.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 (126) hide show
  1. package/LICENSE +21 -0
  2. package/dist/autoLoop.d.ts +70 -0
  3. package/dist/autoLoop.d.ts.map +1 -0
  4. package/dist/autoLoop.js +178 -0
  5. package/dist/autoLoop.js.map +1 -0
  6. package/dist/backpressure.d.ts +38 -0
  7. package/dist/backpressure.d.ts.map +1 -0
  8. package/dist/backpressure.js +103 -0
  9. package/dist/backpressure.js.map +1 -0
  10. package/dist/budgetTracker.d.ts +53 -0
  11. package/dist/budgetTracker.d.ts.map +1 -0
  12. package/dist/budgetTracker.js +93 -0
  13. package/dist/budgetTracker.js.map +1 -0
  14. package/dist/config.d.ts +9 -0
  15. package/dist/config.d.ts.map +1 -0
  16. package/dist/config.js +107 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/escalation.d.ts +43 -0
  19. package/dist/escalation.d.ts.map +1 -0
  20. package/dist/escalation.js +81 -0
  21. package/dist/escalation.js.map +1 -0
  22. package/dist/eventBus.d.ts +30 -0
  23. package/dist/eventBus.d.ts.map +1 -0
  24. package/dist/eventBus.js +42 -0
  25. package/dist/eventBus.js.map +1 -0
  26. package/dist/goalJudge.d.ts +92 -0
  27. package/dist/goalJudge.d.ts.map +1 -0
  28. package/dist/goalJudge.js +254 -0
  29. package/dist/goalJudge.js.map +1 -0
  30. package/dist/index.d.ts +30 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +30 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/modelRouter.d.ts +40 -0
  35. package/dist/modelRouter.d.ts.map +1 -0
  36. package/dist/modelRouter.js +114 -0
  37. package/dist/modelRouter.js.map +1 -0
  38. package/dist/orchestrator.d.ts +242 -0
  39. package/dist/orchestrator.d.ts.map +1 -0
  40. package/dist/orchestrator.js +975 -0
  41. package/dist/orchestrator.js.map +1 -0
  42. package/dist/pacing.d.ts +34 -0
  43. package/dist/pacing.d.ts.map +1 -0
  44. package/dist/pacing.js +49 -0
  45. package/dist/pacing.js.map +1 -0
  46. package/dist/planValidation.d.ts +24 -0
  47. package/dist/planValidation.d.ts.map +1 -0
  48. package/dist/planValidation.js +134 -0
  49. package/dist/planValidation.js.map +1 -0
  50. package/dist/planner.d.ts +70 -0
  51. package/dist/planner.d.ts.map +1 -0
  52. package/dist/planner.js +210 -0
  53. package/dist/planner.js.map +1 -0
  54. package/dist/plugins/host.d.ts +39 -0
  55. package/dist/plugins/host.d.ts.map +1 -0
  56. package/dist/plugins/host.js +109 -0
  57. package/dist/plugins/host.js.map +1 -0
  58. package/dist/plugins/registry.d.ts +71 -0
  59. package/dist/plugins/registry.d.ts.map +1 -0
  60. package/dist/plugins/registry.js +269 -0
  61. package/dist/plugins/registry.js.map +1 -0
  62. package/dist/plugins/secrets.d.ts +30 -0
  63. package/dist/plugins/secrets.d.ts.map +1 -0
  64. package/dist/plugins/secrets.js +99 -0
  65. package/dist/plugins/secrets.js.map +1 -0
  66. package/dist/preflight.d.ts +38 -0
  67. package/dist/preflight.d.ts.map +1 -0
  68. package/dist/preflight.js +108 -0
  69. package/dist/preflight.js.map +1 -0
  70. package/dist/reporting.d.ts +41 -0
  71. package/dist/reporting.d.ts.map +1 -0
  72. package/dist/reporting.js +229 -0
  73. package/dist/reporting.js.map +1 -0
  74. package/dist/safety/denyRules.d.ts +57 -0
  75. package/dist/safety/denyRules.d.ts.map +1 -0
  76. package/dist/safety/denyRules.js +498 -0
  77. package/dist/safety/denyRules.js.map +1 -0
  78. package/dist/safety/envClassifier.d.ts +45 -0
  79. package/dist/safety/envClassifier.d.ts.map +1 -0
  80. package/dist/safety/envClassifier.js +99 -0
  81. package/dist/safety/envClassifier.js.map +1 -0
  82. package/dist/safety/index.d.ts +83 -0
  83. package/dist/safety/index.d.ts.map +1 -0
  84. package/dist/safety/index.js +184 -0
  85. package/dist/safety/index.js.map +1 -0
  86. package/dist/safety/limitSignals.d.ts +36 -0
  87. package/dist/safety/limitSignals.d.ts.map +1 -0
  88. package/dist/safety/limitSignals.js +79 -0
  89. package/dist/safety/limitSignals.js.map +1 -0
  90. package/dist/safety/paths.d.ts +32 -0
  91. package/dist/safety/paths.d.ts.map +1 -0
  92. package/dist/safety/paths.js +102 -0
  93. package/dist/safety/paths.js.map +1 -0
  94. package/dist/safety/redact.d.ts +17 -0
  95. package/dist/safety/redact.d.ts.map +1 -0
  96. package/dist/safety/redact.js +85 -0
  97. package/dist/safety/redact.js.map +1 -0
  98. package/dist/spawnEnv.d.ts +18 -0
  99. package/dist/spawnEnv.d.ts.map +1 -0
  100. package/dist/spawnEnv.js +43 -0
  101. package/dist/spawnEnv.js.map +1 -0
  102. package/dist/store/auditLog.d.ts +28 -0
  103. package/dist/store/auditLog.d.ts.map +1 -0
  104. package/dist/store/auditLog.js +48 -0
  105. package/dist/store/auditLog.js.map +1 -0
  106. package/dist/store/index.d.ts +140 -0
  107. package/dist/store/index.d.ts.map +1 -0
  108. package/dist/store/index.js +648 -0
  109. package/dist/store/index.js.map +1 -0
  110. package/dist/store/schema.d.ts +17 -0
  111. package/dist/store/schema.d.ts.map +1 -0
  112. package/dist/store/schema.js +197 -0
  113. package/dist/store/schema.js.map +1 -0
  114. package/dist/system.d.ts +69 -0
  115. package/dist/system.d.ts.map +1 -0
  116. package/dist/system.js +135 -0
  117. package/dist/system.js.map +1 -0
  118. package/dist/workerManager.d.ts +80 -0
  119. package/dist/workerManager.d.ts.map +1 -0
  120. package/dist/workerManager.js +280 -0
  121. package/dist/workerManager.js.map +1 -0
  122. package/dist/worktrees.d.ts +74 -0
  123. package/dist/worktrees.d.ts.map +1 -0
  124. package/dist/worktrees.js +210 -0
  125. package/dist/worktrees.js.map +1 -0
  126. package/package.json +48 -0
@@ -0,0 +1,975 @@
1
+ /**
2
+ * Orchestrator core (§3, §5). Owns the Goal, the Scope DAG, and the Run state
3
+ * machine. Runs the dispatch loop: on each tick, take ready tasks (dependencies
4
+ * met, scope approved, budget available, concurrency slot free), consult the
5
+ * Model Router, and hand a fully-specified WorkerSpec to the Worker Manager.
6
+ *
7
+ * Phase 2 (§15): Planner-driven DAG materialization + plan approval,
8
+ * pause/resume with session persistence, and bounded retries land here on top
9
+ * of the Phase 1 safety-wired dispatch loop.
10
+ */
11
+ import { execFileSync } from "node:child_process";
12
+ import { detectLimitSignal } from "./safety/limitSignals.js";
13
+ import { validatePlan } from "./planValidation.js";
14
+ import { classifyEnvironment } from "./safety/envClassifier.js";
15
+ import { perTaskBudgetUsd } from "./budgetTracker.js";
16
+ import { routeModel } from "./modelRouter.js";
17
+ import { DispatchPacer } from "./pacing.js";
18
+ /** Continuation prompt sent when resuming an interrupted task (§5). */
19
+ const RESUME_PROMPT = "You were interrupted. First verify the current workspace state, then " +
20
+ "continue the task from where it left off.";
21
+ /** Appended to worker prompts in worktree scopes (spec 002 §R9). */
22
+ const WORKTREE_PROMPT_SUFFIX = "\n\nYou are working in a dedicated git worktree on a scope branch. " +
23
+ "Commit your changes on the current branch as you complete them. " +
24
+ "Do not switch branches, merge, or push.";
25
+ /** Reads the current git branch of a directory, or undefined if not a repo. */
26
+ function gitBranch(cwd) {
27
+ try {
28
+ return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
29
+ cwd,
30
+ encoding: "utf8",
31
+ stdio: ["ignore", "pipe", "ignore"],
32
+ }).trim();
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ /** The orchestration core. In-proc methods are invoked by the API layer. */
39
+ export class Orchestrator {
40
+ deps;
41
+ running = new Map();
42
+ /** Run each in-flight task belongs to: per-run counts + pause/panic scoping. */
43
+ runOfTask = new Map();
44
+ /** Tasks blocked by an escalation, so a settling worker isn't retried (§8.5). */
45
+ blocked = new Set();
46
+ /** Set on shutdown so late worker callbacks don't touch a closed store. */
47
+ stopped = false;
48
+ /** Autonomous controller, registered when auto-loop is enabled (§3.1). */
49
+ autoLoop;
50
+ /** Tasks-per-hour throttle, shared across runs (spec 002 §R14). */
51
+ pacer;
52
+ /** Last engaged pacing gate, so `pacing.hold` is edge-triggered (§R16). */
53
+ pacingHold = null;
54
+ constructor(deps) {
55
+ this.deps = deps;
56
+ this.pacer = new DispatchPacer(deps.config.budget.max_tasks_per_hour);
57
+ // The escalation manager blocks a task; we interrupt its worker here.
58
+ this.deps.escalation.onBlock = (taskId) => this.blockTask(taskId);
59
+ // When backpressure lifts, re-kick the dispatch loop (§7.4).
60
+ this.deps.backpressure.onClear = (runId) => void this.tick(runId);
61
+ }
62
+ /** Number of in-flight workers belonging to one run. */
63
+ runningCountFor(runId) {
64
+ let n = 0;
65
+ for (const id of this.runOfTask.values())
66
+ if (id === runId)
67
+ n++;
68
+ return n;
69
+ }
70
+ /** In-flight handles belonging to one run (pause/panic never cross runs). */
71
+ handlesFor(runId) {
72
+ const handles = [];
73
+ for (const [taskId, handle] of this.running) {
74
+ if (this.runOfTask.get(taskId) === runId)
75
+ handles.push(handle);
76
+ }
77
+ return handles;
78
+ }
79
+ /** Publishes `pacing.hold` once per engagement (spec 002 §R16). */
80
+ emitPacingHold(runId, reason, resumeAt) {
81
+ if (this.pacingHold === reason)
82
+ return;
83
+ this.pacingHold = reason;
84
+ this.deps.bus.publish({
85
+ type: "pacing.hold",
86
+ run_id: runId,
87
+ payload: { reason, resume_at: resumeAt?.toISOString() },
88
+ });
89
+ }
90
+ /** Interrupts the worker of a task the escalation manager just blocked (§8.5). */
91
+ blockTask(taskId) {
92
+ this.blocked.add(taskId);
93
+ const handle = this.running.get(taskId);
94
+ if (handle)
95
+ void handle.interrupt();
96
+ }
97
+ /**
98
+ * Applies an operator resolution to a blocked escalation (§8.5): deny-&-instruct
99
+ * and approve-once re-queue the task (with guidance / a single-use exemption),
100
+ * skip marks it skipped. Then the dispatch loop is re-kicked.
101
+ */
102
+ resolveEscalation(escalationId, action, message) {
103
+ const esc = this.deps.store.getEscalation(escalationId);
104
+ if (!esc || esc.status !== "open")
105
+ return;
106
+ this.deps.store.resolveEscalation(escalationId, action, message ?? null);
107
+ this.deps.escalation.clearTask(esc.task_id);
108
+ this.blocked.delete(esc.task_id);
109
+ const kind = action === "approve_once" ? "exemption" : "state_change";
110
+ this.deps.audit.record({
111
+ ts: new Date().toISOString(),
112
+ run_id: esc.run_id,
113
+ task_id: esc.task_id,
114
+ session_id: null,
115
+ kind,
116
+ tool_name: esc.tool_name,
117
+ tool_input_hash: null,
118
+ tool_input: null,
119
+ decision: action,
120
+ rule_id: esc.rule_id,
121
+ detail: { message: message ?? null },
122
+ });
123
+ if (action === "skip_task") {
124
+ this.deps.store.updateTask(esc.task_id, { status: "skipped" });
125
+ this.emitTaskState(esc.run_id, esc.task_id, "skipped", "operator skip");
126
+ }
127
+ else {
128
+ // Re-queue with a fresh attempt; a captured session resumes with guidance.
129
+ this.deps.store.updateTask(esc.task_id, {
130
+ status: "queued",
131
+ error: null,
132
+ });
133
+ this.emitTaskState(esc.run_id, esc.task_id, "queued", `resolved: ${action}`);
134
+ }
135
+ void this.tick(esc.run_id);
136
+ }
137
+ // --- Entity commands (thin proxies over the store) -----------------------
138
+ createGoal(input) {
139
+ return this.deps.store.createGoal(input);
140
+ }
141
+ /**
142
+ * Feature flow (spec 002 §R4, spec 003 §R4): creates a goal under a project
143
+ * and immediately kicks planning, fire-and-forget. The single entrypoint for
144
+ * `POST /api/projects/:id/goals` and plugin task imports, so the two paths
145
+ * cannot drift. A planner failure drops the goal back to draft.
146
+ */
147
+ createFeatureGoal(project, input) {
148
+ const objective = input.objective.trim();
149
+ if (!objective)
150
+ throw new Error("createFeatureGoal: objective required");
151
+ const title = input.title?.trim() ||
152
+ (objective.length > 80 ? objective.slice(0, 77) + "…" : objective);
153
+ const goal = this.deps.store.createGoal({
154
+ title,
155
+ objective,
156
+ success_criteria: [],
157
+ constraints: [],
158
+ out_of_scope: [],
159
+ project_id: project.id,
160
+ repo_root: project.repo_root,
161
+ external_ref: input.external_ref ?? null,
162
+ });
163
+ // Fire-and-forget: planning is minutes-long; callers poll goal status.
164
+ void this.planGoal(goal.id).catch((err) => {
165
+ if (this.stopped)
166
+ return;
167
+ this.deps.store.updateGoalStatus(goal.id, "draft");
168
+ this.deps.audit.record({
169
+ ts: new Date().toISOString(),
170
+ run_id: null,
171
+ task_id: null,
172
+ session_id: null,
173
+ kind: "state_change",
174
+ tool_name: null,
175
+ tool_input_hash: null,
176
+ tool_input: null,
177
+ decision: null,
178
+ rule_id: null,
179
+ detail: {
180
+ action: "feature_planning_failed",
181
+ goal_id: goal.id,
182
+ error: err instanceof Error ? err.message : String(err),
183
+ },
184
+ });
185
+ });
186
+ return goal;
187
+ }
188
+ createScope(input) {
189
+ return this.deps.store.createScope(input);
190
+ }
191
+ createTask(input) {
192
+ return this.deps.store.createTask(input);
193
+ }
194
+ /** Approves a scope so its tasks become dispatchable (§8, §10 plan review). */
195
+ approveScope(scopeId) {
196
+ this.deps.store.updateScopeStatus(scopeId, "approved");
197
+ }
198
+ /** Approves every proposed scope of a goal (`orc approve <goal-id>`, §9). */
199
+ approveGoal(goalId) {
200
+ const proposed = this.deps.store.listProposedScopesByGoal(goalId);
201
+ for (const s of proposed)
202
+ this.deps.store.updateScopeStatus(s.id, "approved");
203
+ this.deps.store.updateGoalStatus(goalId, "active");
204
+ return proposed;
205
+ }
206
+ // --- Planning (§3, §15 Phase 2) ------------------------------------------
207
+ /**
208
+ * Runs the Planner for a goal and materializes its Plan into proposed scopes
209
+ * and pending tasks, ready for operator approval. The plan is inert until
210
+ * approved — nothing dispatches here (§3: the Planner never dispatches).
211
+ */
212
+ async planGoal(goalId) {
213
+ const goal = this.deps.store.getGoal(goalId);
214
+ if (!goal)
215
+ throw new Error(`planGoal: goal ${goalId} not found`);
216
+ this.deps.store.updateGoalStatus(goalId, "planning");
217
+ const plan = await this.deps.planner.plan(goal);
218
+ const materialized = this.materializePlan(goal.id, plan);
219
+ this.deps.store.updateGoalStatus(goalId, "awaiting_approval");
220
+ return materialized;
221
+ }
222
+ /**
223
+ * Replaces a goal's proposed plan with an edited one (`orc plan edit`, §9).
224
+ * Validates the candidate, drops the existing proposed scopes/tasks, and
225
+ * re-materializes. Throws if validation fails so a bad edit never lands.
226
+ */
227
+ replacePlan(goalId, candidate) {
228
+ const goal = this.deps.store.getGoal(goalId);
229
+ if (!goal)
230
+ throw new Error(`replacePlan: goal ${goalId} not found`);
231
+ const validation = validatePlan(candidate);
232
+ if (!validation.ok) {
233
+ throw new Error(`invalid plan: ${validation.errors.join("; ")}`);
234
+ }
235
+ this.deps.store.deleteProposedPlan(goalId);
236
+ const materialized = this.materializePlan(goalId, validation.plan);
237
+ this.deps.store.updateGoalStatus(goalId, "awaiting_approval");
238
+ return materialized;
239
+ }
240
+ /**
241
+ * Cancels a goal's proposed plan (§10 plan review): drops the proposed
242
+ * scopes and their tasks and returns the goal to `draft` so it can be
243
+ * re-planned. Approved/running scopes are never touched
244
+ * ({@link Store.deleteProposedPlan} only deletes `proposed` ones).
245
+ */
246
+ cancelPlan(goalId) {
247
+ const goal = this.deps.store.getGoal(goalId);
248
+ if (!goal)
249
+ throw new Error(`cancelPlan: goal ${goalId} not found`);
250
+ this.deps.store.deleteProposedPlan(goalId);
251
+ this.deps.store.updateGoalStatus(goalId, "draft");
252
+ }
253
+ /**
254
+ * Applies a re-plan cycle's {@link Plan} additively — grows the goal's DAG
255
+ * without deleting existing scopes/tasks (autonomous-loop.md §3.3, G2). Used
256
+ * by the autonomous controller; the plan is already validated by the Planner.
257
+ * New scopes land `proposed`; the controller approves them per its mode.
258
+ */
259
+ applyReplan(goalId, plan) {
260
+ if (!this.deps.store.getGoal(goalId))
261
+ throw new Error(`applyReplan: goal ${goalId} not found`);
262
+ return this.appendPlan(goalId, plan);
263
+ }
264
+ /**
265
+ * Materializes a validated {@link Plan} into Scope/Task rows, resolving the
266
+ * plan's name-based `depends_on` references to the newly-minted ULIDs. Scopes
267
+ * land `proposed`, tasks `pending`; approval (per scope or per goal) flips
268
+ * scopes to `approved` so the dispatch loop can pick their tasks up.
269
+ *
270
+ * Thin wrapper over {@link appendPlan}: on the first materialization there are
271
+ * no existing scopes, so the two are equivalent.
272
+ */
273
+ materializePlan(goalId, plan) {
274
+ return this.appendPlan(goalId, plan);
275
+ }
276
+ /**
277
+ * Appends a validated {@link Plan}'s scopes/tasks to a goal WITHOUT deleting
278
+ * anything (autonomous-loop.md §3.3, G2). This is what a re-plan cycle uses to
279
+ * grow the DAG. Scope-level `depends_on` may reference either newly-created or
280
+ * pre-existing scope names, so a re-planned scope can depend on an
281
+ * already-completed one; on a name collision the new scope wins (consistent
282
+ * with intra-plan resolution). New scopes land `proposed`, tasks `pending`.
283
+ */
284
+ appendPlan(goalId, plan) {
285
+ const existingByName = new Map();
286
+ for (const s of this.deps.store.listScopesByGoal(goalId)) {
287
+ existingByName.set(s.name, s.id);
288
+ }
289
+ const newByName = new Map();
290
+ const scopes = [];
291
+ const tasks = [];
292
+ // Pass 1: create the plan's scopes (edges resolved in pass 2 once ids exist).
293
+ for (const ps of plan.scopes) {
294
+ const scope = this.deps.store.createScope({
295
+ goal_id: goalId,
296
+ name: ps.name,
297
+ description: ps.description,
298
+ path_allowlist: ps.path_allowlist,
299
+ path_denylist: ps.path_denylist ?? [],
300
+ allowed_tools: ps.allowed_tools,
301
+ disallowed_tools: ps.disallowed_tools ?? [],
302
+ model_tier: ps.model_tier,
303
+ environment: ps.environment,
304
+ permission_mode: ps.permission_mode,
305
+ forbidden_actions: ps.forbidden_actions ?? [],
306
+ success_criteria: ps.success_criteria ?? [],
307
+ max_budget_usd: ps.max_budget_usd,
308
+ depends_on: [],
309
+ });
310
+ newByName.set(ps.name, scope.id);
311
+ scopes.push(scope);
312
+ }
313
+ // Resolve a scope name against new scopes first, then pre-existing ones.
314
+ const resolveScope = (name) => newByName.get(name) ?? existingByName.get(name);
315
+ // Pass 2: resolve scope-level edges (across new + existing), then create +
316
+ // link tasks per scope. Task edges stay within-scope by title (as before).
317
+ for (const ps of plan.scopes) {
318
+ const scopeId = newByName.get(ps.name);
319
+ const scopeDeps = (ps.depends_on ?? [])
320
+ .map(resolveScope)
321
+ .filter((x) => !!x);
322
+ if (scopeDeps.length)
323
+ this.deps.store.setScopeDependsOn(scopeId, scopeDeps);
324
+ const taskIdByTitle = new Map();
325
+ const created = [];
326
+ for (const pt of ps.tasks) {
327
+ const task = this.deps.store.createTask({
328
+ scope_id: scopeId,
329
+ title: pt.title,
330
+ prompt: pt.prompt,
331
+ task_type: pt.task_type,
332
+ depends_on: [],
333
+ });
334
+ taskIdByTitle.set(pt.title, task.id);
335
+ created.push(task);
336
+ }
337
+ for (const pt of ps.tasks) {
338
+ const taskDeps = (pt.depends_on ?? [])
339
+ .map((t) => taskIdByTitle.get(t))
340
+ .filter((x) => !!x);
341
+ if (taskDeps.length) {
342
+ this.deps.store.setTaskDependsOn(taskIdByTitle.get(pt.title), taskDeps);
343
+ }
344
+ }
345
+ tasks.push(...created);
346
+ }
347
+ return { scopes, tasks };
348
+ }
349
+ // --- Run lifecycle -------------------------------------------------------
350
+ /** Starts a run for a goal and kicks the dispatch loop (§5). */
351
+ startRun(goalId, opts) {
352
+ const goalForLock = this.deps.store.getGoal(goalId);
353
+ if (goalForLock) {
354
+ // Always one active run per GOAL — two runs would race the same tasks.
355
+ const activeForGoal = this.deps.store.getActiveRunForGoal(goalId);
356
+ if (activeForGoal &&
357
+ ["running", "pausing", "paused"].includes(activeForGoal.state)) {
358
+ throw new Error(`run ${activeForGoal.id} is already active for this goal; ` +
359
+ `pause or stop it before starting another`);
360
+ }
361
+ // One run per repo at a time (§13.11) — EXCEPT worktree-mode projects
362
+ // (spec 002 v2): scope isolation makes concurrent runs on one repo safe.
363
+ const project = goalForLock.project_id
364
+ ? this.deps.store.getProject(goalForLock.project_id)
365
+ : null;
366
+ if (project?.execution_mode !== "worktree") {
367
+ const active = this.deps.store.getActiveRunForRepo(goalForLock.repo_root);
368
+ if (active) {
369
+ throw new Error(`a run (${active.id}) is already active for ${goalForLock.repo_root}; ` +
370
+ `pause or stop it before starting another (§13.11)`);
371
+ }
372
+ }
373
+ }
374
+ const run = this.deps.store.createRun({
375
+ goal_id: goalId,
376
+ budget_usd: opts.budget_usd,
377
+ concurrency_limit: opts.concurrency_limit ?? this.deps.config.concurrency_limit,
378
+ // Worktrees fork from this branch and env classification uses it, never
379
+ // the `orc/…` worktree branch (spec 002 §R8, §R10).
380
+ base_branch: goalForLock
381
+ ? (gitBranch(goalForLock.repo_root) ?? null)
382
+ : null,
383
+ auto_loop: opts.auto_loop ?? false,
384
+ });
385
+ this.deps.store.updateRun(run.id, { state: "running" });
386
+ this.emitRunState(run.id, "running");
387
+ this.deps.reporting.startInterval(run.id); // §11 interval reports
388
+ void this.tick(run.id);
389
+ return run;
390
+ }
391
+ /**
392
+ * Graceful pause (§5): halt dispatch and interrupt running workers, waiting up
393
+ * to `pause.grace_ms` for them to yield at a tool-call boundary. Workers that
394
+ * do not settle within the grace window are marked `dirty` — resume will
395
+ * re-verify their workspace before continuing (§13.6).
396
+ */
397
+ async pause(runId, reason = "operator pause") {
398
+ this.deps.store.updateRun(runId, {
399
+ state: "pausing",
400
+ pause_reason: reason,
401
+ });
402
+ this.emitRunState(runId, "pausing", reason);
403
+ this.deps.reporting.stopInterval(runId);
404
+ // Only this run's workers are interrupted — other projects' runs keep
405
+ // going (spec 002 multi-run posture).
406
+ const handles = this.handlesFor(runId);
407
+ handles.forEach((h) => void h.interrupt());
408
+ // Wait for graceful settle, bounded by the grace window.
409
+ const allDone = Promise.allSettled(handles.map((h) => h.done));
410
+ const timedOut = await Promise.race([
411
+ allDone.then(() => false),
412
+ this.delay(this.deps.config.pause.grace_ms).then(() => true),
413
+ ]);
414
+ if (timedOut) {
415
+ // Stragglers: flag their tasks dirty so resume re-verifies (§13.6).
416
+ for (const h of this.handlesFor(runId)) {
417
+ this.deps.store.updateTask(h.task_id, {
418
+ status: "paused",
419
+ dirty: true,
420
+ });
421
+ this.emitTaskState(runId, h.task_id, "paused", "dirty: grace timeout");
422
+ }
423
+ }
424
+ this.deps.store.updateRun(runId, {
425
+ state: "paused",
426
+ paused_at: new Date().toISOString(),
427
+ });
428
+ this.emitRunState(runId, "paused", reason);
429
+ this.deps.reporting.generate(runId, "manual");
430
+ }
431
+ /** A promise that resolves after `ms`, with an unref'd timer. */
432
+ delay(ms) {
433
+ return new Promise((resolve) => {
434
+ const t = setTimeout(resolve, ms);
435
+ if (typeof t.unref === "function")
436
+ t.unref();
437
+ });
438
+ }
439
+ /**
440
+ * Resume (§5): re-queue every `paused` task so the dispatch loop picks it up
441
+ * again. Tasks that captured a `session_id` continue that session (SDK
442
+ * `resume:`) with a continuation prompt; the scope config is re-applied fresh
443
+ * so resume never inherits stale permissions. Then the run re-enters Running.
444
+ */
445
+ resume(runId) {
446
+ const run = this.deps.store.getRun(runId);
447
+ if (!run)
448
+ throw new Error(`resume: run ${runId} not found`);
449
+ for (const task of this.deps.store.listTasksByGoal(run.goal_id)) {
450
+ if (task.status === "paused") {
451
+ // Dirty tasks are re-queued too; the resume prompt mandates workspace
452
+ // verification, and the dirty flag is cleared once it re-dispatches.
453
+ this.deps.store.updateTask(task.id, { status: "queued", dirty: false });
454
+ }
455
+ }
456
+ this.deps.store.updateRun(runId, {
457
+ state: "running",
458
+ pause_reason: null,
459
+ });
460
+ this.emitRunState(runId, "running", "resumed");
461
+ this.deps.reporting.startInterval(runId);
462
+ void this.tick(runId);
463
+ }
464
+ /**
465
+ * Hard abort / kill switch (§5, §8.6): interrupt everything immediately and
466
+ * flag every in-flight task dirty — a hard stop may leave half-applied edits.
467
+ */
468
+ async panic(runId) {
469
+ for (const h of this.handlesFor(runId)) {
470
+ this.deps.store.updateTask(h.task_id, { dirty: true });
471
+ }
472
+ await Promise.allSettled(this.handlesFor(runId).map((h) => h.interrupt()));
473
+ this.deps.reporting.stopInterval(runId);
474
+ this.deps.store.updateRun(runId, {
475
+ state: "paused",
476
+ paused_at: new Date().toISOString(),
477
+ pause_reason: "PANIC kill switch (§8.6)",
478
+ });
479
+ this.emitRunState(runId, "paused", "PANIC");
480
+ }
481
+ /** Number of workers currently in flight. */
482
+ get inFlight() {
483
+ return this.running.size;
484
+ }
485
+ /**
486
+ * Halts the dispatch loop for shutdown. Late worker `done` callbacks that fire
487
+ * after the store closes become no-ops, so nothing touches a closed database.
488
+ */
489
+ stop() {
490
+ this.stopped = true;
491
+ }
492
+ // --- Dispatch loop -------------------------------------------------------
493
+ /**
494
+ * One dispatch tick (§5). Selects ready tasks and spawns workers up to the
495
+ * concurrency limit. The budget gate is a single choke-point (§13.10): if the
496
+ * run is stopped, nothing new is dispatched.
497
+ */
498
+ async tick(runId) {
499
+ if (this.stopped)
500
+ return;
501
+ const run = this.deps.store.getRun(runId);
502
+ if (!run || run.state !== "running")
503
+ return;
504
+ if (this.deps.budget.isStopped(runId))
505
+ return;
506
+ // Rate-limit backpressure halts all new dispatch until it clears (§7.4).
507
+ if (this.deps.backpressure.isDispatchBlocked())
508
+ return;
509
+ const goal = this.deps.store.getGoal(run.goal_id);
510
+ if (!goal)
511
+ return;
512
+ // Per-run task ceiling (spec 002 §R15): at the cap the run parks with a
513
+ // distinct reason; in-flight workers settle normally.
514
+ const dispatched = this.deps.store.countDispatchesForRun(runId);
515
+ if (dispatched >= this.deps.config.budget.max_tasks_per_run) {
516
+ this.emitPacingHold(runId, "tasks_per_run");
517
+ this.parkRun(runId, "tasks_per_run_cap");
518
+ return;
519
+ }
520
+ const tasks = this.deps.store.listTasksByGoal(goal.id);
521
+ const byId = new Map(tasks.map((t) => [t.id, t]));
522
+ for (const task of tasks) {
523
+ // Fleet-wide cap across ALL runs (spec 002 §R13); a settling worker
524
+ // re-ticks, so no timer is needed to resume.
525
+ if (this.running.size >= this.deps.config.global_concurrency_limit) {
526
+ this.emitPacingHold(runId, "global_concurrency");
527
+ break;
528
+ }
529
+ if (this.runningCountFor(runId) >= run.concurrency_limit)
530
+ break;
531
+ if (this.running.has(task.id))
532
+ continue;
533
+ if (task.status !== "pending" && task.status !== "queued")
534
+ continue;
535
+ if (!this.dependenciesMet(task, byId))
536
+ continue;
537
+ const scope = this.deps.store.getScope(task.scope_id);
538
+ if (!scope)
539
+ continue;
540
+ if (scope.status !== "approved" && scope.status !== "running")
541
+ continue;
542
+ // Tasks-per-hour throttle (spec 002 §R14): defer and re-tick when the
543
+ // sliding window frees a slot.
544
+ const pace = this.pacer.check(new Date());
545
+ if (!pace.ok) {
546
+ this.emitPacingHold(runId, "tasks_per_hour", pace.resume_at);
547
+ const delayMs = Math.max(pace.resume_at.getTime() - Date.now(), 1_000);
548
+ const timer = setTimeout(() => void this.tick(runId), delayMs);
549
+ if (typeof timer.unref === "function")
550
+ timer.unref();
551
+ break;
552
+ }
553
+ this.pacingHold = null;
554
+ this.pacer.recordDispatch(new Date());
555
+ this.dispatch(run, goal, scope, task);
556
+ }
557
+ // If nothing is running and no task is dispatchable, the run may be done.
558
+ if (this.runningCountFor(runId) === 0)
559
+ this.maybeFinish(run);
560
+ }
561
+ dependenciesMet(task, byId) {
562
+ return task.depends_on.every((dep) => byId.get(dep)?.status === "done");
563
+ }
564
+ dispatch(run, goal, scope, task) {
565
+ // Workspace resolution (spec 002 §R8): worktree-mode projects pin the
566
+ // scope's workers to a dedicated worktree; everything else runs in-repo.
567
+ let cwd = goal.repo_root;
568
+ let isolated = false;
569
+ const project = goal.project_id
570
+ ? this.deps.store.getProject(goal.project_id)
571
+ : null;
572
+ if (project?.execution_mode === "worktree") {
573
+ try {
574
+ if (scope.worktree_path) {
575
+ cwd = scope.worktree_path;
576
+ }
577
+ else {
578
+ const wt = this.deps.worktrees.ensureScopeWorktree({
579
+ repoRoot: goal.repo_root,
580
+ runId: run.id,
581
+ scopeId: scope.id,
582
+ goalTitle: goal.title,
583
+ scopeName: scope.name,
584
+ baseBranch: run.base_branch,
585
+ });
586
+ this.deps.store.setScopeWorktree(scope.id, wt.path, wt.branch);
587
+ cwd = wt.path;
588
+ }
589
+ isolated = true;
590
+ }
591
+ catch (err) {
592
+ // Worktree setup failure fails the task cleanly — never hangs (§R8).
593
+ this.deps.store.updateTask(task.id, {
594
+ status: "failed",
595
+ error: String(err),
596
+ });
597
+ this.emitTaskState(run.id, task.id, "failed", "worktree setup failed");
598
+ this.settleScopesFor(run.id, goal.id);
599
+ return;
600
+ }
601
+ }
602
+ // Re-classify environment at dispatch (§8.1): declared + git branch. A
603
+ // worktree scope classifies against the run's base branch (spec 002 §R10),
604
+ // never the `orc/…` worktree branch; in-repo keeps the live-branch read.
605
+ const classification = classifyEnvironment({
606
+ declared: scope.environment,
607
+ branch: isolated
608
+ ? (run.base_branch ?? gitBranch(goal.repo_root))
609
+ : gitBranch(cwd),
610
+ }, this.deps.config.safety);
611
+ // Route model (§6) and log the decision.
612
+ const decision = routeModel({
613
+ task_type: task.task_type,
614
+ model_tier: scope.model_tier,
615
+ routing: this.deps.config.routing,
616
+ ctx: {
617
+ budget_state: run.budget_state,
618
+ attempt: task.attempt,
619
+ previous_model: task.model_used ?? undefined,
620
+ quarantined: this.deps.backpressure.quarantinedModels(),
621
+ },
622
+ });
623
+ this.deps.audit.record({
624
+ ts: new Date().toISOString(),
625
+ run_id: run.id,
626
+ task_id: task.id,
627
+ session_id: null,
628
+ kind: "routing_decision",
629
+ tool_name: null,
630
+ tool_input_hash: null,
631
+ tool_input: null,
632
+ decision: decision.model,
633
+ rule_id: decision.rule_id,
634
+ detail: {
635
+ reason: decision.reason,
636
+ degraded: decision.degraded,
637
+ escalated_from: decision.escalated_from,
638
+ environment: classification.environment,
639
+ env_signals: classification.signals,
640
+ },
641
+ });
642
+ const taskCount = this.deps.store.listTasksByScope(scope.id).length;
643
+ const maxBudget = perTaskBudgetUsd(scope.max_budget_usd, taskCount, this.deps.config.budget);
644
+ this.deps.store.updateTask(task.id, {
645
+ status: "queued",
646
+ model_used: decision.model,
647
+ routing_reason: `${decision.rule_id}: ${decision.reason}`,
648
+ });
649
+ this.deps.store.updateScopeStatus(scope.id, "running");
650
+ this.deps.bus.publish({
651
+ type: "dispatch",
652
+ run_id: run.id,
653
+ payload: {
654
+ task_id: task.id,
655
+ scope_id: scope.id,
656
+ model: decision.model,
657
+ routing_reason: `${decision.rule_id}: ${decision.reason}`,
658
+ },
659
+ });
660
+ // A captured session id means this is a resume (§5): continue that session
661
+ // with the continuation prompt rather than re-sending the original prompt.
662
+ const isResume = !!task.session_id;
663
+ const handle = this.deps.workers.spawn({
664
+ run_id: run.id,
665
+ task_id: task.id,
666
+ cwd,
667
+ environment: classification.environment,
668
+ path_allowlist: scope.path_allowlist,
669
+ path_denylist: scope.path_denylist,
670
+ allowed_tools: scope.allowed_tools,
671
+ disallowed_tools: scope.disallowed_tools,
672
+ permission_mode: scope.permission_mode,
673
+ model: decision.model,
674
+ max_turns: 30,
675
+ max_budget_usd: maxBudget,
676
+ prompt: (isResume ? RESUME_PROMPT : task.prompt) +
677
+ (isolated ? WORKTREE_PROMPT_SUFFIX : ""),
678
+ resume_session_id: isResume ? task.session_id : undefined,
679
+ });
680
+ this.running.set(task.id, handle);
681
+ this.runOfTask.set(task.id, run.id);
682
+ void handle.done
683
+ .then((result) => {
684
+ this.running.delete(task.id);
685
+ this.runOfTask.delete(task.id);
686
+ this.onWorkerSettled(run.id, task.id, result);
687
+ })
688
+ .catch(() => {
689
+ this.running.delete(task.id);
690
+ this.runOfTask.delete(task.id);
691
+ void this.tick(run.id);
692
+ });
693
+ }
694
+ /**
695
+ * Reacts to a settled worker (§5, §13). A failure while the run is
696
+ * pausing/paused is an interrupt, not a real failure — the task is marked
697
+ * `paused` for resume. A genuine failure retries up to `retry.max_attempts`,
698
+ * feeding router R5 escalation, before it is left `failed`.
699
+ */
700
+ onWorkerSettled(runId, taskId, result) {
701
+ if (this.stopped)
702
+ return; // shutting down: don't touch the store
703
+ // A task blocked by an escalation (§8.5) stays blocked, not retried/failed.
704
+ if (this.blocked.has(taskId)) {
705
+ this.blocked.delete(taskId);
706
+ this.deps.store.updateTask(taskId, { status: "blocked" });
707
+ void this.tick(runId);
708
+ return;
709
+ }
710
+ if (result.status === "failed") {
711
+ const run = this.deps.store.getRun(runId);
712
+ const pausing = run?.state === "pausing" || run?.state === "paused";
713
+ if (pausing) {
714
+ this.deps.store.updateTask(taskId, { status: "paused" });
715
+ this.emitTaskState(runId, taskId, "paused", "interrupted by pause");
716
+ }
717
+ else if (this.handleLimitSignal(runId, taskId, result)) {
718
+ // Rate-limited: re-queued and held by backpressure, not a real failure.
719
+ }
720
+ else {
721
+ this.handleFailure(runId, taskId);
722
+ this.deps.reporting.generate(runId, "task_done");
723
+ }
724
+ }
725
+ // Detect scope completion before advancing (autonomous-loop.md §3.2, G1).
726
+ const run = this.deps.store.getRun(runId);
727
+ if (run)
728
+ this.settleScopesFor(runId, run.goal_id);
729
+ // Re-tick every running run, not just this one: a settling worker frees a
730
+ // global concurrency slot another run may be waiting on (spec 002 §R13).
731
+ void this.tick(runId);
732
+ for (const other of this.deps.store.listRuns()) {
733
+ if (other.id !== runId && other.state === "running") {
734
+ void this.tick(other.id);
735
+ }
736
+ }
737
+ }
738
+ /**
739
+ * Detects scope completion (autonomous-loop.md §3.2, G1). For each active
740
+ * scope whose tasks are all terminal, transitions it to `done` (none failed)
741
+ * or `failed` (≥1 failed after retries) and emits `scope.done`/`scope.failed`.
742
+ * A scope with no tasks, or holding a non-terminal task (queued/paused/
743
+ * blocked), is left untouched. Idempotent: already-terminal scopes are
744
+ * skipped, so this is safe to call after every worker settles.
745
+ */
746
+ settleScopesFor(runId, goalId) {
747
+ const terminalStatuses = ["done", "skipped", "cancelled", "failed"];
748
+ const goal = this.deps.store.getGoal(goalId);
749
+ for (const scope of this.deps.store.listScopesByGoal(goalId)) {
750
+ if (scope.status !== "running" && scope.status !== "approved")
751
+ continue;
752
+ const tasks = this.deps.store.listTasksByScope(scope.id);
753
+ if (tasks.length === 0)
754
+ continue;
755
+ if (!tasks.every((t) => terminalStatuses.includes(t.status)))
756
+ continue;
757
+ const failedCount = tasks.filter((t) => t.status === "failed").length;
758
+ const settled = failedCount > 0 ? "failed" : "done";
759
+ this.deps.store.updateScopeStatus(scope.id, settled);
760
+ // Worktree lifecycle at settlement (spec 002 §R8): success releases the
761
+ // worktree (safety-net commit for leftover dirt; branch kept for manual
762
+ // merge); failure keeps the worktree on disk for debugging.
763
+ if (scope.worktree_path && settled === "done" && goal) {
764
+ try {
765
+ this.deps.worktrees.releaseScopeWorktree(scope.worktree_path, goal.repo_root, scope.name);
766
+ this.deps.store.setScopeWorktree(scope.id, null, scope.branch_name);
767
+ this.maybeAutoMerge(runId, goal, scope);
768
+ }
769
+ catch (err) {
770
+ this.deps.audit.record({
771
+ ts: new Date().toISOString(),
772
+ run_id: runId,
773
+ task_id: null,
774
+ session_id: null,
775
+ kind: "state_change",
776
+ tool_name: null,
777
+ tool_input_hash: null,
778
+ tool_input: null,
779
+ decision: "worktree_release_failed",
780
+ rule_id: null,
781
+ detail: { scope_id: scope.id, error: String(err) },
782
+ });
783
+ }
784
+ }
785
+ this.deps.bus.publish({
786
+ type: failedCount > 0 ? "scope.failed" : "scope.done",
787
+ run_id: runId,
788
+ payload: {
789
+ scope_id: scope.id,
790
+ goal_id: goalId,
791
+ task_count: tasks.length,
792
+ failed_count: failedCount,
793
+ },
794
+ });
795
+ }
796
+ }
797
+ /**
798
+ * Opt-in auto-merge of a settled scope branch into the run's base branch
799
+ * (spec 002 v2). Conservative by design: any skip (dirty checkout, wrong
800
+ * branch, conflict) leaves the branch for manual merge — the default flow.
801
+ * Every outcome is audited.
802
+ */
803
+ maybeAutoMerge(runId, goal, scope) {
804
+ if (!scope.branch_name || !goal.project_id)
805
+ return;
806
+ const project = this.deps.store.getProject(goal.project_id);
807
+ if (!project?.auto_merge)
808
+ return;
809
+ const baseBranch = this.deps.store.getRun(runId)?.base_branch;
810
+ if (!baseBranch)
811
+ return;
812
+ const outcome = this.deps.worktrees.mergeScopeBranch(goal.repo_root, scope.branch_name, baseBranch, scope.name);
813
+ this.deps.audit.record({
814
+ ts: new Date().toISOString(),
815
+ run_id: runId,
816
+ task_id: null,
817
+ session_id: null,
818
+ kind: "state_change",
819
+ tool_name: null,
820
+ tool_input_hash: null,
821
+ tool_input: null,
822
+ decision: outcome.merged ? "auto_merge" : "auto_merge_skipped",
823
+ rule_id: null,
824
+ detail: {
825
+ scope_id: scope.id,
826
+ branch: scope.branch_name,
827
+ base_branch: baseBranch,
828
+ reason: outcome.reason ?? null,
829
+ },
830
+ });
831
+ }
832
+ /**
833
+ * Detects a rate-limit signal in a worker failure (§7.4). If found, engages
834
+ * backpressure and re-queues the task without consuming a retry attempt — the
835
+ * dispatch gate holds it until the limit clears. Returns true when handled.
836
+ */
837
+ handleLimitSignal(runId, taskId, result) {
838
+ const text = typeof result.error === "string"
839
+ ? result.error
840
+ : JSON.stringify(result.error ?? "");
841
+ const httpish = /\b429\b|rate.?limit|too many requests/i.test(text);
842
+ const signal = detectLimitSignal(text, this.deps.config.limits, new Date(), httpish);
843
+ if (!signal)
844
+ return false;
845
+ this.deps.backpressure.engage(signal, runId);
846
+ this.deps.audit.record({
847
+ ts: new Date().toISOString(),
848
+ run_id: runId,
849
+ task_id: taskId,
850
+ session_id: null,
851
+ kind: "backpressure",
852
+ tool_name: null,
853
+ tool_input_hash: null,
854
+ tool_input: null,
855
+ decision: signal.kind,
856
+ rule_id: null,
857
+ detail: {
858
+ model: signal.model,
859
+ resets_at: signal.resets_at,
860
+ raw: signal.raw,
861
+ },
862
+ });
863
+ // Re-queue for a later attempt once backpressure lifts; keep the attempt.
864
+ this.deps.store.updateTask(taskId, { status: "queued", error: null });
865
+ this.emitTaskState(runId, taskId, "queued", `rate-limited: ${signal.kind}`);
866
+ return true;
867
+ }
868
+ /** Bounded retry (§5 `failed → queued`, §13). Requeues or gives up. */
869
+ handleFailure(runId, taskId) {
870
+ const task = this.deps.store.getTask(taskId);
871
+ if (!task)
872
+ return;
873
+ const nextAttempt = task.attempt + 1;
874
+ if (nextAttempt < this.deps.config.retry.max_attempts) {
875
+ // Fresh session on retry — the failed session is discarded, but the prior
876
+ // model is retained via model_used so R5 can escalate a tier.
877
+ this.deps.store.updateTask(taskId, {
878
+ status: "queued",
879
+ attempt: nextAttempt,
880
+ session_id: null,
881
+ error: null,
882
+ });
883
+ this.deps.audit.record({
884
+ ts: new Date().toISOString(),
885
+ run_id: runId,
886
+ task_id: taskId,
887
+ session_id: null,
888
+ kind: "state_change",
889
+ tool_name: null,
890
+ tool_input_hash: null,
891
+ tool_input: null,
892
+ decision: "retry",
893
+ rule_id: null,
894
+ detail: { attempt: nextAttempt, previous_model: task.model_used },
895
+ });
896
+ this.emitTaskState(runId, taskId, "queued", `retry #${nextAttempt}`, {
897
+ attempt: nextAttempt,
898
+ });
899
+ }
900
+ // Otherwise leave it `failed`; maybeFinish transitions the run to Failed.
901
+ }
902
+ emitTaskState(runId, taskId, status, reason, extra) {
903
+ this.deps.bus.publish({
904
+ type: "task.state",
905
+ run_id: runId,
906
+ payload: {
907
+ task_id: taskId,
908
+ scope_id: "",
909
+ status,
910
+ attempt: extra?.attempt,
911
+ error: reason ? { reason } : undefined,
912
+ },
913
+ });
914
+ }
915
+ /** Registers the autonomous controller (autonomous-loop.md §3.1, G4). */
916
+ setAutoLoop(hook) {
917
+ this.autoLoop = hook;
918
+ }
919
+ maybeFinish(run) {
920
+ const tasks = this.deps.store.listTasksByGoal(run.goal_id);
921
+ if (tasks.length === 0)
922
+ return;
923
+ // `paused`/`blocked` tasks are not terminal — they await resume/operator.
924
+ const terminal = tasks.every((t) => ["done", "skipped", "cancelled", "failed"].includes(t.status));
925
+ if (!terminal)
926
+ return;
927
+ // Auto mode (autonomous-loop.md §3.1): the DAG is quiesced, but "done" is a
928
+ // goal-satisfaction decision, not DAG exhaustion. Hand off to the controller,
929
+ // which evaluates criteria and either finishes, re-plans, or pauses.
930
+ // Feature-flow runs (spec 002 §R5) opt in per-run via `run.auto_loop`.
931
+ if ((this.deps.config.autoLoop.enabled || run.auto_loop) && this.autoLoop) {
932
+ this.autoLoop.onRunQuiesced(run.id);
933
+ return;
934
+ }
935
+ const anyFailed = tasks.some((t) => t.status === "failed");
936
+ this.finalizeRun(run.id, anyFailed ? "failed" : "done", anyFailed ? "one or more tasks failed after retries" : undefined);
937
+ }
938
+ /**
939
+ * Marks a run terminal (done/failed), emits the transition, stops the interval
940
+ * timer, and writes the final report (§11). Used by `maybeFinish` and, in auto
941
+ * mode, by the controller when success criteria are satisfied.
942
+ */
943
+ finalizeRun(runId, state, reason) {
944
+ this.deps.store.updateRun(runId, {
945
+ state,
946
+ finished_at: new Date().toISOString(),
947
+ });
948
+ this.emitRunState(runId, state, reason);
949
+ this.deps.reporting.stopInterval(runId);
950
+ this.deps.reporting.generate(runId, "final");
951
+ }
952
+ /**
953
+ * Parks an auto-loop run as `paused` with a reason (autonomous-loop.md §3.1):
954
+ * the runaway guards (`cycle_cap`, `no_progress`) and the supervised approval
955
+ * gate (`awaiting_replan_approval`) all land here. Non-terminal — an operator
956
+ * (or a mode change) can resume. Halts the dispatch loop via the run state.
957
+ */
958
+ parkRun(runId, reason) {
959
+ this.deps.store.updateRun(runId, {
960
+ state: "paused",
961
+ paused_at: new Date().toISOString(),
962
+ pause_reason: reason,
963
+ });
964
+ this.emitRunState(runId, "paused", reason);
965
+ this.deps.reporting.stopInterval(runId);
966
+ }
967
+ emitRunState(runId, state, reason) {
968
+ this.deps.bus.publish({
969
+ type: "run.state",
970
+ run_id: runId,
971
+ payload: { state, reason },
972
+ });
973
+ }
974
+ }
975
+ //# sourceMappingURL=orchestrator.js.map