oxe-cc 1.6.0 → 1.7.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 (105) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +5 -3
  3. package/bin/lib/oxe-agent-install.cjs +125 -24
  4. package/bin/lib/oxe-release.cjs +1 -0
  5. package/bin/oxe-cc.js +87 -39
  6. package/commands/oxe/debug.md +6 -1
  7. package/commands/oxe/discuss.md +7 -2
  8. package/commands/oxe/execute.md +7 -2
  9. package/commands/oxe/plan-agent.md +7 -2
  10. package/commands/oxe/plan.md +7 -2
  11. package/commands/oxe/scan.md +6 -1
  12. package/commands/oxe/spec.md +6 -1
  13. package/commands/oxe/verify.md +6 -1
  14. package/docs/CONTENT-MIGRATION-AUDIT.md +49 -0
  15. package/docs/RUNTIME-SMOKE-MATRIX.md +1 -1
  16. package/lib/runtime/compiler/graph-compiler.js +32 -0
  17. package/lib/runtime/context/context-pack-builder.d.ts +15 -0
  18. package/lib/runtime/context/context-pack-builder.js +78 -0
  19. package/lib/runtime/events/catalog.d.ts +1 -1
  20. package/lib/runtime/events/catalog.js +5 -0
  21. package/lib/runtime/executor/action-tool-map.d.ts +3 -0
  22. package/lib/runtime/executor/action-tool-map.js +41 -0
  23. package/lib/runtime/executor/built-in-tools.d.ts +8 -0
  24. package/lib/runtime/executor/built-in-tools.js +267 -0
  25. package/lib/runtime/executor/index.d.ts +6 -0
  26. package/lib/runtime/executor/index.js +12 -0
  27. package/lib/runtime/executor/llm-task-executor.d.ts +29 -0
  28. package/lib/runtime/executor/llm-task-executor.js +138 -0
  29. package/lib/runtime/executor/node-prompt-builder.d.ts +3 -0
  30. package/lib/runtime/executor/node-prompt-builder.js +36 -0
  31. package/lib/runtime/executor/stream-completion.d.ts +38 -0
  32. package/lib/runtime/executor/stream-completion.js +105 -0
  33. package/lib/runtime/index.d.ts +1 -0
  34. package/lib/runtime/index.js +2 -0
  35. package/lib/runtime/models/failure.d.ts +5 -0
  36. package/lib/runtime/models/failure.js +2 -0
  37. package/lib/runtime/plugins/capability-adapter.d.ts +9 -0
  38. package/lib/runtime/plugins/capability-adapter.js +111 -8
  39. package/lib/runtime/plugins/plugin-abi.d.ts +8 -0
  40. package/lib/runtime/plugins/plugin-registry.d.ts +2 -1
  41. package/lib/runtime/plugins/plugin-registry.js +6 -1
  42. package/lib/runtime/reducers/run-state-reducer.js +39 -2
  43. package/lib/runtime/scheduler/scheduler.d.ts +14 -2
  44. package/lib/runtime/scheduler/scheduler.js +131 -11
  45. package/lib/runtime/verification/verification-manifest.d.ts +5 -2
  46. package/oxe/agents/oxe-assumptions-analyzer.md +136 -0
  47. package/oxe/agents/oxe-codebase-mapper.md +142 -0
  48. package/oxe/agents/oxe-debugger.md +145 -0
  49. package/oxe/agents/oxe-executor.md +139 -0
  50. package/oxe/agents/oxe-integration-checker.md +142 -0
  51. package/oxe/agents/oxe-plan-checker.md +143 -0
  52. package/oxe/agents/oxe-planner.md +151 -0
  53. package/oxe/agents/oxe-research-synthesizer.md +146 -0
  54. package/oxe/agents/oxe-researcher.md +163 -0
  55. package/oxe/agents/oxe-ui-auditor.md +151 -0
  56. package/oxe/agents/oxe-ui-checker.md +157 -0
  57. package/oxe/agents/oxe-ui-researcher.md +179 -0
  58. package/oxe/agents/oxe-validation-auditor.md +154 -0
  59. package/oxe/agents/oxe-verifier.md +132 -0
  60. package/oxe/personas/README.md +91 -39
  61. package/oxe/personas/architect.md +149 -37
  62. package/oxe/personas/db-specialist.md +149 -36
  63. package/oxe/personas/debugger.md +155 -38
  64. package/oxe/personas/executor.md +164 -38
  65. package/oxe/personas/planner.md +165 -36
  66. package/oxe/personas/researcher.md +148 -35
  67. package/oxe/personas/ui-specialist.md +164 -36
  68. package/oxe/personas/verifier.md +174 -39
  69. package/oxe/templates/FIXTURE-PACK.template.json +18 -11
  70. package/oxe/templates/FIXTURE-PACK.template.md +19 -10
  71. package/oxe/templates/IMPLEMENTATION-PACK.template.json +26 -10
  72. package/oxe/templates/IMPLEMENTATION-PACK.template.md +32 -20
  73. package/oxe/templates/PLAN.template.md +62 -31
  74. package/oxe/templates/REFERENCE-ANCHORS.template.md +14 -10
  75. package/oxe/templates/SUMMARY.template.md +50 -20
  76. package/oxe/workflows/debug.md +9 -7
  77. package/oxe/workflows/execute.md +11 -8
  78. package/oxe/workflows/forensics.md +5 -3
  79. package/oxe/workflows/plan.md +277 -0
  80. package/oxe/workflows/scan.md +355 -69
  81. package/oxe/workflows/spec.md +302 -9
  82. package/oxe/workflows/ui-review.md +5 -4
  83. package/oxe/workflows/ui-spec.md +4 -3
  84. package/oxe/workflows/verify.md +8 -5
  85. package/package.json +1 -1
  86. package/packages/runtime/package.json +1 -1
  87. package/packages/runtime/src/compiler/graph-compiler.ts +40 -0
  88. package/packages/runtime/src/context/context-pack-builder.ts +80 -0
  89. package/packages/runtime/src/events/catalog.ts +5 -0
  90. package/packages/runtime/src/executor/action-tool-map.ts +46 -0
  91. package/packages/runtime/src/executor/built-in-tools.ts +276 -0
  92. package/packages/runtime/src/executor/index.ts +6 -0
  93. package/packages/runtime/src/executor/llm-task-executor.ts +194 -0
  94. package/packages/runtime/src/executor/node-prompt-builder.ts +45 -0
  95. package/packages/runtime/src/executor/stream-completion.ts +145 -0
  96. package/packages/runtime/src/index.ts +3 -0
  97. package/packages/runtime/src/models/failure.ts +11 -0
  98. package/packages/runtime/src/plugins/capability-adapter.ts +117 -10
  99. package/packages/runtime/src/plugins/plugin-abi.ts +9 -0
  100. package/packages/runtime/src/plugins/plugin-registry.ts +10 -1
  101. package/packages/runtime/src/reducers/run-state-reducer.ts +59 -2
  102. package/packages/runtime/src/scheduler/scheduler.ts +152 -14
  103. package/packages/runtime/src/verification/verification-manifest.ts +12 -8
  104. package/vscode-extension/oxe-agents-1.7.0.vsix +0 -0
  105. package/vscode-extension/package.json +1 -1
