oxe-cc 1.2.1 → 1.4.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 (281) hide show
  1. package/.cursor/commands/oxe-ask.md +2 -2
  2. package/.cursor/commands/oxe-capabilities.md +2 -2
  3. package/.cursor/commands/oxe-checkpoint.md +2 -2
  4. package/.cursor/commands/oxe-compact.md +2 -2
  5. package/.cursor/commands/oxe-dashboard.md +2 -2
  6. package/.cursor/commands/oxe-debug.md +2 -2
  7. package/.cursor/commands/oxe-discuss.md +2 -2
  8. package/.cursor/commands/oxe-execute.md +5 -2
  9. package/.cursor/commands/oxe-forensics.md +2 -2
  10. package/.cursor/commands/oxe-help.md +2 -2
  11. package/.cursor/commands/oxe-loop.md +2 -2
  12. package/.cursor/commands/oxe-milestone.md +2 -2
  13. package/.cursor/commands/oxe-next.md +2 -2
  14. package/.cursor/commands/oxe-obs.md +2 -2
  15. package/.cursor/commands/oxe-plan-agent.md +2 -2
  16. package/.cursor/commands/oxe-plan.md +2 -2
  17. package/.cursor/commands/oxe-project.md +2 -2
  18. package/.cursor/commands/oxe-quick.md +2 -2
  19. package/.cursor/commands/oxe-research.md +2 -2
  20. package/.cursor/commands/oxe-retro.md +2 -2
  21. package/.cursor/commands/oxe-review-pr.md +2 -2
  22. package/.cursor/commands/oxe-route.md +2 -2
  23. package/.cursor/commands/oxe-scan.md +2 -2
  24. package/.cursor/commands/oxe-security.md +2 -2
  25. package/.cursor/commands/oxe-session.md +2 -2
  26. package/.cursor/commands/oxe-ship.md +2 -2
  27. package/.cursor/commands/oxe-skill.md +2 -2
  28. package/.cursor/commands/oxe-spec.md +2 -2
  29. package/.cursor/commands/oxe-ui-review.md +2 -2
  30. package/.cursor/commands/oxe-ui-spec.md +2 -2
  31. package/.cursor/commands/oxe-update.md +2 -2
  32. package/.cursor/commands/oxe-validate-gaps.md +2 -2
  33. package/.cursor/commands/oxe-verify.md +5 -2
  34. package/.cursor/commands/oxe-workstream.md +2 -2
  35. package/.cursor/commands/oxe.md +2 -2
  36. package/.github/copilot-instructions.md +13 -13
  37. package/.github/prompts/oxe-ask.prompt.md +2 -2
  38. package/.github/prompts/oxe-capabilities.prompt.md +2 -2
  39. package/.github/prompts/oxe-checkpoint.prompt.md +2 -2
  40. package/.github/prompts/oxe-compact.prompt.md +2 -2
  41. package/.github/prompts/oxe-dashboard.prompt.md +2 -2
  42. package/.github/prompts/oxe-debug.prompt.md +2 -2
  43. package/.github/prompts/oxe-discuss.prompt.md +2 -2
  44. package/.github/prompts/oxe-execute.prompt.md +5 -2
  45. package/.github/prompts/oxe-forensics.prompt.md +2 -2
  46. package/.github/prompts/oxe-help.prompt.md +2 -2
  47. package/.github/prompts/oxe-loop.prompt.md +2 -2
  48. package/.github/prompts/oxe-milestone.prompt.md +2 -2
  49. package/.github/prompts/oxe-next.prompt.md +2 -2
  50. package/.github/prompts/oxe-obs.prompt.md +2 -2
  51. package/.github/prompts/oxe-plan-agent.prompt.md +2 -2
  52. package/.github/prompts/oxe-plan.prompt.md +2 -2
  53. package/.github/prompts/oxe-project.prompt.md +2 -2
  54. package/.github/prompts/oxe-quick.prompt.md +2 -2
  55. package/.github/prompts/oxe-research.prompt.md +2 -2
  56. package/.github/prompts/oxe-retro.prompt.md +2 -2
  57. package/.github/prompts/oxe-review-pr.prompt.md +2 -2
  58. package/.github/prompts/oxe-route.prompt.md +2 -2
  59. package/.github/prompts/oxe-scan.prompt.md +2 -2
  60. package/.github/prompts/oxe-security.prompt.md +2 -2
  61. package/.github/prompts/oxe-session.prompt.md +2 -2
  62. package/.github/prompts/oxe-ship.prompt.md +2 -2
  63. package/.github/prompts/oxe-skill.prompt.md +2 -2
  64. package/.github/prompts/oxe-spec.prompt.md +2 -2
  65. package/.github/prompts/oxe-ui-review.prompt.md +2 -2
  66. package/.github/prompts/oxe-ui-spec.prompt.md +2 -2
  67. package/.github/prompts/oxe-update.prompt.md +2 -2
  68. package/.github/prompts/oxe-validate-gaps.prompt.md +2 -2
  69. package/.github/prompts/oxe-verify.prompt.md +5 -2
  70. package/.github/prompts/oxe-workstream.prompt.md +2 -2
  71. package/.github/prompts/oxe.prompt.md +2 -2
  72. package/AGENTS.md +5 -3
  73. package/CHANGELOG.md +72 -10
  74. package/LICENSE +21 -674
  75. package/README.md +631 -535
  76. package/bin/banner.txt +6 -6
  77. package/bin/lib/oxe-agent-install.cjs +69 -69
  78. package/bin/lib/oxe-azure.cjs +1445 -1445
  79. package/bin/lib/oxe-context-engine.cjs +867 -867
  80. package/bin/lib/oxe-dashboard.cjs +76 -28
  81. package/bin/lib/oxe-operational.cjs +2144 -1340
  82. package/bin/lib/oxe-project-health.cjs +483 -1
  83. package/bin/lib/oxe-runtime-semantics.cjs +12 -0
  84. package/bin/oxe-cc.js +554 -152
  85. package/commands/oxe/ask.md +2 -2
  86. package/commands/oxe/capabilities.md +2 -2
  87. package/commands/oxe/checkpoint.md +2 -2
  88. package/commands/oxe/compact.md +2 -2
  89. package/commands/oxe/dashboard.md +2 -2
  90. package/commands/oxe/debug.md +2 -2
  91. package/commands/oxe/discuss.md +2 -2
  92. package/commands/oxe/execute.md +5 -2
  93. package/commands/oxe/forensics.md +2 -2
  94. package/commands/oxe/help.md +2 -2
  95. package/commands/oxe/loop.md +2 -2
  96. package/commands/oxe/milestone.md +2 -2
  97. package/commands/oxe/next.md +2 -2
  98. package/commands/oxe/obs.md +2 -2
  99. package/commands/oxe/oxe.md +2 -2
  100. package/commands/oxe/plan-agent.md +2 -2
  101. package/commands/oxe/plan.md +2 -2
  102. package/commands/oxe/project.md +2 -2
  103. package/commands/oxe/quick.md +2 -2
  104. package/commands/oxe/research.md +2 -2
  105. package/commands/oxe/retro.md +2 -2
  106. package/commands/oxe/review-pr.md +2 -2
  107. package/commands/oxe/route.md +2 -2
  108. package/commands/oxe/scan.md +2 -2
  109. package/commands/oxe/security.md +2 -2
  110. package/commands/oxe/session.md +2 -2
  111. package/commands/oxe/ship.md +2 -2
  112. package/commands/oxe/skill.md +2 -2
  113. package/commands/oxe/spec.md +2 -2
  114. package/commands/oxe/ui-review.md +2 -2
  115. package/commands/oxe/ui-spec.md +2 -2
  116. package/commands/oxe/update.md +2 -2
  117. package/commands/oxe/validate-gaps.md +2 -2
  118. package/commands/oxe/verify.md +5 -2
  119. package/commands/oxe/workstream.md +2 -2
  120. package/lib/runtime/delivery/branch-manager.d.ts +1 -0
  121. package/lib/runtime/delivery/branch-manager.js +7 -0
  122. package/lib/runtime/delivery/ci-checks.js +34 -1
  123. package/lib/runtime/delivery/delivery-records.d.ts +34 -0
  124. package/lib/runtime/delivery/delivery-records.js +48 -0
  125. package/lib/runtime/delivery/index.d.ts +1 -0
  126. package/lib/runtime/delivery/index.js +1 -0
  127. package/lib/runtime/delivery/promotion-pipeline.d.ts +26 -2
  128. package/lib/runtime/delivery/promotion-pipeline.js +111 -14
  129. package/lib/runtime/gate/gate-manager.d.ts +41 -0
  130. package/lib/runtime/gate/gate-manager.js +108 -1
  131. package/lib/runtime/index.d.ts +2 -2
  132. package/lib/runtime/index.js +3 -1
  133. package/lib/runtime/models/gate-decision.d.ts +4 -1
  134. package/lib/runtime/models/workspace.d.ts +3 -0
  135. package/lib/runtime/plugins/capability-adapter.d.ts +12 -0
  136. package/lib/runtime/plugins/capability-adapter.js +204 -0
  137. package/lib/runtime/plugins/capability-matrix.d.ts +5 -0
  138. package/lib/runtime/plugins/capability-matrix.js +48 -17
  139. package/lib/runtime/plugins/index.d.ts +1 -0
  140. package/lib/runtime/plugins/index.js +1 -0
  141. package/lib/runtime/plugins/plugin-abi.d.ts +2 -0
  142. package/lib/runtime/plugins/plugin-manifest.d.ts +1 -1
  143. package/lib/runtime/plugins/plugin-manifest.js +6 -2
  144. package/lib/runtime/plugins/plugin-registry.d.ts +46 -0
  145. package/lib/runtime/plugins/plugin-registry.js +79 -2
  146. package/lib/runtime/policy/policy-engine.d.ts +19 -0
  147. package/lib/runtime/policy/policy-engine.js +76 -4
  148. package/lib/runtime/projection/projection-engine.d.ts +9 -1
  149. package/lib/runtime/projection/projection-engine.js +73 -3
  150. package/lib/runtime/scheduler/multi-agent-coordinator.d.ts +43 -1
  151. package/lib/runtime/scheduler/multi-agent-coordinator.js +151 -39
  152. package/lib/runtime/scheduler/run-journal.d.ts +1 -1
  153. package/lib/runtime/scheduler/scheduler.d.ts +19 -1
  154. package/lib/runtime/scheduler/scheduler.js +258 -13
  155. package/lib/runtime/verification/verification-compiler.d.ts +43 -0
  156. package/lib/runtime/verification/verification-compiler.js +137 -0
  157. package/lib/runtime/verification/verification-manifest.d.ts +9 -0
  158. package/lib/runtime/verification/verification-manifest.js +56 -6
  159. package/lib/runtime/workspace/strategies/ephemeral-container.d.ts +1 -0
  160. package/lib/runtime/workspace/strategies/ephemeral-container.js +4 -0
  161. package/lib/runtime/workspace/strategies/git-worktree.d.ts +1 -0
  162. package/lib/runtime/workspace/strategies/git-worktree.js +2 -0
  163. package/lib/runtime/workspace/strategies/inplace.d.ts +1 -0
  164. package/lib/runtime/workspace/strategies/inplace.js +2 -0
  165. package/lib/runtime/workspace/workspace-manager.d.ts +2 -1
  166. package/lib/sdk/README.md +20 -8
  167. package/lib/sdk/index.cjs +33 -24
  168. package/lib/sdk/index.d.ts +149 -14
  169. package/oxe/templates/ACTIVE-RUN.template.json +32 -32
  170. package/oxe/templates/CAPABILITIES.template.md +7 -7
  171. package/oxe/templates/CAPABILITY.template.md +45 -45
  172. package/oxe/templates/CHECKPOINTS.template.md +7 -7
  173. package/oxe/templates/EXECUTION-RUNTIME.template.md +68 -68
  174. package/oxe/templates/HYPOTHESES.template.md +33 -33
  175. package/oxe/templates/LESSONS-METRICS.template.json +13 -13
  176. package/oxe/templates/NOTES.template.md +16 -16
  177. package/oxe/templates/PLAN-REVIEW.template.md +31 -31
  178. package/oxe/templates/SESSION.template.md +34 -34
  179. package/oxe/templates/SKILL.template.md +26 -26
  180. package/oxe/templates/STATE.md +55 -55
  181. package/oxe/templates/WORKFLOW_AUTHORING.md +18 -18
  182. package/oxe/workflows/ask.md +96 -96
  183. package/oxe/workflows/capabilities.md +25 -25
  184. package/oxe/workflows/dashboard.md +33 -33
  185. package/oxe/workflows/discuss.md +12 -12
  186. package/oxe/workflows/execute.md +14 -0
  187. package/oxe/workflows/help.md +352 -352
  188. package/oxe/workflows/next.md +22 -22
  189. package/oxe/workflows/oxe.md +6 -6
  190. package/oxe/workflows/plan-agent.md +9 -9
  191. package/oxe/workflows/plan.md +51 -20
  192. package/oxe/workflows/quick.md +10 -10
  193. package/oxe/workflows/references/reasoning-discovery.md +28 -28
  194. package/oxe/workflows/references/reasoning-execution.md +29 -29
  195. package/oxe/workflows/references/reasoning-planning.md +32 -32
  196. package/oxe/workflows/references/reasoning-review.md +29 -29
  197. package/oxe/workflows/references/reasoning-status.md +24 -24
  198. package/oxe/workflows/references/robustness-elevation.md +295 -295
  199. package/oxe/workflows/references/workflow-runtime-contracts.json +952 -930
  200. package/oxe/workflows/route.md +16 -16
  201. package/oxe/workflows/session.md +213 -213
  202. package/oxe/workflows/ship.md +142 -142
  203. package/oxe/workflows/skill.md +44 -44
  204. package/oxe/workflows/ui-review.md +36 -36
  205. package/oxe/workflows/verify-audit.md +73 -73
  206. package/oxe/workflows/verify.md +10 -0
  207. package/package.json +92 -92
  208. package/packages/runtime/package.json +16 -15
  209. package/packages/runtime/src/audit/audit-trail.ts +243 -243
  210. package/packages/runtime/src/audit/index.ts +2 -2
  211. package/packages/runtime/src/audit/policy-pack.ts +62 -62
  212. package/packages/runtime/src/compiler/graph-compiler.ts +245 -245
  213. package/packages/runtime/src/compiler/index.ts +1 -1
  214. package/packages/runtime/src/context/context-pack-builder.ts +259 -259
  215. package/packages/runtime/src/context/context-pack-store.ts +197 -197
  216. package/packages/runtime/src/context/context-profiles.ts +60 -60
  217. package/packages/runtime/src/context/index.ts +3 -3
  218. package/packages/runtime/src/decision/decision-engine.ts +174 -174
  219. package/packages/runtime/src/decision/decision-memo.ts +211 -211
  220. package/packages/runtime/src/decision/index.ts +2 -2
  221. package/packages/runtime/src/delivery/branch-manager.ts +91 -84
  222. package/packages/runtime/src/delivery/ci-checks.ts +285 -252
  223. package/packages/runtime/src/delivery/delivery-records.ts +75 -0
  224. package/packages/runtime/src/delivery/index.ts +5 -4
  225. package/packages/runtime/src/delivery/pr-manager.ts +112 -112
  226. package/packages/runtime/src/delivery/promotion-pipeline.ts +334 -180
  227. package/packages/runtime/src/events/bus.ts +92 -92
  228. package/packages/runtime/src/events/catalog.ts +29 -29
  229. package/packages/runtime/src/events/envelope.ts +14 -14
  230. package/packages/runtime/src/events/index.ts +3 -3
  231. package/packages/runtime/src/evidence/evidence-store.ts +130 -130
  232. package/packages/runtime/src/evidence/index.ts +1 -1
  233. package/packages/runtime/src/gate/gate-manager.ts +289 -137
  234. package/packages/runtime/src/gate/index.ts +1 -1
  235. package/packages/runtime/src/index.ts +41 -37
  236. package/packages/runtime/src/models/attempt.ts +19 -19
  237. package/packages/runtime/src/models/evidence.ts +21 -21
  238. package/packages/runtime/src/models/gate-decision.ts +25 -21
  239. package/packages/runtime/src/models/index.ts +8 -8
  240. package/packages/runtime/src/models/run.ts +24 -24
  241. package/packages/runtime/src/models/session.ts +11 -11
  242. package/packages/runtime/src/models/verification-result.ts +10 -10
  243. package/packages/runtime/src/models/work-item.ts +25 -25
  244. package/packages/runtime/src/models/workspace.ts +31 -28
  245. package/packages/runtime/src/plugins/capability-adapter.ts +206 -0
  246. package/packages/runtime/src/plugins/capability-matrix.ts +126 -83
  247. package/packages/runtime/src/plugins/index.ts +5 -4
  248. package/packages/runtime/src/plugins/plugin-abi.ts +97 -95
  249. package/packages/runtime/src/plugins/plugin-manifest.ts +118 -113
  250. package/packages/runtime/src/plugins/plugin-registry.ts +232 -124
  251. package/packages/runtime/src/policy/index.ts +1 -1
  252. package/packages/runtime/src/policy/policy-engine.ts +330 -244
  253. package/packages/runtime/src/projection/index.ts +1 -1
  254. package/packages/runtime/src/projection/projection-engine.ts +328 -249
  255. package/packages/runtime/src/reducers/debug-reducer.ts +36 -36
  256. package/packages/runtime/src/reducers/index.ts +2 -2
  257. package/packages/runtime/src/reducers/run-state-reducer.ts +269 -269
  258. package/packages/runtime/src/scheduler/agent-registry.ts +132 -132
  259. package/packages/runtime/src/scheduler/agent-roles.ts +109 -109
  260. package/packages/runtime/src/scheduler/index.ts +4 -4
  261. package/packages/runtime/src/scheduler/multi-agent-coordinator.ts +521 -333
  262. package/packages/runtime/src/scheduler/run-journal.ts +62 -62
  263. package/packages/runtime/src/scheduler/scheduler.ts +722 -441
  264. package/packages/runtime/src/verification/index.ts +2 -2
  265. package/packages/runtime/src/verification/verification-compiler.ts +436 -225
  266. package/packages/runtime/src/verification/verification-manifest.ts +252 -192
  267. package/packages/runtime/src/workspace/index.ts +5 -5
  268. package/packages/runtime/src/workspace/strategies/ephemeral-container.ts +126 -121
  269. package/packages/runtime/src/workspace/strategies/git-worktree.ts +79 -77
  270. package/packages/runtime/src/workspace/strategies/inplace.ts +38 -35
  271. package/packages/runtime/src/workspace/workspace-manager.ts +16 -15
  272. package/packages/runtime/tsconfig.json +17 -17
  273. package/vscode-extension/.vscodeignore +7 -7
  274. package/vscode-extension/LICENSE +21 -0
  275. package/vscode-extension/oxe-agents-1.0.0.vsix +0 -0
  276. package/vscode-extension/oxe-agents-1.4.0.vsix +0 -0
  277. package/vscode-extension/package.json +184 -184
  278. package/vscode-extension/src/extension.js +310 -310
  279. package/vscode-extension/src/shared/contextLoader.js +137 -137
  280. package/vscode-extension/src/shared/contractBuilder.js +159 -159
  281. package/vscode-extension/src/shared/stateReader.js +101 -101
