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.
- package/CHANGELOG.md +18 -0
- package/README.md +5 -3
- package/bin/lib/oxe-agent-install.cjs +125 -24
- package/bin/lib/oxe-release.cjs +1 -0
- package/bin/oxe-cc.js +87 -39
- package/commands/oxe/debug.md +6 -1
- package/commands/oxe/discuss.md +7 -2
- package/commands/oxe/execute.md +7 -2
- package/commands/oxe/plan-agent.md +7 -2
- package/commands/oxe/plan.md +7 -2
- package/commands/oxe/scan.md +6 -1
- package/commands/oxe/spec.md +6 -1
- package/commands/oxe/verify.md +6 -1
- package/docs/CONTENT-MIGRATION-AUDIT.md +49 -0
- package/docs/RUNTIME-SMOKE-MATRIX.md +1 -1
- package/lib/runtime/compiler/graph-compiler.js +32 -0
- package/lib/runtime/context/context-pack-builder.d.ts +15 -0
- package/lib/runtime/context/context-pack-builder.js +78 -0
- package/lib/runtime/events/catalog.d.ts +1 -1
- package/lib/runtime/events/catalog.js +5 -0
- package/lib/runtime/executor/action-tool-map.d.ts +3 -0
- package/lib/runtime/executor/action-tool-map.js +41 -0
- package/lib/runtime/executor/built-in-tools.d.ts +8 -0
- package/lib/runtime/executor/built-in-tools.js +267 -0
- package/lib/runtime/executor/index.d.ts +6 -0
- package/lib/runtime/executor/index.js +12 -0
- package/lib/runtime/executor/llm-task-executor.d.ts +29 -0
- package/lib/runtime/executor/llm-task-executor.js +138 -0
- package/lib/runtime/executor/node-prompt-builder.d.ts +3 -0
- package/lib/runtime/executor/node-prompt-builder.js +36 -0
- package/lib/runtime/executor/stream-completion.d.ts +38 -0
- package/lib/runtime/executor/stream-completion.js +105 -0
- package/lib/runtime/index.d.ts +1 -0
- package/lib/runtime/index.js +2 -0
- package/lib/runtime/models/failure.d.ts +5 -0
- package/lib/runtime/models/failure.js +2 -0
- package/lib/runtime/plugins/capability-adapter.d.ts +9 -0
- package/lib/runtime/plugins/capability-adapter.js +111 -8
- package/lib/runtime/plugins/plugin-abi.d.ts +8 -0
- package/lib/runtime/plugins/plugin-registry.d.ts +2 -1
- package/lib/runtime/plugins/plugin-registry.js +6 -1
- package/lib/runtime/reducers/run-state-reducer.js +39 -2
- package/lib/runtime/scheduler/scheduler.d.ts +14 -2
- package/lib/runtime/scheduler/scheduler.js +131 -11
- package/lib/runtime/verification/verification-manifest.d.ts +5 -2
- package/oxe/agents/oxe-assumptions-analyzer.md +136 -0
- package/oxe/agents/oxe-codebase-mapper.md +142 -0
- package/oxe/agents/oxe-debugger.md +145 -0
- package/oxe/agents/oxe-executor.md +139 -0
- package/oxe/agents/oxe-integration-checker.md +142 -0
- package/oxe/agents/oxe-plan-checker.md +143 -0
- package/oxe/agents/oxe-planner.md +151 -0
- package/oxe/agents/oxe-research-synthesizer.md +146 -0
- package/oxe/agents/oxe-researcher.md +163 -0
- package/oxe/agents/oxe-ui-auditor.md +151 -0
- package/oxe/agents/oxe-ui-checker.md +157 -0
- package/oxe/agents/oxe-ui-researcher.md +179 -0
- package/oxe/agents/oxe-validation-auditor.md +154 -0
- package/oxe/agents/oxe-verifier.md +132 -0
- package/oxe/personas/README.md +91 -39
- package/oxe/personas/architect.md +149 -37
- package/oxe/personas/db-specialist.md +149 -36
- package/oxe/personas/debugger.md +155 -38
- package/oxe/personas/executor.md +164 -38
- package/oxe/personas/planner.md +165 -36
- package/oxe/personas/researcher.md +148 -35
- package/oxe/personas/ui-specialist.md +164 -36
- package/oxe/personas/verifier.md +174 -39
- package/oxe/templates/FIXTURE-PACK.template.json +18 -11
- package/oxe/templates/FIXTURE-PACK.template.md +19 -10
- package/oxe/templates/IMPLEMENTATION-PACK.template.json +26 -10
- package/oxe/templates/IMPLEMENTATION-PACK.template.md +32 -20
- package/oxe/templates/PLAN.template.md +62 -31
- package/oxe/templates/REFERENCE-ANCHORS.template.md +14 -10
- package/oxe/templates/SUMMARY.template.md +50 -20
- package/oxe/workflows/debug.md +9 -7
- package/oxe/workflows/execute.md +11 -8
- package/oxe/workflows/forensics.md +5 -3
- package/oxe/workflows/plan.md +277 -0
- package/oxe/workflows/scan.md +355 -69
- package/oxe/workflows/spec.md +302 -9
- package/oxe/workflows/ui-review.md +5 -4
- package/oxe/workflows/ui-spec.md +4 -3
- package/oxe/workflows/verify.md +8 -5
- package/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/runtime/src/compiler/graph-compiler.ts +40 -0
- package/packages/runtime/src/context/context-pack-builder.ts +80 -0
- package/packages/runtime/src/events/catalog.ts +5 -0
- package/packages/runtime/src/executor/action-tool-map.ts +46 -0
- package/packages/runtime/src/executor/built-in-tools.ts +276 -0
- package/packages/runtime/src/executor/index.ts +6 -0
- package/packages/runtime/src/executor/llm-task-executor.ts +194 -0
- package/packages/runtime/src/executor/node-prompt-builder.ts +45 -0
- package/packages/runtime/src/executor/stream-completion.ts +145 -0
- package/packages/runtime/src/index.ts +3 -0
- package/packages/runtime/src/models/failure.ts +11 -0
- package/packages/runtime/src/plugins/capability-adapter.ts +117 -10
- package/packages/runtime/src/plugins/plugin-abi.ts +9 -0
- package/packages/runtime/src/plugins/plugin-registry.ts +10 -1
- package/packages/runtime/src/reducers/run-state-reducer.ts +59 -2
- package/packages/runtime/src/scheduler/scheduler.ts +152 -14
- package/packages/runtime/src/verification/verification-manifest.ts +12 -8
- package/vscode-extension/oxe-agents-1.7.0.vsix +0 -0
- 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 {
|
|
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 =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
151
|
-
output
|
|
211
|
+
success: result.exitCode === 0,
|
|
212
|
+
output,
|
|
152
213
|
evidence_paths: resolveEvidencePaths(projectRoot, manifest),
|
|
153
214
|
side_effects_applied: manifest.sideEffects,
|
|
154
|
-
error: result.
|
|
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
|
|
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)
|
|
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)
|
|
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:
|
|
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)
|
|
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:
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
| '
|
|
12
|
-
| '
|
|
13
|
-
| '
|
|
14
|
-
| '
|
|
15
|
-
| '
|
|
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:
|
|
27
|
+
failure_class: VerificationFailureClass | null;
|
|
24
28
|
evidence_refs: string[];
|
|
25
29
|
duration_ms: number;
|
|
26
30
|
}
|
|
Binary file
|