@@ -1,11 +1,12 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { spawnSync } from 'child_process';
3
+ import { spawn } from 'child_process';
4
4
  import type {
5
5
  OxePlugin,
6
6
  ToolProvider,
7
7
  ToolInvocationInput,
8
8
  ToolInvocationResult,
9
+ PreInvokeResult,
9
10
  VerifierProvider,
10
11
  VerificationInput,
11
12
  } from './plugin-abi';
@@ -19,6 +20,56 @@ interface CapabilityManifest {
19
20
  evidenceOutputs: string[];
20
21
  checkTypes: string[];
21
22
  dir: string;
23
+ timeoutMs: number;
24
+ preInvokeHook: string | null;
25
+ postInvokeHook: string | null;
26
+ }
27
+
28
+ const DEFAULT_CAPABILITY_TIMEOUT_MS = 60_000;
29
+
30
+ export async function runCapabilityAsync(
31
+ program: string,
32
+ args: string[],
33
+ env: NodeJS.ProcessEnv,
34
+ cwd: string,
35
+ timeoutMs: number,
36
+ onChunk?: (chunk: string, stream: 'stdout' | 'stderr') => void,
37
+ ): Promise<{ exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }> {
38
+ return new Promise((resolve) => {
39
+ const proc = spawn(program, args, { cwd, env, stdio: 'pipe' });
40
+ const stdoutChunks: Buffer[] = [];
41
+ const stderrChunks: Buffer[] = [];
42
+ let timedOut = false;
43
+
44
+ const timer = setTimeout(() => {
45
+ timedOut = true;
46
+ proc.kill('SIGTERM');
47
+ }, timeoutMs);
48
+
49
+ proc.stdout.on('data', (chunk: Buffer) => {
50
+ stdoutChunks.push(chunk);
51
+ onChunk?.(chunk.toString(), 'stdout');
52
+ });
53
+ proc.stderr.on('data', (chunk: Buffer) => {
54
+ stderrChunks.push(chunk);
55
+ onChunk?.(chunk.toString(), 'stderr');
56
+ });
57
+
58
+ proc.on('close', (exitCode) => {
59
+ clearTimeout(timer);
60
+ resolve({
61
+ exitCode,
62
+ stdout: Buffer.concat(stdoutChunks).toString('utf8'),
63
+ stderr: Buffer.concat(stderrChunks).toString('utf8'),
64
+ timedOut,
65
+ });
66
+ });
67
+
68
+ proc.on('error', (err) => {
69
+ clearTimeout(timer);
70
+ resolve({ exitCode: null, stdout: '', stderr: String(err), timedOut: false });
71
+ });
72
+ });
22
73
  }