@@ -1,441 +1,722 @@
1
- import { appendEvent } from '../events/bus';
2
- import type { OxeEvent } from '../events/envelope';
3
- import type { EventInput } from '../events/bus';
4
- import type { ExecutionGraph, GraphNode } from '../compiler/graph-compiler';
5
- import type { WorkspaceManager, WorkspaceRequest } from '../workspace/workspace-manager';
6
- import type { WorkspaceLease } from '../models/workspace';
7
- import {
8
- saveJournal,
9
- loadJournal,
10
- deleteJournal,
11
- createJournal,
12
- } from './run-journal';
13
- import type { RunJournal } from './run-journal';
14
-
15
- export interface TaskResult {
16
- success: boolean;
17
- failure_class: 'env' | 'policy' | 'test' | 'timeout' | null;
18
- evidence: string[];
19
- output: string;
20
- }
21
-
22
- export interface TaskExecutor {
23
- execute(
24
- node: GraphNode,
25
- lease: WorkspaceLease,
26
- runId: string,
27
- attemptNumber: number
28
- ): Promise<TaskResult>;
29
- }
30
-
31
- export interface SchedulerContext {
32
- projectRoot: string;
33
- sessionId: string | null;
34
- runId: string;
35
- executor: TaskExecutor;
36
- workspaceManager: WorkspaceManager;
37
- onEvent?: (event: OxeEvent) => void;
38
- }
39
-
40
- export interface RunResult {
41
- run_id: string;
42
- status: 'completed' | 'failed' | 'cancelled' | 'paused';
43
- completed: string[];
44
- failed: string[];
45
- blocked: string[];
46
- }
47
-
48
- type NodeStatus = 'pending' | 'ready' | 'running' | 'completed' | 'failed' | 'blocked';
49
-
50
- export class Scheduler {
51
- private cancelled = false;
52
- private paused = false;
53
- private journal: RunJournal | null = null;
54
- private ctx: SchedulerContext | null = null;
55
-
56
- async run(graph: ExecutionGraph, ctx: SchedulerContext): Promise<RunResult> {
57
- this.cancelled = false;
58
- this.paused = false;
59
- this.ctx = ctx;
60
-
61
- const status = new Map<string, NodeStatus>();
62
- for (const id of graph.nodes.keys()) status.set(id, 'pending');
63
-
64
- const completed: string[] = [];
65
- const failed: string[] = [];
66
- const blocked: string[] = [];
67
-
68
- this.journal = createJournal(ctx.runId);
69
- saveJournal(ctx.projectRoot, ctx.runId, this.journal);
70
-
71
- this.emit(ctx, { type: 'RunStarted', payload: { run_id: ctx.runId } });
72
-
73
- for (const wave of graph.waves) {
74
- if (this.cancelled) break;
75
-
76
- // Respect pause: persist journal and return paused result
77
- if (this.paused) {
78
- this.journal.scheduler_state = 'paused';
79
- this.journal.paused_at = new Date().toISOString();
80
- this.journal.completed_work_items = completed.slice();
81
- this.journal.failed_work_items = failed.slice();
82
- this.journal.blocked_work_items = blocked.slice();
83
- this.journal.partial_result = { run_id: ctx.runId, completed, failed, blocked };
84
- saveJournal(ctx.projectRoot, ctx.runId, this.journal);
85
- return { run_id: ctx.runId, status: 'paused', completed, failed, blocked };
86
- }
87
-
88
- const waveFailed = await this.runWave(
89
- wave.node_ids,
90
- graph,
91
- ctx,
92
- status,
93
- completed,
94
- failed,
95
- blocked
96
- );
97
-
98
- // Sync journal after each wave
99
- this.journal.completed_work_items = completed.slice();
100
- this.journal.failed_work_items = failed.slice();
101
- this.journal.blocked_work_items = blocked.slice();
102
- saveJournal(ctx.projectRoot, ctx.runId, this.journal);
103
-
104
- if (waveFailed) break;
105
- }
106
-
107
- // Any remaining pending nodes become blocked
108
- for (const [id, s] of status) {
109
- if (s === 'pending') {
110
- status.set(id, 'blocked');
111
- blocked.push(id);
112
- this.emit(ctx, {
113
- type: 'WorkItemBlocked',
114
- work_item_id: id,
115
- payload: { reason: 'upstream_wave_failed' },
116
- });
117
- }
118
- }
119
-
120
- const finalStatus: RunResult['status'] = this.cancelled
121
- ? 'cancelled'
122
- : failed.length > 0
123
- ? 'failed'
124
- : 'completed';
125
-
126
- this.emit(ctx, {
127
- type: 'RunCompleted',
128
- payload: { run_id: ctx.runId, status: finalStatus },
129
- });
130
-
131
- this.journal.scheduler_state = this.cancelled ? 'cancelled' : 'completed';
132
- this.journal.completed_work_items = completed.slice();
133
- this.journal.failed_work_items = failed.slice();
134
- this.journal.blocked_work_items = blocked.slice();
135
- saveJournal(ctx.projectRoot, ctx.runId, this.journal);
136
-
137
- return { run_id: ctx.runId, status: finalStatus, completed, failed, blocked };
138
- }
139
-
140
- /**
141
- * Recover a previously paused run by loading its journal and re-running
142
- * only the work items that haven't completed yet.
143
- */
144
- async recover(runId: string, ctx: SchedulerContext, graph: ExecutionGraph): Promise<RunResult | null> {
145
- const journal = loadJournal(ctx.projectRoot, runId);
146
- if (!journal || journal.scheduler_state !== 'paused') return null;
147
-
148
- // Restore state from journal
149
- this.cancelled = false;
150
- this.paused = false;
151
- this.ctx = ctx;
152
- this.journal = { ...journal, scheduler_state: 'running', paused_at: null };
153
-
154
- const restoredCompleted = new Set(journal.completed_work_items);
155
- const restoredFailed = new Set(journal.failed_work_items);
156
- const restoredBlocked = new Set(journal.blocked_work_items);
157
-
158
- const status = new Map<string, NodeStatus>();
159
- for (const id of graph.nodes.keys()) {
160
- if (restoredCompleted.has(id)) status.set(id, 'completed');
161
- else if (restoredFailed.has(id)) status.set(id, 'failed');
162
- else if (restoredBlocked.has(id)) status.set(id, 'blocked');
163
- else status.set(id, 'pending');
164
- }
165
-
166
- const completed = [...restoredCompleted];
167
- const failed = [...restoredFailed];
168
- const blocked = [...restoredBlocked];
169
-
170
- saveJournal(ctx.projectRoot, runId, this.journal);
171
-
172
- this.emit(ctx, { type: 'RunStarted', payload: { run_id: ctx.runId, recovered: true } });
173
-
174
- for (const wave of graph.waves) {
175
- if (this.cancelled) break;
176
- if (this.paused) {
177
- this.journal.scheduler_state = 'paused';
178
- this.journal.paused_at = new Date().toISOString();
179
- this.journal.completed_work_items = completed.slice();
180
- this.journal.failed_work_items = failed.slice();
181
- this.journal.blocked_work_items = blocked.slice();
182
- this.journal.partial_result = { run_id: ctx.runId, completed, failed, blocked };
183
- saveJournal(ctx.projectRoot, ctx.runId, this.journal);
184
- return { run_id: ctx.runId, status: 'paused', completed, failed, blocked };
185
- }
186
-
187
- // Skip waves fully completed
188
- const allDone = wave.node_ids.every(
189
- (id) => restoredCompleted.has(id) || restoredFailed.has(id) || restoredBlocked.has(id)
190
- );
191
- if (allDone) continue;
192
-
193
- const waveFailed = await this.runWave(
194
- wave.node_ids,
195
- graph,
196
- ctx,
197
- status,
198
- completed,
199
- failed,
200
- blocked
201
- );
202
-
203
- this.journal.completed_work_items = completed.slice();
204
- this.journal.failed_work_items = failed.slice();
205
- this.journal.blocked_work_items = blocked.slice();
206
- saveJournal(ctx.projectRoot, ctx.runId, this.journal);
207
-
208
- if (waveFailed) break;
209
- }
210
-
211
- for (const [id, s] of status) {
212
- if (s === 'pending') {
213
- status.set(id, 'blocked');
214
- blocked.push(id);
215
- this.emit(ctx, {
216
- type: 'WorkItemBlocked',
217
- work_item_id: id,
218
- payload: { reason: 'upstream_wave_failed' },
219
- });
220
- }
221
- }
222
-
223
- const finalStatus: RunResult['status'] = this.cancelled
224
- ? 'cancelled'
225
- : failed.length > 0
226
- ? 'failed'
227
- : 'completed';
228
-
229
- this.emit(ctx, {
230
- type: 'RunCompleted',
231
- payload: { run_id: ctx.runId, status: finalStatus, recovered: true },
232
- });
233
-
234
- this.journal.scheduler_state = this.cancelled ? 'cancelled' : 'completed';
235
- this.journal.completed_work_items = completed.slice();
236
- this.journal.failed_work_items = failed.slice();
237
- this.journal.blocked_work_items = blocked.slice();
238
- saveJournal(ctx.projectRoot, ctx.runId, this.journal);
239
- deleteJournal(ctx.projectRoot, ctx.runId);
240
-
241
- return { run_id: ctx.runId, status: finalStatus, completed, failed, blocked };
242
- }
243
-
244
- private async runWave(
245
- nodeIds: string[],
246
- graph: ExecutionGraph,
247
- ctx: SchedulerContext,
248
- status: Map<string, NodeStatus>,
249
- completed: string[],
250
- failed: string[],
251
- blocked: string[]
252
- ): Promise<boolean> {
253
- const eligible: string[] = [];
254
- const depsNotMet: string[] = [];
255
-
256
- for (const id of nodeIds) {
257
- if (status.get(id) === 'completed') continue; // already done in recovery
258
- const node = graph.nodes.get(id)!;
259
- const depsMet = node.depends_on.every((dep) => status.get(dep) === 'completed');
260
- if (depsMet) {
261
- eligible.push(id);
262
- } else {
263
- depsNotMet.push(id);
264
- }
265
- }
266
-
267
- for (const id of depsNotMet) {
268
- status.set(id, 'blocked');
269
- blocked.push(id);
270
- this.emit(ctx, {
271
- type: 'WorkItemBlocked',
272
- work_item_id: id,
273
- payload: { reason: 'dependency_not_met' },
274
- });
275
- }
276
-
277
- const readOnly = eligible.filter((id) => {
278
- const node = graph.nodes.get(id)!;
279
- return node.mutation_scope.length === 0;
280
- });
281
- const mutations = eligible.filter((id) => !readOnly.includes(id));
282
-
283
- if (readOnly.length > 0) {
284
- await Promise.all(
285
- readOnly.map((id) => this.runNode(id, graph, ctx, status, completed, failed))
286
- );
287
- }
288
-
289
- for (const id of mutations) {
290
- if (this.cancelled) break;
291
- await this.runNode(id, graph, ctx, status, completed, failed);
292
- }
293
-
294
- return failed.length > 0;
295
- }
296
-
297
- private async runNode(
298
- nodeId: string,
299
- graph: ExecutionGraph,
300
- ctx: SchedulerContext,
301
- status: Map<string, NodeStatus>,
302
- completed: string[],
303
- failed: string[]
304
- ): Promise<void> {
305
- const node = graph.nodes.get(nodeId)!;
306
- status.set(nodeId, 'running');
307
- this.emit(ctx, {
308
- type: 'WorkItemReady',
309
- work_item_id: nodeId,
310
- payload: { title: node.title, wave: node.wave },
311
- });
312
-
313
- let lease: WorkspaceLease | null = null;
314
- let lastResult: TaskResult | null = null;
315
- const maxAttempts = node.policy.max_retries + 1;
316
-
317
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
318
- const attemptId = `${nodeId}-a${attempt}`;
319
-
320
- this.emit(ctx, {
321
- type: 'AttemptStarted',
322
- work_item_id: nodeId,
323
- attempt_id: attemptId,
324
- payload: { attempt_number: attempt },
325
- });
326
-
327
- try {
328
- const wsReq: WorkspaceRequest = {
329
- work_item_id: nodeId,
330
- attempt_number: attempt,
331
- strategy: node.workspace_strategy,
332
- mutation_scope: node.mutation_scope,
333
- };
334
- lease = await ctx.workspaceManager.allocate(wsReq);
335
- this.emit(ctx, {
336
- type: 'WorkspaceAllocated',
337
- work_item_id: nodeId,
338
- attempt_id: attemptId,
339
- payload: { workspace_id: lease.workspace_id, strategy: lease.strategy },
340
- });
341
-
342
- lastResult = await ctx.executor.execute(node, lease, ctx.runId, attempt);
343
-
344
- if (lastResult.success) {
345
- this.emit(ctx, {
346
- type: 'WorkItemCompleted',
347
- work_item_id: nodeId,
348
- attempt_id: attemptId,
349
- payload: { attempt_number: attempt, evidence: lastResult.evidence },
350
- });
351
- status.set(nodeId, 'completed');
352
- completed.push(nodeId);
353
- return;
354
- }
355
-
356
- if (lastResult.failure_class === 'policy') break;
357
-
358
- if (attempt < maxAttempts) {
359
- this.emit(ctx, {
360
- type: 'RetryScheduled',
361
- work_item_id: nodeId,
362
- payload: { next_attempt: attempt + 1, reason: lastResult.failure_class },
363
- });
364
- }
365
- } catch (err) {
366
- lastResult = {
367
- success: false,
368
- failure_class: 'env',
369
- evidence: [],
370
- output: String(err),
371
- };
372
- if (attempt < maxAttempts) {
373
- this.emit(ctx, {
374
- type: 'RetryScheduled',
375
- work_item_id: nodeId,
376
- payload: { next_attempt: attempt + 1, reason: 'env' },
377
- });
378
- }
379
- } finally {
380
- if (lease) {
381
- await ctx.workspaceManager.dispose(lease.workspace_id).catch(() => {});
382
- lease = null;
383
- }
384
- }
385
- }
386
-
387
- this.emit(ctx, {
388
- type: 'WorkItemBlocked',
389
- work_item_id: nodeId,
390
- payload: { failure_class: lastResult?.failure_class ?? 'env', max_attempts: maxAttempts },
391
- });
392
- status.set(nodeId, 'failed');
393
- failed.push(nodeId);
394
- }
395
-
396
- pause(): void {
397
- this.paused = true;
398
- if (this.journal && this.ctx) {
399
- this.journal.scheduler_state = 'paused';
400
- this.journal.paused_at = new Date().toISOString();
401
- saveJournal(this.ctx.projectRoot, this.ctx.runId, this.journal);
402
- }
403
- }
404
-
405
- resume(): void {
406
- this.paused = false;
407
- if (this.journal && this.ctx) {
408
- this.journal.scheduler_state = 'running';
409
- this.journal.paused_at = null;
410
- saveJournal(this.ctx.projectRoot, this.ctx.runId, this.journal);
411
- }
412
- }
413
-
414
- cancel(): void {
415
- this.cancelled = true;
416
- if (this.journal && this.ctx) {
417
- this.journal.cancelled = true;
418
- this.journal.scheduler_state = 'cancelled';
419
- saveJournal(this.ctx.projectRoot, this.ctx.runId, this.journal);
420
- }
421
- }
422
-
423
- getJournal(): RunJournal | null {
424
- return this.journal;
425
- }
426
-
427
- static loadJournal(projectRoot: string, runId: string): RunJournal | null {
428
- return loadJournal(projectRoot, runId);
429
- }
430
-
431
- private emit(
432
- ctx: SchedulerContext,
433
- input: EventInput
434
- ): void {
435
- const event = appendEvent(ctx.projectRoot, ctx.sessionId, {
436
- run_id: ctx.runId,
437
- ...input,
438
- });
439
- ctx.onEvent?.(event);
440
- }
441
- }
1
+ import { appendEvent } from '../events/bus';
2
+ import type { OxeEvent } from '../events/envelope';
3
+ import type { EventInput } from '../events/bus';
4
+ import type { ExecutionGraph, GraphNode } from '../compiler/graph-compiler';
5
+ import type { WorkspaceManager, WorkspaceRequest } from '../workspace/workspace-manager';
6
+ import type { WorkspaceLease } from '../models/workspace';
7
+ import type { GateManager } from '../gate/gate-manager';
8
+ import type { PolicyEngine, PersistedPolicyDecision, PolicyContext } from '../policy/policy-engine';
9
+ import { savePolicyDecision } from '../policy/policy-engine';
10
+ import type { PluginRegistry } from '../plugins/plugin-registry';
11
+ import type { AuditTrail } from '../audit/audit-trail';
12
+ import type { RunQuota, QuotaViolation } from '../audit/audit-trail';
13
+ import { checkQuota, consumeQuota } from '../audit/audit-trail';
14
+ import {
15
+ saveJournal,
16
+ loadJournal,
17
+ deleteJournal,
18
+ createJournal,
19
+ } from './run-journal';
20
+ import type { RunJournal } from './run-journal';
21
+
22
+ export interface TaskResult {
23
+ success: boolean;
24
+ failure_class: 'env' | 'policy' | 'test' | 'timeout' | null;
25
+ evidence: string[];
26
+ output: string;
27
+ }
28
+
29
+ export interface TaskExecutor {
30
+ execute(
31
+ node: GraphNode,
32
+ lease: WorkspaceLease,
33
+ runId: string,
34
+ attemptNumber: number
35
+ ): Promise<TaskResult>;
36
+ }
37
+
38
+ export interface SchedulerContext {
39
+ projectRoot: string;
40
+ sessionId: string | null;
41
+ runId: string;
42
+ executor: TaskExecutor;
43
+ workspaceManager: WorkspaceManager;
44
+ gateManager?: GateManager;
45
+ policyEngine?: PolicyEngine;
46
+ pluginRegistry?: PluginRegistry;
47
+ auditTrail?: AuditTrail;
48
+ quota?: RunQuota;
49
+ policyActor?: string;
50
+ onEvent?: (event: OxeEvent) => void;
51
+ }
52
+
53
+ export interface RunResult {
54
+ run_id: string;
55
+ status: 'completed' | 'failed' | 'blocked' | 'cancelled' | 'paused';
56
+ completed: string[];
57
+ failed: string[];
58
+ blocked: string[];
59
+ pending_gates?: string[];
60
+ }
61
+
62
+ type NodeStatus = 'pending' | 'ready' | 'running' | 'completed' | 'failed' | 'blocked';
63
+
64
+ export class Scheduler {
65
+ private cancelled = false;
66
+ private paused = false;
67
+ private journal: RunJournal | null = null;
68
+ private ctx: SchedulerContext | null = null;
69
+
70
+ async run(graph: ExecutionGraph, ctx: SchedulerContext): Promise<RunResult> {
71
+ this.cancelled = false;
72
+ this.paused = false;
73
+ this.ctx = ctx;
74
+
75
+ const status = new Map<string, NodeStatus>();
76
+ for (const id of graph.nodes.keys()) status.set(id, 'pending');
77
+
78
+ const completed: string[] = [];
79
+ const failed: string[] = [];
80
+ const blocked: string[] = [];
81
+
82
+ this.journal = createJournal(ctx.runId);
83
+ saveJournal(ctx.projectRoot, ctx.runId, this.journal);
84
+
85
+ this.emit(ctx, { type: 'RunStarted', payload: { run_id: ctx.runId } });
86
+ ctx.auditTrail?.record('run_started', ctx.policyActor ?? 'runtime', {
87
+ runId: ctx.runId,
88
+ detail: { session_id: ctx.sessionId ?? null },
89
+ });
90
+
91
+ for (const wave of graph.waves) {
92
+ if (this.cancelled) break;
93
+
94
+ // Respect pause: persist journal and return paused result
95
+ if (this.paused) {
96
+ this.journal.scheduler_state = 'paused';
97
+ this.journal.paused_at = new Date().toISOString();
98
+ this.journal.completed_work_items = completed.slice();
99
+ this.journal.failed_work_items = failed.slice();
100
+ this.journal.blocked_work_items = blocked.slice();
101
+ this.journal.partial_result = { run_id: ctx.runId, completed, failed, blocked };
102
+ saveJournal(ctx.projectRoot, ctx.runId, this.journal);
103
+ ctx.auditTrail?.record('run_paused', ctx.policyActor ?? 'runtime', {
104
+ runId: ctx.runId,
105
+ detail: { completed, failed, blocked },
106
+ });
107
+ return { run_id: ctx.runId, status: 'paused', completed, failed, blocked, pending_gates: this.journal.pending_gates.slice() };
108
+ }
109
+
110
+ const waveFailed = await this.runWave(
111
+ wave.node_ids,
112
+ graph,
113
+ ctx,
114
+ status,
115
+ completed,
116
+ failed,
117
+ blocked
118
+ );
119
+
120
+ // Sync journal after each wave
121
+ this.journal.completed_work_items = completed.slice();
122
+ this.journal.failed_work_items = failed.slice();
123
+ this.journal.blocked_work_items = blocked.slice();
124
+ saveJournal(ctx.projectRoot, ctx.runId, this.journal);
125
+
126
+ if (waveFailed) break;
127
+ }
128
+
129
+ // Any remaining pending nodes become blocked
130
+ for (const [id, s] of status) {
131
+ if (s === 'pending') {
132
+ status.set(id, 'blocked');
133
+ blocked.push(id);
134
+ this.emit(ctx, {
135
+ type: 'WorkItemBlocked',
136
+ work_item_id: id,
137
+ payload: { reason: 'upstream_wave_failed' },
138
+ });
139
+ }
140
+ }
141
+
142
+ const finalStatus: RunResult['status'] = this.cancelled
143
+ ? 'cancelled'
144
+ : failed.length > 0
145
+ ? 'failed'
146
+ : blocked.length > 0
147
+ ? 'blocked'
148
+ : 'completed';
149
+
150
+ this.emit(ctx, {
151
+ type: 'RunCompleted',
152
+ payload: { run_id: ctx.runId, status: finalStatus },
153
+ });
154
+ ctx.auditTrail?.record('run_completed', ctx.policyActor ?? 'runtime', {
155
+ runId: ctx.runId,
156
+ detail: {
157
+ status: finalStatus,
158
+ completed: completed.length,
159
+ failed: failed.length,
160
+ blocked: blocked.length,
161
+ pending_gates: this.journal.pending_gates.slice(),
162
+ },
163
+ });
164
+
165
+ this.journal.scheduler_state = this.cancelled ? 'cancelled' : finalStatus === 'blocked' ? 'blocked' : 'completed';
166
+ this.journal.completed_work_items = completed.slice();
167
+ this.journal.failed_work_items = failed.slice();
168
+ this.journal.blocked_work_items = blocked.slice();
169
+ saveJournal(ctx.projectRoot, ctx.runId, this.journal);
170
+
171
+ return {
172
+ run_id: ctx.runId,
173
+ status: finalStatus,
174
+ completed,
175
+ failed,
176
+ blocked,
177
+ pending_gates: this.journal.pending_gates.slice(),
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Recover a previously paused run by loading its journal and re-running
183
+ * only the work items that haven't completed yet.
184
+ */
185
+ async recover(runId: string, ctx: SchedulerContext, graph: ExecutionGraph): Promise<RunResult | null> {
186
+ const journal = loadJournal(ctx.projectRoot, runId);
187
+ if (!journal || journal.scheduler_state !== 'paused') return null;
188
+
189
+ // Restore state from journal
190
+ this.cancelled = false;
191
+ this.paused = false;
192
+ this.ctx = ctx;
193
+ this.journal = { ...journal, scheduler_state: 'running', paused_at: null };
194
+
195
+ const restoredCompleted = new Set(journal.completed_work_items);
196
+ const restoredFailed = new Set(journal.failed_work_items);
197
+ const restoredBlocked = new Set(journal.blocked_work_items);
198
+
199
+ const status = new Map<string, NodeStatus>();
200
+ for (const id of graph.nodes.keys()) {
201
+ if (restoredCompleted.has(id)) status.set(id, 'completed');
202
+ else if (restoredFailed.has(id)) status.set(id, 'failed');
203
+ else if (restoredBlocked.has(id)) status.set(id, 'blocked');
204
+ else status.set(id, 'pending');
205
+ }
206
+
207
+ const completed = [...restoredCompleted];
208
+ const failed = [...restoredFailed];
209
+ const blocked = [...restoredBlocked];
210
+
211
+ saveJournal(ctx.projectRoot, runId, this.journal);
212
+
213
+ this.emit(ctx, { type: 'RunStarted', payload: { run_id: ctx.runId, recovered: true } });
214
+
215
+ for (const wave of graph.waves) {
216
+ if (this.cancelled) break;
217
+ if (this.paused) {
218
+ this.journal.scheduler_state = 'paused';
219
+ this.journal.paused_at = new Date().toISOString();
220
+ this.journal.completed_work_items = completed.slice();
221
+ this.journal.failed_work_items = failed.slice();
222
+ this.journal.blocked_work_items = blocked.slice();
223
+ this.journal.partial_result = { run_id: ctx.runId, completed, failed, blocked };
224
+ saveJournal(ctx.projectRoot, ctx.runId, this.journal);
225
+ ctx.auditTrail?.record('run_paused', ctx.policyActor ?? 'runtime', {
226
+ runId: ctx.runId,
227
+ detail: { recovered: true, completed, failed, blocked },
228
+ });
229
+ return { run_id: ctx.runId, status: 'paused', completed, failed, blocked, pending_gates: this.journal.pending_gates.slice() };
230
+ }
231
+
232
+ // Skip waves fully completed
233
+ const allDone = wave.node_ids.every(
234
+ (id) => restoredCompleted.has(id) || restoredFailed.has(id) || restoredBlocked.has(id)
235
+ );
236
+ if (allDone) continue;
237
+
238
+ const waveFailed = await this.runWave(
239
+ wave.node_ids,
240
+ graph,
241
+ ctx,
242
+ status,
243
+ completed,
244
+ failed,
245
+ blocked
246
+ );
247
+
248
+ this.journal.completed_work_items = completed.slice();
249
+ this.journal.failed_work_items = failed.slice();
250
+ this.journal.blocked_work_items = blocked.slice();
251
+ saveJournal(ctx.projectRoot, ctx.runId, this.journal);
252
+
253
+ if (waveFailed) break;
254
+ }
255
+
256
+ for (const [id, s] of status) {
257
+ if (s === 'pending') {
258
+ status.set(id, 'blocked');
259
+ blocked.push(id);
260
+ this.emit(ctx, {
261
+ type: 'WorkItemBlocked',
262
+ work_item_id: id,
263
+ payload: { reason: 'upstream_wave_failed' },
264
+ });
265
+ }
266
+ }
267
+
268
+ const finalStatus: RunResult['status'] = this.cancelled
269
+ ? 'cancelled'
270
+ : failed.length > 0
271
+ ? 'failed'
272
+ : blocked.length > 0
273
+ ? 'blocked'
274
+ : 'completed';
275
+
276
+ this.emit(ctx, {
277
+ type: 'RunCompleted',
278
+ payload: { run_id: ctx.runId, status: finalStatus, recovered: true },
279
+ });
280
+ ctx.auditTrail?.record('run_recovered', ctx.policyActor ?? 'runtime', {
281
+ runId: ctx.runId,
282
+ detail: {
283
+ status: finalStatus,
284
+ completed: completed.length,
285
+ failed: failed.length,
286
+ blocked: blocked.length,
287
+ pending_gates: this.journal.pending_gates.slice(),
288
+ },
289
+ });
290
+
291
+ this.journal.scheduler_state = this.cancelled ? 'cancelled' : finalStatus === 'blocked' ? 'blocked' : 'completed';
292
+ this.journal.completed_work_items = completed.slice();
293
+ this.journal.failed_work_items = failed.slice();
294
+ this.journal.blocked_work_items = blocked.slice();
295
+ saveJournal(ctx.projectRoot, ctx.runId, this.journal);
296
+ deleteJournal(ctx.projectRoot, ctx.runId);
297
+
298
+ return {
299
+ run_id: ctx.runId,
300
+ status: finalStatus,
301
+ completed,
302
+ failed,
303
+ blocked,
304
+ pending_gates: this.journal.pending_gates.slice(),
305
+ };
306
+ }
307
+
308
+ private async runWave(
309
+ nodeIds: string[],
310
+ graph: ExecutionGraph,
311
+ ctx: SchedulerContext,
312
+ status: Map<string, NodeStatus>,
313
+ completed: string[],
314
+ failed: string[],
315
+ blocked: string[]
316
+ ): Promise<boolean> {
317
+ const eligible: string[] = [];
318
+ const depsNotMet: string[] = [];
319
+
320
+ for (const id of nodeIds) {
321
+ if (status.get(id) === 'completed') continue; // already done in recovery
322
+ const node = graph.nodes.get(id)!;
323
+ const depsMet = node.depends_on.every((dep) => status.get(dep) === 'completed');
324
+ if (depsMet) {
325
+ eligible.push(id);
326
+ } else {
327
+ depsNotMet.push(id);
328
+ }
329
+ }
330
+
331
+ for (const id of depsNotMet) {
332
+ status.set(id, 'blocked');
333
+ blocked.push(id);
334
+ this.emit(ctx, {
335
+ type: 'WorkItemBlocked',
336
+ work_item_id: id,
337
+ payload: { reason: 'dependency_not_met' },
338
+ });
339
+ }
340
+
341
+ const readOnly = eligible.filter((id) => {
342
+ const node = graph.nodes.get(id)!;
343
+ return node.mutation_scope.length === 0;
344
+ });
345
+ const mutations = eligible.filter((id) => !readOnly.includes(id));
346
+
347
+ if (readOnly.length > 0) {
348
+ await Promise.all(
349
+ readOnly.map((id) => this.runNode(id, graph, ctx, status, completed, failed, blocked))
350
+ );
351
+ }
352
+
353
+ for (const id of mutations) {
354
+ if (this.cancelled) break;
355
+ await this.runNode(id, graph, ctx, status, completed, failed, blocked);
356
+ }
357
+
358
+ return failed.length > 0;
359
+ }
360
+
361
+ private async runNode(
362
+ nodeId: string,
363
+ graph: ExecutionGraph,
364
+ ctx: SchedulerContext,
365
+ status: Map<string, NodeStatus>,
366
+ completed: string[],
367
+ failed: string[],
368
+ blocked: string[]
369
+ ): Promise<void> {
370
+ const node = graph.nodes.get(nodeId)!;
371
+ status.set(nodeId, 'running');
372
+ this.emit(ctx, {
373
+ type: 'WorkItemReady',
374
+ work_item_id: nodeId,
375
+ payload: { title: node.title, wave: node.wave },
376
+ });
377
+
378
+ let lease: WorkspaceLease | null = null;
379
+ let lastResult: TaskResult | null = null;
380
+ const maxAttempts = node.policy.max_retries + 1;
381
+ const quotaBlocked = this.consumeQuotaForNode(ctx, node);
382
+ if (quotaBlocked) {
383
+ this.blockNode(nodeId, ctx, status, blocked, 'quota_exceeded', quotaBlocked);
384
+ return;
385
+ }
386
+
387
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
388
+ const attemptId = `${nodeId}-a${attempt}`;
389
+
390
+ this.emit(ctx, {
391
+ type: 'AttemptStarted',
392
+ work_item_id: nodeId,
393
+ attempt_id: attemptId,
394
+ payload: { attempt_number: attempt },
395
+ });
396
+
397
+ try {
398
+ const policyDecision = this.evaluatePolicyForNode(node, ctx);
399
+ if (policyDecision && !policyDecision.allowed) {
400
+ this.emit(ctx, {
401
+ type: 'PolicyEvaluated',
402
+ work_item_id: nodeId,
403
+ attempt_id: attemptId,
404
+ payload: { ...policyDecision },
405
+ });
406
+ ctx.auditTrail?.record('policy_denied', ctx.policyActor ?? 'runtime', {
407
+ runId: ctx.runId,
408
+ workItemId: nodeId,
409
+ detail: { reason: policyDecision.reason, rule_id: policyDecision.rule_id },
410
+ });
411
+ this.blockNode(nodeId, ctx, status, blocked, 'policy_denied', policyDecision.reason);
412
+ return;
413
+ }
414
+
415
+ if (policyDecision) {
416
+ this.emit(ctx, {
417
+ type: 'PolicyEvaluated',
418
+ work_item_id: nodeId,
419
+ attempt_id: attemptId,
420
+ payload: { ...policyDecision },
421
+ });
422
+ }
423
+
424
+ if (policyDecision?.gate_required || node.policy.requires_human_approval) {
425
+ const gateId = await this.requestGateForNode(node, ctx, policyDecision);
426
+ this.blockNode(nodeId, ctx, status, blocked, 'pending_gate', gateId);
427
+ return;
428
+ }
429
+
430
+ const wsReq: WorkspaceRequest = {
431
+ work_item_id: nodeId,
432
+ attempt_number: attempt,
433
+ strategy: node.workspace_strategy,
434
+ mutation_scope: node.mutation_scope,
435
+ };
436
+ const workspaceManager = ctx.pluginRegistry?.workspaceProviderFor(node.workspace_strategy) ?? ctx.workspaceManager;
437
+ lease = await workspaceManager.allocate(wsReq);
438
+ this.emit(ctx, {
439
+ type: 'WorkspaceAllocated',
440
+ work_item_id: nodeId,
441
+ attempt_id: attemptId,
442
+ payload: { workspace_id: lease.workspace_id, strategy: lease.strategy },
443
+ });
444
+
445
+ lastResult = await this.executeNode(node, lease, ctx, attempt, attemptId);
446
+
447
+ if (lastResult.success) {
448
+ this.emit(ctx, {
449
+ type: 'WorkItemCompleted',
450
+ work_item_id: nodeId,
451
+ attempt_id: attemptId,
452
+ payload: { attempt_number: attempt, evidence: lastResult.evidence },
453
+ });
454
+ status.set(nodeId, 'completed');
455
+ completed.push(nodeId);
456
+ return;
457
+ }
458
+
459
+ if (lastResult.failure_class === 'policy') break;
460
+
461
+ if (attempt < maxAttempts) {
462
+ const retryBlocked = this.consumeRetryQuota(ctx);
463
+ if (retryBlocked) {
464
+ this.blockNode(nodeId, ctx, status, blocked, 'quota_exceeded', retryBlocked);
465
+ return;
466
+ }
467
+ this.emit(ctx, {
468
+ type: 'RetryScheduled',
469
+ work_item_id: nodeId,
470
+ payload: { next_attempt: attempt + 1, reason: lastResult.failure_class },
471
+ });
472
+ }
473
+ } catch (err) {
474
+ lastResult = {
475
+ success: false,
476
+ failure_class: 'env',
477
+ evidence: [],
478
+ output: String(err),
479
+ };
480
+ if (attempt < maxAttempts) {
481
+ this.emit(ctx, {
482
+ type: 'RetryScheduled',
483
+ work_item_id: nodeId,
484
+ payload: { next_attempt: attempt + 1, reason: 'env' },
485
+ });
486
+ }
487
+ } finally {
488
+ if (lease) {
489
+ await ctx.workspaceManager.dispose(lease.workspace_id).catch(() => {});
490
+ lease = null;
491
+ }
492
+ }
493
+ }
494
+
495
+ this.emit(ctx, {
496
+ type: 'WorkItemBlocked',
497
+ work_item_id: nodeId,
498
+ payload: { failure_class: lastResult?.failure_class ?? 'env', max_attempts: maxAttempts },
499
+ });
500
+ status.set(nodeId, 'failed');
501
+ failed.push(nodeId);
502
+ }
503
+
504
+ pause(): void {
505
+ this.paused = true;
506
+ if (this.journal && this.ctx) {
507
+ this.journal.scheduler_state = 'paused';
508
+ this.journal.paused_at = new Date().toISOString();
509
+ saveJournal(this.ctx.projectRoot, this.ctx.runId, this.journal);
510
+ }
511
+ }
512
+
513
+ resume(): void {
514
+ this.paused = false;
515
+ if (this.journal && this.ctx) {
516
+ this.journal.scheduler_state = 'running';
517
+ this.journal.paused_at = null;
518
+ saveJournal(this.ctx.projectRoot, this.ctx.runId, this.journal);
519
+ }
520
+ }
521
+
522
+ cancel(): void {
523
+ this.cancelled = true;
524
+ if (this.journal && this.ctx) {
525
+ this.journal.cancelled = true;
526
+ this.journal.scheduler_state = 'cancelled';
527
+ saveJournal(this.ctx.projectRoot, this.ctx.runId, this.journal);
528
+ }
529
+ }
530
+
531
+ getJournal(): RunJournal | null {
532
+ return this.journal;
533
+ }
534
+
535
+ static loadJournal(projectRoot: string, runId: string): RunJournal | null {
536
+ return loadJournal(projectRoot, runId);
537
+ }
538
+
539
+ private async executeNode(
540
+ node: GraphNode,
541
+ lease: WorkspaceLease,
542
+ ctx: SchedulerContext,
543
+ attempt: number,
544
+ attemptId: string
545
+ ): Promise<TaskResult> {
546
+ const primaryAction = pickPrimaryAction(node, ctx.pluginRegistry);
547
+ const provider = primaryAction ? ctx.pluginRegistry?.toolProviderFor(primaryAction.type) : null;
548
+ if (!provider || !primaryAction) {
549
+ return ctx.executor.execute(node, lease, ctx.runId, attempt);
550
+ }
551
+
552
+ ctx.auditTrail?.record('plugin_invoked', ctx.policyActor ?? 'runtime', {
553
+ runId: ctx.runId,
554
+ workItemId: node.id,
555
+ resource: provider.name,
556
+ detail: { action_type: primaryAction.type, attempt_id: attemptId },
557
+ });
558
+ this.emit(ctx, {
559
+ type: 'ToolInvoked',
560
+ work_item_id: node.id,
561
+ attempt_id: attemptId,
562
+ payload: { provider: provider.name, action_type: primaryAction.type },
563
+ });
564
+
565
+ const result = await provider.invoke({
566
+ action_type: primaryAction.type,
567
+ work_item_id: node.id,
568
+ run_id: ctx.runId,
569
+ attempt_id: attemptId,
570
+ params: {
571
+ command: primaryAction.command ?? null,
572
+ targets: primaryAction.targets ?? [],
573
+ },
574
+ workspace_root: lease.root_path,
575
+ });
576
+
577
+ this.emit(ctx, {
578
+ type: result.success ? 'ToolCompleted' : 'ToolFailed',
579
+ work_item_id: node.id,
580
+ attempt_id: attemptId,
581
+ payload: {
582
+ provider: provider.name,
583
+ action_type: primaryAction.type,
584
+ evidence_paths: result.evidence_paths,
585
+ side_effects_applied: result.side_effects_applied,
586
+ error: result.error ?? null,
587
+ },
588
+ });
589
+
590
+ return {
591
+ success: result.success,
592
+ failure_class: result.success ? null : provider.kind === 'external_operation' ? 'policy' : 'env',
593
+ evidence: result.evidence_paths,
594
+ output: result.output,
595
+ };
596
+ }
597
+
598
+ private evaluatePolicyForNode(node: GraphNode, ctx: SchedulerContext): PersistedPolicyDecision | null {
599
+ if (!ctx.policyEngine) return null;
600
+ const primaryAction = pickPrimaryAction(node, ctx.pluginRegistry);
601
+ const decisionContext: PolicyContext = {
602
+ tool: primaryAction?.type ?? 'custom',
603
+ kind: node.workspace_strategy,
604
+ mutation_scope: node.mutation_scope,
605
+ affected_paths: node.mutation_scope,
606
+ side_effect_class: inferSideEffectClass(node),
607
+ mutation_count: node.mutation_scope.length,
608
+ node_policy: {
609
+ max_retries: node.policy.max_retries,
610
+ },
611
+ };
612
+ const evaluated = ctx.policyEngine.evaluate(decisionContext);
613
+ const persisted: PersistedPolicyDecision = {
614
+ ...evaluated,
615
+ run_id: ctx.runId,
616
+ work_item_id: node.id,
617
+ action: primaryAction?.type ?? 'custom',
618
+ actor: ctx.policyActor ?? 'runtime',
619
+ override: false,
620
+ rationale: null,
621
+ context: decisionContext,
622
+ };
623
+ savePolicyDecision(ctx.projectRoot, persisted);
624
+ return persisted;
625
+ }
626
+
627
+ private async requestGateForNode(
628
+ node: GraphNode,
629
+ ctx: SchedulerContext,
630
+ decision: PersistedPolicyDecision | null
631
+ ): Promise<string> {
632
+ if (!ctx.gateManager) return 'gate-missing-manager';
633
+ const scope = inferGateScope(node);
634
+ const primaryAction = pickPrimaryAction(node, ctx.pluginRegistry);
635
+ const gate = await ctx.gateManager.request(scope, {
636
+ run_id: ctx.runId,
637
+ work_item_id: node.id,
638
+ action: primaryAction?.type ?? 'custom',
639
+ description: `Gate required before executing ${node.id}`,
640
+ evidence_refs: [],
641
+ risks: [decision?.reason ?? 'human approval required'],
642
+ rationale: decision?.reason ?? 'node policy requires approval',
643
+ policy_decision_id: decision?.decision_id ?? null,
644
+ });
645
+ if (this.journal && !this.journal.pending_gates.includes(gate.gate_id)) {
646
+ this.journal.pending_gates.push(gate.gate_id);
647
+ }
648
+ ctx.auditTrail?.record('gate_requested', ctx.policyActor ?? 'runtime', {
649
+ runId: ctx.runId,
650
+ workItemId: node.id,
651
+ detail: { gate_id: gate.gate_id, scope: gate.scope, action: gate.action },
652
+ });
653
+ return gate.gate_id;
654
+ }
655
+
656
+ private blockNode(
657
+ nodeId: string,
658
+ ctx: SchedulerContext,
659
+ status: Map<string, NodeStatus>,
660
+ blocked: string[],
661
+ reason: string,
662
+ detail: string | QuotaViolation | null = null
663
+ ): void {
664
+ this.emit(ctx, {
665
+ type: 'WorkItemBlocked',
666
+ work_item_id: nodeId,
667
+ payload: { reason, detail },
668
+ });
669
+ status.set(nodeId, 'blocked');
670
+ if (!blocked.includes(nodeId)) blocked.push(nodeId);
671
+ }
672
+
673
+ private consumeQuotaForNode(ctx: SchedulerContext, node: GraphNode): string | null {
674
+ if (!ctx.quota) return null;
675
+ let quota = consumeQuota(ctx.quota, 'work_items', 1);
676
+ if (node.mutation_scope.length > 0) {
677
+ quota = consumeQuota(quota, 'mutations', 1);
678
+ }
679
+ const violation = checkQuota(quota);
680
+ ctx.quota = quota;
681
+ return violation ? `${violation.quota_type}:${violation.consumed}/${violation.limit}` : null;
682
+ }
683
+
684
+ private consumeRetryQuota(ctx: SchedulerContext): string | null {
685
+ if (!ctx.quota) return null;
686
+ ctx.quota = consumeQuota(ctx.quota, 'retries', 1);
687
+ const violation = checkQuota(ctx.quota);
688
+ return violation ? `${violation.quota_type}:${violation.consumed}/${violation.limit}` : null;
689
+ }
690
+
691
+ private emit(
692
+ ctx: SchedulerContext,
693
+ input: EventInput
694
+ ): void {
695
+ const event = appendEvent(ctx.projectRoot, ctx.sessionId, {
696
+ run_id: ctx.runId,
697
+ ...input,
698
+ });
699
+ ctx.onEvent?.(event);
700
+ }
701
+ }
702
+
703
+ function inferSideEffectClass(node: GraphNode): PolicyContext['side_effect_class'] {
704
+ if (node.mutation_scope.length > 0) return 'write_fs';
705
+ if (node.actions.some((action) => action.type === 'run_tests' || action.type === 'run_lint')) return 'spawn_process';
706
+ return 'read_fs';
707
+ }
708
+
709
+ function pickPrimaryAction(node: GraphNode, pluginRegistry?: PluginRegistry): GraphNode['actions'][number] | undefined {
710
+ const candidateActions = node.actions.filter((action) => action.type !== 'collect_evidence');
711
+ const preferredMutation = candidateActions.find((action) => action.type === 'generate_patch')
712
+ || candidateActions.find((action) => action.type === 'run_tests')
713
+ || candidateActions[0];
714
+ if (!pluginRegistry) return preferredMutation;
715
+ return candidateActions.find((action) => pluginRegistry.toolProviderFor(action.type)) || preferredMutation;
716
+ }
717
+
718
+ function inferGateScope(node: GraphNode): 'critical_mutation' | 'security' | 'plan_approval' {
719
+ if (node.mutation_scope.length > 0) return 'critical_mutation';
720
+ if (node.actions.some((action) => action.type === 'collect_evidence')) return 'security';
721
+ return 'plan_approval';
722
+ }