23
74
 
24
75
  function parseFrontmatter(text: string): Record<string, string> {
@@ -62,6 +113,7 @@ function loadCapabilityManifests(projectRoot: string): CapabilityManifest[] {
62
113
  const fm = parseFrontmatter(raw);
63
114
  const id = String(fm.id || '').trim();
64
115
  if (!id) return null;
116
+ const rawTimeout = parseInt(String(fm.timeout_ms || ''), 10);
65
117
  return {
66
118
  id,
67
119
  entrypoint: String(fm.entrypoint || '').trim() || null,
@@ -69,6 +121,9 @@ function loadCapabilityManifests(projectRoot: string): CapabilityManifest[] {
69
121
  evidenceOutputs: parseArrayField(fm.evidence_outputs),
70
122
  checkTypes: parseArrayField(fm.check_types || fm.supports_checks),
71
123
  dir,
124
+ timeoutMs: isNaN(rawTimeout) ? DEFAULT_CAPABILITY_TIMEOUT_MS : rawTimeout,
125
+ preInvokeHook: String(fm.pre_invoke_hook || '').trim() || null,
126
+ postInvokeHook: String(fm.post_invoke_hook || '').trim() || null,
72
127
  };
73
128
  })
74
129
  .filter((item): item is CapabilityManifest => Boolean(item));
@@ -124,7 +179,7 @@ function buildToolProvider(projectRoot: string, manifest: CapabilityManifest): T
124
179
  };
125
180
  }
126
181
  const ext = path.extname(entrypoint).toLowerCase();
127
- const env = {
182
+ const env: NodeJS.ProcessEnv = {
128
183
  ...process.env,
129
184
  OXE_CAPABILITY_INPUT: JSON.stringify(input.params || {}),
130
185
  OXE_CAPABILITY_RUN_ID: input.run_id,
@@ -141,19 +196,71 @@ function buildToolProvider(projectRoot: string, manifest: CapabilityManifest): T
141
196
  program = 'powershell';
142
197
  args = ['-File', entrypoint];
143
198
  }
144
- const result = spawnSync(program, args, {
145
- cwd: projectRoot,
146
- encoding: 'utf8',
147
- env,
148
- });
199
+ const result = await runCapabilityAsync(program, args, env, projectRoot, manifest.timeoutMs);
200
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
201
+ if (result.timedOut) {
202
+ return {
203
+ success: false,
204
+ output,
205
+ evidence_paths: resolveEvidencePaths(projectRoot, manifest),
206
+ side_effects_applied: manifest.sideEffects,
207
+ error: `Capability ${manifest.id} timed out after ${manifest.timeoutMs}ms`,
208
+ };
209
+ }
149
210
  return {
150
- success: result.status === 0 && !result.error,
151
- output: [result.stdout || '', result.stderr || ''].filter(Boolean).join('\n').trim(),
211
+ success: result.exitCode === 0,
212
+ output,
152
213
  evidence_paths: resolveEvidencePaths(projectRoot, manifest),
153
214
  side_effects_applied: manifest.sideEffects,
154
- error: result.error ? String(result.error) : result.status === 0 ? undefined : (result.stderr || result.stdout || `Capability exited with status ${result.status}`),
215
+ error: result.exitCode === 0 ? undefined : (result.stderr || result.stdout || `Capability exited with status ${result.exitCode}`),
155
216
  };
156
217
  },
218
+
219
+ async preInvoke(input: ToolInvocationInput): Promise<PreInvokeResult> {
220
+ if (!manifest.preInvokeHook) return { allowed: true };
221
+ const hookPath = path.isAbsolute(manifest.preInvokeHook)
222
+ ? manifest.preInvokeHook
223
+ : path.join(manifest.dir, manifest.preInvokeHook);
224
+ const env: NodeJS.ProcessEnv = {
225
+ ...process.env,
226
+ OXE_CAPABILITY_INPUT: JSON.stringify(input.params || {}),
227
+ OXE_CAPABILITY_RUN_ID: input.run_id,
228
+ OXE_CAPABILITY_WORK_ITEM_ID: input.work_item_id,
229
+ OXE_CAPABILITY_ATTEMPT_ID: input.attempt_id,
230
+ OXE_CAPABILITY_WORKSPACE_ROOT: input.workspace_root,
231
+ };
232
+ const ext = path.extname(hookPath).toLowerCase();
233
+ let program = hookPath;
234
+ let args: string[] = [];
235
+ if (ext === '.js' || ext === '.cjs' || ext === '.mjs') { program = process.execPath; args = [hookPath]; }
236
+ else if (ext === '.ps1') { program = 'powershell'; args = ['-File', hookPath]; }
237
+ const result = await runCapabilityAsync(program, args, env, projectRoot, 10_000);
238
+ return result.exitCode === 0
239
+ ? { allowed: true }
240
+ : { allowed: false, reason: result.stderr || result.stdout || `pre_invoke_hook exited with status ${result.exitCode}` };
241
+ },
242
+
243
+ async postInvoke(input: ToolInvocationInput, _result: ToolInvocationResult): Promise<void> {
244
+ if (!manifest.postInvokeHook) return;
245
+ const hookPath = path.isAbsolute(manifest.postInvokeHook)
246
+ ? manifest.postInvokeHook
247
+ : path.join(manifest.dir, manifest.postInvokeHook);
248
+ const env: NodeJS.ProcessEnv = {
249
+ ...process.env,
250
+ OXE_CAPABILITY_INPUT: JSON.stringify(input.params || {}),
251
+ OXE_CAPABILITY_RUN_ID: input.run_id,
252
+ OXE_CAPABILITY_WORK_ITEM_ID: input.work_item_id,
253
+ OXE_CAPABILITY_ATTEMPT_ID: input.attempt_id,
254
+ OXE_CAPABILITY_WORKSPACE_ROOT: input.workspace_root,
255
+ OXE_INVOKE_SUCCESS: _result.success ? '1' : '0',
256
+ };
257
+ const ext = path.extname(hookPath).toLowerCase();
258
+ let program = hookPath;
259
+ let args: string[] = [];
260
+ if (ext === '.js' || ext === '.cjs' || ext === '.mjs') { program = process.execPath; args = [hookPath]; }
261
+ else if (ext === '.ps1') { program = 'powershell'; args = ['-File', hookPath]; }
262
+ await runCapabilityAsync(program, args, env, projectRoot, 10_000).catch(() => {});
263
+ },
157
264
  };
158
265
  }
159
266
 
@@ -21,12 +21,21 @@ export interface ToolInvocationResult {
21
21
  error?: string;
22
22
  }
23
23
 
24
+ export interface PreInvokeResult {
25
+ allowed: boolean;
26
+ reason?: string;
27
+ }
28
+
24
29
  export interface ToolProvider {
25
30
  readonly name: string;
26
31
  readonly kind: 'read' | 'mutation' | 'verification' | 'analysis' | 'external_operation';
27
32
  readonly idempotent: boolean;
28
33
  supports(actionType: string): boolean;
29
34
  invoke(input: ToolInvocationInput): Promise<ToolInvocationResult>;
35
+ /** Optional: called before invoke. Return allowed:false to block execution. */
36
+ preInvoke?(input: ToolInvocationInput): Promise<PreInvokeResult>;
37
+ /** Optional: called after invoke. Errors are swallowed — does not affect outcome. */
38
+ postInvoke?(input: ToolInvocationInput, result: ToolInvocationResult): Promise<void>;
30
39
  }
31
40
 
32
41
  // ─── WorkspaceProvider ───────────────────────────────────────────────────────
@@ -53,11 +53,20 @@ export class PluginRegistry {
53
53
  return loaded;
54
54
  }
55
55
 
56
- toolProviderFor(actionType: string): ToolProvider | null {
56
+ toolProviderFor(actionType: string, required: true): ToolProvider;
57
+ toolProviderFor(actionType: string, required?: false): ToolProvider | null;
58
+ toolProviderFor(actionType: string, required = false): ToolProvider | null {
57
59
  for (const plugin of this.plugins) {
58
60
  const provider = plugin.toolProviders?.find((p) => p.supports(actionType));
59
61
  if (provider) return provider;
60
62
  }
63
+ if (required) {
64
+ const loaded = this.plugins.map(p => p.name).join(', ') || '(none)';
65
+ throw new Error(
66
+ `[plugin-registry] No provider supports action type "${actionType}". ` +
67
+ `Loaded plugins: [${loaded}]. Load errors: ${this.loadErrors.length}.`
68
+ );
69
+ }
61
70
  return null;
62
71
  }
63
72
 
@@ -61,6 +61,56 @@ export function reduce(events: OxeEvent[]): RunState {
61
61
  // Exported alias so debug-reducer can import applyEvent without circular issues
62
62
  export { applyEvent as applyEventExported };
63
63
 
64
+ // ─── State machine: valid transitions ─────────────────────────────────────────
65
+
66
+ import type { WorkItemStatus } from '../models/work-item';
67
+ import type { RunStatus } from '../models/run';
68
+
69
+ const VALID_WORK_ITEM_TRANSITIONS: Record<WorkItemStatus, readonly WorkItemStatus[]> = {
70
+ pending: ['ready'],
71
+ ready: ['running', 'completed', 'failed', 'blocked'],
72
+ running: ['completed', 'failed', 'blocked'],
73
+ failed: ['ready'], // retry path
74
+ completed: [], // terminal
75
+ blocked: [], // terminal
76
+ skipped: [], // terminal
77
+ };
78
+
79
+ const VALID_RUN_TRANSITIONS: Record<RunStatus, readonly RunStatus[]> = {
80
+ planned: ['running'],
81
+ running: ['paused', 'failed', 'completed', 'aborted', 'cancelled', 'waiting_approval'],
82
+ paused: ['running', 'cancelled'],
83
+ waiting_approval: ['running', 'cancelled'],
84
+ failed: ['replaying'],
85
+ replaying: ['running', 'failed', 'completed'],
86
+ completed: [],
87
+ aborted: [],
88
+ cancelled: [],
89
+ };
90
+
91
+ function assertWorkItemTransition(
92
+ itemId: string,
93
+ from: WorkItemStatus,
94
+ to: WorkItemStatus,
95
+ eventType: string
96
+ ): void {
97
+ const allowed = VALID_WORK_ITEM_TRANSITIONS[from] ?? [];
98
+ if (!(allowed as readonly string[]).includes(to)) {
99
+ throw new Error(
100
+ `[state-machine] Invalid work item transition for "${itemId}": ${from} → ${to} (event: ${eventType})`
101
+ );
102
+ }
103
+ }
104
+
105
+ function assertRunTransition(from: RunStatus, to: RunStatus, eventType: string): void {
106
+ const allowed = VALID_RUN_TRANSITIONS[from] ?? [];
107
+ if (!(allowed as readonly string[]).includes(to)) {
108
+ throw new Error(
109
+ `[state-machine] Invalid run transition: ${from} → ${to} (event: ${eventType})`
110
+ );
111
+ }
112
+ }
113
+
64
114
  function applyEvent(state: RunState, event: OxeEvent): RunState {
65
115
  switch (event.type) {
66
116
  case 'RunStarted': {
@@ -71,6 +121,7 @@ function applyEvent(state: RunState, event: OxeEvent): RunState {
71
121
  case 'RunCompleted': {
72
122
  if (!state.run) return state;
73
123
  const status = (event.payload as { status?: Run['status'] }).status ?? 'completed';
124
+ assertRunTransition(state.run.status, status, event.type);
74
125
  return {
75
126
  ...state,
76
127
  run: { ...state.run, status, ended_at: event.timestamp },
@@ -121,7 +172,10 @@ function applyEvent(state: RunState, event: OxeEvent): RunState {
121
172
  if (!event.work_item_id) return state;
122
173
  const workItems = new Map(state.workItems);
123
174
  const item = workItems.get(event.work_item_id);
124
- if (item) workItems.set(event.work_item_id, { ...item, status: 'completed' });
175
+ if (item) {
176
+ assertWorkItemTransition(event.work_item_id, item.status, 'completed', event.type);
177
+ workItems.set(event.work_item_id, { ...item, status: 'completed' });
178
+ }
125
179
  const completedWorkItems = new Set(state.completedWorkItems);
126
180
  completedWorkItems.add(event.work_item_id);
127
181
  // Collect evidence refs from payload
@@ -139,7 +193,10 @@ function applyEvent(state: RunState, event: OxeEvent): RunState {
139
193
  if (!event.work_item_id) return state;
140
194
  const workItems = new Map(state.workItems);
141
195
  const item = workItems.get(event.work_item_id);
142
- if (item) workItems.set(event.work_item_id, { ...item, status: 'blocked' });
196
+ if (item) {
197
+ assertWorkItemTransition(event.work_item_id, item.status, 'blocked', event.type);
198
+ workItems.set(event.work_item_id, { ...item, status: 'blocked' });
199
+ }
143
200
  const blockedWorkItems = new Set(state.blockedWorkItems);
144
201
  blockedWorkItems.add(event.work_item_id);
145
202
  return { ...state, workItems, blockedWorkItems };
@@ -1,3 +1,5 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
1
3
  import { appendEvent } from '../events/bus';
2
4
  import type { OxeEvent } from '../events/envelope';
3
5
  import type { EventInput } from '../events/bus';
@@ -18,10 +20,14 @@ import {
18
20
  createJournal,
19
21
  } from './run-journal';
20
22
  import type { RunJournal } from './run-journal';
23
+ import type { FailureClass } from '../models/failure';
24
+ import { listMemos } from '../decision/decision-memo';
25
+ import type { RollbackPlan } from '../decision/decision-memo';
26
+ import { runCapabilityAsync } from '../plugins/capability-adapter';
21
27
 
22
28
  export interface TaskResult {
23
29
  success: boolean;
24
- failure_class: 'env' | 'policy' | 'test' | 'timeout' | null;
30
+ failure_class: FailureClass;
25
31
  evidence: string[];
26
32
  output: string;
27
33
  }
@@ -35,6 +41,11 @@ export interface TaskExecutor {
35
41
  ): Promise<TaskResult>;
36
42
  }
37
43
 
44
+ export interface SchedulerOptions {
45
+ maxRunDurationMs?: number; // default: 30 min
46
+ staleProgressMs?: number; // default: 5 min without any task completing
47
+ }
48
+
38
49
  export interface SchedulerContext {
39
50
  projectRoot: string;
40
51
  sessionId: string | null;
@@ -48,15 +59,17 @@ export interface SchedulerContext {
48
59
  quota?: RunQuota;
49
60
  policyActor?: string;
50
61
  onEvent?: (event: OxeEvent) => void;
62
+ options?: SchedulerOptions;
51
63
  }
52
64
 
53
65
  export interface RunResult {
54
66
  run_id: string;
55
- status: 'completed' | 'failed' | 'blocked' | 'cancelled' | 'paused';
67
+ status: 'completed' | 'failed' | 'blocked' | 'cancelled' | 'paused' | 'aborted';
56
68
  completed: string[];
57
69
  failed: string[];
58
70
  blocked: string[];
59
71
  pending_gates?: string[];
72
+ reason?: string;
60
73
  }
61
74
 
62
75
  type NodeStatus = 'pending' | 'ready' | 'running' | 'completed' | 'failed' | 'blocked';
@@ -66,11 +79,43 @@ export class Scheduler {
66
79
  private paused = false;
67
80
  private journal: RunJournal | null = null;
68
81
  private ctx: SchedulerContext | null = null;
82
+ private runStartMs = 0;
83
+ private lastProgressMs = 0;
84
+
85
+ private recordProgress(): void {
86
+ this.lastProgressMs = Date.now();
87
+ }
88
+
89
+ private async executeRollback(plan: RollbackPlan, ctx: SchedulerContext): Promise<void> {
90
+ try {
91
+ switch (plan.strategy) {
92
+ case 'revert_commit':
93
+ await runCapabilityAsync('git', ['revert', 'HEAD', '--no-edit'], {}, ctx.projectRoot, 30_000);
94
+ break;
95
+ case 'restore_workspace':
96
+ await runCapabilityAsync('git', ['checkout', '.'], {}, ctx.projectRoot, 30_000);
97
+ break;
98
+ case 'undo_patch':
99
+ for (const p of plan.steps) {
100
+ await runCapabilityAsync('git', ['checkout', 'HEAD', '--', p], {}, ctx.projectRoot, 10_000);
101
+ }
102
+ break;
103
+ case 'no_rollback':
104
+ default:
105
+ break;
106
+ }
107
+ this.emit(ctx, { type: 'RollbackExecuted', payload: { strategy: plan.strategy } });
108
+ } catch (err) {
109
+ this.emit(ctx, { type: 'RollbackFailed', payload: { strategy: plan.strategy, error: String(err) } });
110
+ }
111
+ }
69
112
 
70
113
  async run(graph: ExecutionGraph, ctx: SchedulerContext): Promise<RunResult> {
71
114
  this.cancelled = false;
72
115
  this.paused = false;
73
116
  this.ctx = ctx;
117
+ this.runStartMs = Date.now();
118
+ this.lastProgressMs = Date.now();
74
119
 
75
120
  const status = new Map<string, NodeStatus>();
76
121
  for (const id of graph.nodes.keys()) status.set(id, 'pending');
@@ -79,6 +124,30 @@ export class Scheduler {
79
124
  const failed: string[] = [];
80
125
  const blocked: string[] = [];
81
126
 
127
+ // Plan hash drift detection: abort if the graph was recompiled since ACTIVE-RUN was saved
128
+ const activeRunPath = ctx.sessionId
129
+ ? path.join(ctx.projectRoot, '.oxe', ctx.sessionId, 'execution', 'ACTIVE-RUN.json')
130
+ : path.join(ctx.projectRoot, '.oxe', 'ACTIVE-RUN.json');
131
+ if (fs.existsSync(activeRunPath)) {
132
+ try {
133
+ const activeRun = JSON.parse(fs.readFileSync(activeRunPath, 'utf8')) as Record<string, unknown>;
134
+ const savedHash = activeRun.plan_hash as string | undefined;
135
+ const currentHash = graph.metadata.plan_hash;
136
+ if (savedHash && savedHash !== currentHash) {
137
+ return {
138
+ run_id: ctx.runId,
139
+ status: 'aborted',
140
+ completed: [],
141
+ failed: [],
142
+ blocked: [],
143
+ reason: `plan_drift: graph recompiled (${savedHash} → ${currentHash}). Run /oxe-plan --replan to realign.`,
144
+ };
145
+ }
146
+ } catch {
147
+ // ACTIVE-RUN not parseable — continue without drift check
148
+ }
149
+ }
150
+
82
151
  this.journal = createJournal(ctx.runId);
83
152
  saveJournal(ctx.projectRoot, ctx.runId, this.journal);
84
153
 
@@ -88,9 +157,24 @@ export class Scheduler {
88
157
  detail: { session_id: ctx.sessionId ?? null },
89
158
  });
90
159
 
160
+ const maxRunMs = ctx.options?.maxRunDurationMs ?? 30 * 60_000;
161
+ const staleMs = ctx.options?.staleProgressMs ?? 5 * 60_000;
162
+
91
163
  for (const wave of graph.waves) {
92
164
  if (this.cancelled) break;
93
165
 
166
+ // Global run timeout
167
+ if (Date.now() - this.runStartMs > maxRunMs) {
168
+ this.emit(ctx, { type: 'RunAborted', payload: { reason: 'global_timeout' } });
169
+ return { run_id: ctx.runId, status: 'aborted', completed: [], failed: [], blocked: [], reason: 'global_timeout' };
170
+ }
171
+
172
+ // Stale progress timeout (no task completed in staleMs)
173
+ if (Date.now() - this.lastProgressMs > staleMs) {
174
+ this.emit(ctx, { type: 'RunAborted', payload: { reason: 'no_progress_timeout' } });
175
+ return { run_id: ctx.runId, status: 'aborted', completed: [], failed: [], blocked: [], reason: 'no_progress_timeout' };
176
+ }
177
+
94
178
  // Respect pause: persist journal and return paused result
95
179
  if (this.paused) {
96
180
  this.journal.scheduler_state = 'paused';
@@ -123,7 +207,17 @@ export class Scheduler {
123
207
  this.journal.blocked_work_items = blocked.slice();
124
208
  saveJournal(ctx.projectRoot, ctx.runId, this.journal);
125
209
 
126
- if (waveFailed) break;
210
+ if (waveFailed) {
211
+ // Execute rollback plan if one was created for this run
212
+ const memos = listMemos(ctx.projectRoot, ctx.runId);
213
+ for (const memo of memos) {
214
+ if (memo.rollback_plan.strategy !== 'no_rollback') {
215
+ await this.executeRollback(memo.rollback_plan, ctx);
216
+ break; // apply at most one rollback plan per wave failure
217
+ }
218
+ }
219
+ break;
220
+ }
127
221
  }
128
222
 
129
223
  // Any remaining pending nodes become blocked
@@ -305,6 +399,15 @@ export class Scheduler {
305
399
  };
306
400
  }
307
401
 
402
+ private isConcurrentSafe(nodeId: string, graph: ExecutionGraph, ctx: SchedulerContext): boolean {
403
+ const node = graph.nodes.get(nodeId)!;
404
+ if (node.mutation_scope.length > 0) return false;
405
+ const primaryAction = pickPrimaryAction(node, ctx.pluginRegistry);
406
+ if (!primaryAction) return true;
407
+ const provider = ctx.pluginRegistry?.toolProviderFor(primaryAction.type);
408
+ return provider?.idempotent ?? true;
409
+ }
410
+
308
411
  private async runWave(
309
412
  nodeIds: string[],
310
413
  graph: ExecutionGraph,
@@ -338,10 +441,7 @@ export class Scheduler {
338
441
  });
339
442
  }
340
443
 
341
- const readOnly = eligible.filter((id) => {
342
- const node = graph.nodes.get(id)!;
343
- return node.mutation_scope.length === 0;
344
- });
444
+ const readOnly = eligible.filter((id) => this.isConcurrentSafe(id, graph, ctx));
345
445
  const mutations = eligible.filter((id) => !readOnly.includes(id));
346
446
 
347
447
  if (readOnly.length > 0) {
@@ -453,6 +553,7 @@ export class Scheduler {
453
553
  });
454
554
  status.set(nodeId, 'completed');
455
555
  completed.push(nodeId);
556
+ this.recordProgress();
456
557
  return;
457
558
  }
458
559
 
@@ -464,29 +565,47 @@ export class Scheduler {
464
565
  this.blockNode(nodeId, ctx, status, blocked, 'quota_exceeded', retryBlocked);
465
566
  return;
466
567
  }
568
+ // Exponential backoff with jitter: 1s * 2^(attempt-1) + [0, 500ms], capped at 30s
569
+ const backoffMs = Math.min(
570
+ 1_000 * Math.pow(2, attempt - 1) + Math.random() * 500,
571
+ 30_000
572
+ );
573
+ await new Promise<void>(resolve => setTimeout(resolve, backoffMs));
467
574
  this.emit(ctx, {
468
575
  type: 'RetryScheduled',
469
576
  work_item_id: nodeId,
470
- payload: { next_attempt: attempt + 1, reason: lastResult.failure_class },
577
+ payload: { next_attempt: attempt + 1, reason: lastResult.failure_class, backoff_ms: backoffMs },
471
578
  });
472
579
  }
473
- } catch (err) {
580
+ } catch (err: unknown) {
581
+ // Error boundary: isolate task failure, emit structured event, do not crash scheduler
582
+ const message = err instanceof Error ? err.message : String(err);
583
+ const stack = err instanceof Error ? err.stack : undefined;
584
+ this.emit(ctx, {
585
+ type: 'TaskErrorBoundaryTripped',
586
+ work_item_id: nodeId,
587
+ payload: { message, stack, attempt },
588
+ });
474
589
  lastResult = {
475
590
  success: false,
476
591
  failure_class: 'env',
477
592
  evidence: [],
478
- output: String(err),
593
+ output: `[error_boundary] ${message}`,
479
594
  };
480
595
  if (attempt < maxAttempts) {
596
+ const backoffMs = Math.min(1_000 * Math.pow(2, attempt - 1) + Math.random() * 500, 30_000);
597
+ await new Promise<void>(resolve => setTimeout(resolve, backoffMs));
481
598
  this.emit(ctx, {
482
599
  type: 'RetryScheduled',
483
600
  work_item_id: nodeId,
484
- payload: { next_attempt: attempt + 1, reason: 'env' },
601
+ payload: { next_attempt: attempt + 1, reason: 'env', backoff_ms: backoffMs },
485
602
  });
486
603
  }
487
604
  } finally {
488
605
  if (lease) {
489
- await ctx.workspaceManager.dispose(lease.workspace_id).catch(() => {});
606
+ await ctx.workspaceManager.dispose(lease.workspace_id).catch((e: unknown) =>
607
+ this.emit(ctx!, { type: 'WorkspaceDisposeFailed', payload: { workspace_id: lease?.workspace_id, error: String(e) } })
608
+ );
490
609
  lease = null;
491
610
  }
492
611
  }
@@ -562,7 +681,7 @@ export class Scheduler {
562
681
  payload: { provider: provider.name, action_type: primaryAction.type },
563
682
  });
564
683
 
565
- const result = await provider.invoke({
684
+ const invocationInput = {
566
685
  action_type: primaryAction.type,
567
686
  work_item_id: node.id,
568
687
  run_id: ctx.runId,
@@ -572,7 +691,26 @@ export class Scheduler {
572
691
  targets: primaryAction.targets ?? [],
573
692
  },
574
693
  workspace_root: lease.root_path,
575
- });
694
+ };
695
+
696
+ if (provider.preInvoke) {
697
+ const preCheck = await provider.preInvoke(invocationInput);
698
+ if (!preCheck.allowed) {
699
+ this.emit(ctx, {
700
+ type: 'ToolFailed',
701
+ work_item_id: node.id,
702
+ attempt_id: attemptId,
703
+ payload: { provider: provider.name, action_type: primaryAction.type, error: preCheck.reason ?? 'pre_invoke blocked', evidence_paths: [], side_effects_applied: [] },
704
+ });
705
+ return { success: false, failure_class: 'policy', evidence: [], output: preCheck.reason ?? 'pre_invoke blocked' };
706
+ }
707
+ }
708
+
709
+ const result = await provider.invoke(invocationInput);
710
+
711
+ if (provider.postInvoke) {
712
+ await provider.postInvoke(invocationInput, result).catch(() => {});
713
+ }
576
714
 
577
715
  this.emit(ctx, {
578
716
  type: result.success ? 'ToolCompleted' : 'ToolFailed',
@@ -6,13 +6,17 @@ import type { CheckResult } from './verification-compiler';
6
6
 
7
7
  export type VerificationProfile = 'quick' | 'standard' | 'critical';
8
8
 
9
- export type FailureClass =
10
- | 'deterministic'
11
- | 'flaky'
12
- | 'timeout'
13
- | 'env_setup'
14
- | 'policy_failure'
15
- | 'evidence_missing';
9
+ /** Verification-specific failure classification (why a check failed, not why a task failed). */
10
+ export type VerificationFailureClass =
11
+ | 'deterministic' // check always fails regardless of retry
12
+ | 'flaky' // check outcome is non-deterministic
13
+ | 'timeout' // check exceeded time budget
14
+ | 'env_setup' // environment/infrastructure prevented check from running
15
+ | 'policy_failure' // policy blocked the check
16
+ | 'evidence_missing'; // required evidence was never collected
17
+
18
+ /** @deprecated Use VerificationFailureClass. Kept for backwards compat. */
19
+ export type FailureClass = VerificationFailureClass;
16
20
 
17
21
  export type VerificationGranularity = 'work_item' | 'wave' | 'run';
18
22
 
@@ -20,7 +24,7 @@ export interface ManifestCheck {
20
24
  check_id: string;
21
25
  acceptance_ref: string | null;
22
26
  status: VerificationStatus;
23
- failure_class: FailureClass | null;
27
+ failure_class: VerificationFailureClass | null;
24
28
  evidence_refs: string[];
25
29
  duration_ms: number;
26
30
  }
@@ -2,7 +2,7 @@
2
2
  "name": "oxe-agents",
3
3
  "displayName": "OXE Agents",
4
4
  "description": "Agentes OXE para GitHub Copilot Chat — cada fase do ciclo como um @agente no VS Code",
5
- "version": "1.6.0",
5
+ "version": "1.7.0",
6
6
  "publisher": "oxe-cc",
7
7
  "license": "MIT",
8
8
  "engines": {