openhermes 4.12.1 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +6 -6
- package/ETHOS.md +2 -2
- package/README.md +11 -17
- package/bootstrap.ts +118 -126
- package/docs/HOW-IT-WORKS.md +162 -0
- package/docs/adr/ADR-0001-rebuild-vs-increment.md +30 -0
- package/docs/adr/ADR-0002-routing-graph-vs-linear-chain.md +36 -0
- package/docs/adr/ADR-0003-per-directory-plan-storage.md +34 -0
- package/docs/adr/ADR-0004-composer-fragment-architecture.md +42 -0
- package/docs/adr/ADR-0005-hook-system-design.md +42 -0
- package/docs/adr/README.md +9 -0
- package/harness/codex/AUTOPILOT.md +35 -40
- package/harness/codex/CHARTER.md +3 -3
- package/harness/lib/composer/compose.test.ts +29 -29
- package/harness/lib/composer/fragments/02-delegation.md +5 -5
- package/harness/lib/composer/fragments/04-task-flow.md +13 -13
- package/harness/lib/composer/fragments/08-routing.md +1 -1
- package/harness/lib/composer/fragments/09-guardrails.md +25 -25
- package/harness/lib/composer/index.ts +1 -1
- package/harness/lib/guards/guard-config.ts +72 -72
- package/harness/lib/hooks/builtins/confidence-gate-hook.ts +9 -9
- package/harness/lib/hooks/builtins/delegation-depth-hook.ts +1 -1
- package/harness/lib/hooks/builtins/dynamic-route-hook.ts +99 -99
- package/harness/lib/hooks/builtins/next-route-hook.ts +24 -24
- package/harness/lib/hooks/builtins/plan-check-hook.ts +5 -5
- package/harness/lib/hooks/builtins/route-tracking-hook.ts +1 -1
- package/harness/lib/hooks/hooks.test.ts +160 -324
- package/harness/lib/hooks/index.ts +38 -42
- package/harness/lib/hooks/registry.ts +309 -416
- package/harness/lib/hooks/types.ts +116 -119
- package/harness/lib/plans/plan-location.ts +134 -134
- package/harness/lib/routing/index.ts +21 -21
- package/harness/lib/routing/route-guidance.ts +147 -147
- package/harness/lib/routing/route-resolver.ts +58 -58
- package/harness/lib/routing/routing.test.ts +195 -195
- package/harness/lib/routing/skill-frontmatter.ts +125 -125
- package/harness/lib/routing/types.ts +52 -52
- package/harness/skills/oh-ascii/SKILL.md +1 -1
- package/harness/skills/oh-fusion/DEEP.md +109 -109
- package/harness/skills/oh-fusion/SKILL.md +47 -47
- package/harness/skills/oh-init/DEEP.md +2 -2
- package/harness/skills/oh-plan-review/DEEP.md +1 -1
- package/harness/skills/oh-planner/DEEP.md +3 -3
- package/harness/skills/oh-review/DEEP.md +5 -5
- package/package.json +56 -53
- package/harness/lib/background/background.test.ts +0 -216
- package/harness/lib/background/index.ts +0 -7
- package/harness/lib/background/interfaces.ts +0 -31
- package/harness/lib/background/manager.ts +0 -320
- package/harness/lib/hooks/builtins/error-recovery-hook.ts +0 -107
- package/harness/lib/hooks/builtins/memory-sync-hook.ts +0 -73
- package/harness/lib/hooks/builtins/sanity-check-hook.ts +0 -52
- package/harness/lib/hooks/builtins/subagent-failure-hook.ts +0 -93
- package/harness/lib/memory/index.ts +0 -18
- package/harness/lib/memory/interfaces.ts +0 -53
- package/harness/lib/memory/memory-manager.ts +0 -205
- package/harness/lib/memory/memory.test.ts +0 -485
- package/harness/lib/memory/plan-store.ts +0 -346
- package/harness/lib/recovery/handler.ts +0 -243
- package/harness/lib/recovery/index.ts +0 -14
- package/harness/lib/recovery/interfaces.ts +0 -48
- package/harness/lib/recovery/patterns.ts +0 -149
- package/harness/lib/recovery/recovery.test.ts +0 -312
- package/harness/lib/sanity/anomaly-tracker.ts +0 -127
- package/harness/lib/sanity/checker.ts +0 -189
- package/harness/lib/sanity/index.ts +0 -13
- package/harness/lib/sanity/interfaces.ts +0 -24
- package/harness/lib/sanity/sanity.test.ts +0 -472
- package/harness/lib/sync/file-watcher.ts +0 -175
- package/harness/lib/sync/index.ts +0 -11
- package/harness/lib/sync/interfaces.ts +0 -27
- package/harness/lib/sync/plan-sync.ts +0 -533
- package/harness/lib/sync/sync.test.ts +0 -858
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
import { describe, it, afterEach } from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { BackgroundManager } from "./manager.ts";
|
|
4
|
-
import type { BackgroundTaskStatus } from "./interfaces.ts";
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// Helpers
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
function delay(ms: number): Promise<void> {
|
|
11
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Poll check() until the task reaches one of the given statuses, or until
|
|
16
|
-
* a generous timeout elapses (2.5 s).
|
|
17
|
-
*/
|
|
18
|
-
async function waitForStatus(
|
|
19
|
-
manager: BackgroundManager,
|
|
20
|
-
id: string,
|
|
21
|
-
...expected: BackgroundTaskStatus[]
|
|
22
|
-
): Promise<void> {
|
|
23
|
-
for (let i = 0; i < 50; i++) {
|
|
24
|
-
const task = manager.check(id);
|
|
25
|
-
if (task && expected.includes(task.status)) return;
|
|
26
|
-
await delay(50);
|
|
27
|
-
}
|
|
28
|
-
const task = manager.check(id);
|
|
29
|
-
const actual = task?.status ?? "(not found)";
|
|
30
|
-
throw new Error(
|
|
31
|
-
`Timed out waiting for status [${expected.join("/")}], got "${actual}"`,
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Windows detection — some assertions differ per platform
|
|
36
|
-
const IS_WIN = process.platform === "win32";
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Tests
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
describe("BackgroundManager", () => {
|
|
43
|
-
afterEach(() => {
|
|
44
|
-
BackgroundManager.resetInstance();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// ---- 1: run() returns ID immediately ----------------------------------
|
|
48
|
-
|
|
49
|
-
it("run() returns a task ID immediately", () => {
|
|
50
|
-
const mgr = BackgroundManager.getInstance();
|
|
51
|
-
const id = mgr.run({ command: IS_WIN ? "echo" : "echo", args: ["hello"] });
|
|
52
|
-
assert.ok(typeof id === "string");
|
|
53
|
-
assert.ok(id.length > 0, "id must not be empty");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// ---- 2: check() shows pending → running → completed -------------------
|
|
57
|
-
|
|
58
|
-
it("check() transitions pending -> running -> completed", async () => {
|
|
59
|
-
const mgr = BackgroundManager.getInstance();
|
|
60
|
-
const id = mgr.run({ command: IS_WIN ? "echo" : "echo", args: ["hello"] });
|
|
61
|
-
|
|
62
|
-
// Immediately after run() the task should be "pending"
|
|
63
|
-
// (spawn is deferred via setImmediate)
|
|
64
|
-
const initial = mgr.check(id);
|
|
65
|
-
assert.ok(initial, "task must exist immediately");
|
|
66
|
-
assert.equal(initial!.status, "pending");
|
|
67
|
-
|
|
68
|
-
// Wait for it to complete
|
|
69
|
-
await waitForStatus(mgr, id, "completed");
|
|
70
|
-
const done = mgr.check(id);
|
|
71
|
-
assert.equal(done!.exitCode, 0);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("resetInstance returns a fresh manager with cleared state", async () => {
|
|
75
|
-
const mgr = BackgroundManager.getInstance();
|
|
76
|
-
const id = mgr.run({
|
|
77
|
-
command: IS_WIN ? "powershell.exe" : "sleep",
|
|
78
|
-
args: IS_WIN
|
|
79
|
-
? ["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]
|
|
80
|
-
: ["30"],
|
|
81
|
-
timeout: 0,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
await waitForStatus(mgr, id, "running");
|
|
85
|
-
|
|
86
|
-
BackgroundManager.resetInstance();
|
|
87
|
-
|
|
88
|
-
const fresh = BackgroundManager.getInstance();
|
|
89
|
-
assert.notEqual(fresh, mgr);
|
|
90
|
-
assert.equal(fresh.list().length, 0);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// ---- 3: capture stdout -------------------------------------------------
|
|
94
|
-
|
|
95
|
-
it("captures stdout from a simple command", async () => {
|
|
96
|
-
const mgr = BackgroundManager.getInstance();
|
|
97
|
-
const id = mgr.run({
|
|
98
|
-
command: IS_WIN ? "echo" : "echo",
|
|
99
|
-
args: ["hello-background"],
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
await waitForStatus(mgr, id, "completed");
|
|
103
|
-
const task = mgr.check(id);
|
|
104
|
-
assert.ok(task, "task must exist");
|
|
105
|
-
assert.match(task!.output, /hello-background/);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// ---- 4: failed command (non-zero exit) ---------------------------------
|
|
109
|
-
|
|
110
|
-
it("detects a failed command (non-zero exit)", async () => {
|
|
111
|
-
const mgr = BackgroundManager.getInstance();
|
|
112
|
-
const id = mgr.run({
|
|
113
|
-
command: IS_WIN ? "cmd.exe" : "bash",
|
|
114
|
-
args: IS_WIN ? ["/c", "exit", "1"] : ["-c", "exit 1"],
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
await waitForStatus(mgr, id, "failed");
|
|
118
|
-
const task = mgr.check(id);
|
|
119
|
-
assert.ok(task);
|
|
120
|
-
assert.equal(task!.exitCode, 1);
|
|
121
|
-
assert.equal(task!.status, "failed");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// ---- 5: timeout enforcement --------------------------------------------
|
|
125
|
-
|
|
126
|
-
it("enforces timeout and marks task as timed_out", async () => {
|
|
127
|
-
const mgr = BackgroundManager.getInstance();
|
|
128
|
-
|
|
129
|
-
// Use a long-running command with a very short timeout (100 ms)
|
|
130
|
-
const id = mgr.run({
|
|
131
|
-
command: IS_WIN ? "powershell.exe" : "sleep",
|
|
132
|
-
args: IS_WIN
|
|
133
|
-
? ["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]
|
|
134
|
-
: ["30"],
|
|
135
|
-
timeout: 100,
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
await waitForStatus(mgr, id, "timed_out");
|
|
139
|
-
const task = mgr.check(id);
|
|
140
|
-
assert.ok(task);
|
|
141
|
-
assert.equal(task!.status, "timed_out");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
// ---- 6: kill() marks as cancelled --------------------------------------
|
|
145
|
-
|
|
146
|
-
it("kill() marks a running task as cancelled", async () => {
|
|
147
|
-
const mgr = BackgroundManager.getInstance();
|
|
148
|
-
|
|
149
|
-
const id = mgr.run({
|
|
150
|
-
command: IS_WIN ? "powershell.exe" : "sleep",
|
|
151
|
-
args: IS_WIN
|
|
152
|
-
? ["-NoProfile", "-Command", "Start-Sleep -Seconds 30"]
|
|
153
|
-
: ["30"],
|
|
154
|
-
timeout: 0, // no timeout
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
// Wait for the task to enter "running"
|
|
158
|
-
await waitForStatus(mgr, id, "running");
|
|
159
|
-
|
|
160
|
-
// Kill it
|
|
161
|
-
const killed = mgr.kill(id);
|
|
162
|
-
assert.ok(killed, "kill() must return true");
|
|
163
|
-
|
|
164
|
-
const task = mgr.check(id);
|
|
165
|
-
assert.ok(task);
|
|
166
|
-
assert.equal(task!.status, "cancelled");
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
// ---- 7: list() returns all tasks ---------------------------------------
|
|
170
|
-
|
|
171
|
-
it("list() returns all tracked tasks", async () => {
|
|
172
|
-
const mgr = BackgroundManager.getInstance();
|
|
173
|
-
const id1 = mgr.run({ command: IS_WIN ? "echo" : "echo", args: ["a"] });
|
|
174
|
-
const id2 = mgr.run({ command: IS_WIN ? "echo" : "echo", args: ["b"] });
|
|
175
|
-
|
|
176
|
-
const tasks = mgr.list();
|
|
177
|
-
const ids = tasks.map((t) => t.id);
|
|
178
|
-
assert.ok(ids.includes(id1), "list must contain first task");
|
|
179
|
-
assert.ok(ids.includes(id2), "list must contain second task");
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
// ---- 8: kill() on already-terminal task returns false ------------------
|
|
183
|
-
|
|
184
|
-
it("kill() returns false for already-completed task", async () => {
|
|
185
|
-
const mgr = BackgroundManager.getInstance();
|
|
186
|
-
const id = mgr.run({ command: IS_WIN ? "echo" : "echo", args: ["quick"] });
|
|
187
|
-
|
|
188
|
-
await waitForStatus(mgr, id, "completed");
|
|
189
|
-
const result = mgr.kill(id);
|
|
190
|
-
assert.equal(result, false, "kill() must return false on complete task");
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
// ---- 9: check() returns undefined for unknown ID -----------------------
|
|
194
|
-
|
|
195
|
-
it("check() returns undefined for unknown task ID", () => {
|
|
196
|
-
const mgr = BackgroundManager.getInstance();
|
|
197
|
-
const result = mgr.check("nonexistent-id");
|
|
198
|
-
assert.equal(result, undefined);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
// ---- 10: error output captured on command-not-found --------------------
|
|
202
|
-
|
|
203
|
-
it("captures error output when command does not exist", async () => {
|
|
204
|
-
const mgr = BackgroundManager.getInstance();
|
|
205
|
-
const id = mgr.run({ command: "this-command-does-not-exist-hopefully" });
|
|
206
|
-
|
|
207
|
-
await waitForStatus(mgr, id, "failed");
|
|
208
|
-
const task = mgr.check(id);
|
|
209
|
-
assert.ok(task);
|
|
210
|
-
// On Windows cmd.exe will emit an error; on Unix spawn error will fire
|
|
211
|
-
assert.ok(
|
|
212
|
-
task!.errorOutput.length > 0 || task!.output.length > 0,
|
|
213
|
-
"should have some error output",
|
|
214
|
-
);
|
|
215
|
-
});
|
|
216
|
-
});
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
export interface BackgroundTask {
|
|
2
|
-
id: string;
|
|
3
|
-
command: string;
|
|
4
|
-
args: string[];
|
|
5
|
-
cwd: string;
|
|
6
|
-
status: BackgroundTaskStatus;
|
|
7
|
-
output: string;
|
|
8
|
-
errorOutput: string;
|
|
9
|
-
exitCode: number | null;
|
|
10
|
-
startTime: number;
|
|
11
|
-
endTime: number | null;
|
|
12
|
-
timeout: number; // ms, 0 = no timeout
|
|
13
|
-
label?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type BackgroundTaskStatus =
|
|
17
|
-
| "pending"
|
|
18
|
-
| "running"
|
|
19
|
-
| "completed"
|
|
20
|
-
| "failed"
|
|
21
|
-
| "timed_out"
|
|
22
|
-
| "cancelled";
|
|
23
|
-
|
|
24
|
-
export interface BackgroundRunOptions {
|
|
25
|
-
command: string;
|
|
26
|
-
args?: string[];
|
|
27
|
-
cwd?: string;
|
|
28
|
-
timeout?: number; // ms, default 30000
|
|
29
|
-
label?: string;
|
|
30
|
-
env?: Record<string, string>;
|
|
31
|
-
}
|
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
import { spawn, exec, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import type {
|
|
4
|
-
BackgroundTask,
|
|
5
|
-
BackgroundTaskStatus,
|
|
6
|
-
BackgroundRunOptions,
|
|
7
|
-
} from "./interfaces.ts";
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Constants
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
const CLEANUP_INTERVAL_MS = 60_000; // Check for stale tasks every 60s
|
|
14
|
-
const TASK_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
|
15
|
-
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// Internal entry
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
|
|
20
|
-
interface TaskEntry {
|
|
21
|
-
task: BackgroundTask;
|
|
22
|
-
process: ChildProcess | null;
|
|
23
|
-
timeoutId?: ReturnType<typeof setTimeout>;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Manager
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
export class BackgroundManager {
|
|
31
|
-
private static instance: BackgroundManager | null = null;
|
|
32
|
-
private tasks = new Map<string, TaskEntry>();
|
|
33
|
-
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
34
|
-
|
|
35
|
-
private constructor() {
|
|
36
|
-
this.startCleanup();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// -----------------------------------------------------------------------
|
|
40
|
-
// Singleton
|
|
41
|
-
// -----------------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
static getInstance(): BackgroundManager {
|
|
44
|
-
if (!BackgroundManager.instance) {
|
|
45
|
-
BackgroundManager.instance = new BackgroundManager();
|
|
46
|
-
}
|
|
47
|
-
return BackgroundManager.instance;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Reset singleton — used in tests to get a clean slate. */
|
|
51
|
-
static resetInstance(): void {
|
|
52
|
-
const inst = BackgroundManager.instance;
|
|
53
|
-
if (inst) {
|
|
54
|
-
inst.destroy();
|
|
55
|
-
BackgroundManager.instance = null;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// -----------------------------------------------------------------------
|
|
60
|
-
// Public API
|
|
61
|
-
// -----------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Spawn a background process and return its task ID immediately.
|
|
65
|
-
* The task status starts as "pending" and transitions to "running" on
|
|
66
|
-
* the next event-loop tick when the process actually spawns.
|
|
67
|
-
*/
|
|
68
|
-
run(options: BackgroundRunOptions): string {
|
|
69
|
-
const id = randomUUID();
|
|
70
|
-
const cwd = options.cwd ?? process.cwd();
|
|
71
|
-
const args = options.args ?? [];
|
|
72
|
-
const timeout = options.timeout ?? 30_000;
|
|
73
|
-
|
|
74
|
-
const task: BackgroundTask = {
|
|
75
|
-
id,
|
|
76
|
-
command: options.command,
|
|
77
|
-
args,
|
|
78
|
-
cwd,
|
|
79
|
-
status: "pending",
|
|
80
|
-
output: "",
|
|
81
|
-
errorOutput: "",
|
|
82
|
-
exitCode: null,
|
|
83
|
-
startTime: Date.now(),
|
|
84
|
-
endTime: null,
|
|
85
|
-
timeout,
|
|
86
|
-
label: options.label,
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const entry: TaskEntry = { task, process: null };
|
|
90
|
-
this.tasks.set(id, entry);
|
|
91
|
-
|
|
92
|
-
// Defer to next tick so callers can observe "pending" immediately
|
|
93
|
-
setImmediate(() => {
|
|
94
|
-
this.spawnTask(id, options, entry);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
return id;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** Return current state of a tracked task, or undefined if not found. */
|
|
101
|
-
check(id: string): BackgroundTask | undefined {
|
|
102
|
-
this.sweepStale();
|
|
103
|
-
return this.tasks.get(id)?.task;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Return all tracked tasks (including completed ones). */
|
|
107
|
-
list(): BackgroundTask[] {
|
|
108
|
-
this.sweepStale();
|
|
109
|
-
return Array.from(this.tasks.values()).map((e) => e.task);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Kill a running / pending task.
|
|
114
|
-
* Returns true if the task existed and was killable, false otherwise.
|
|
115
|
-
*/
|
|
116
|
-
kill(id: string): boolean {
|
|
117
|
-
const entry = this.tasks.get(id);
|
|
118
|
-
if (!entry) return false;
|
|
119
|
-
|
|
120
|
-
const { task } = entry;
|
|
121
|
-
if (isTerminal(task.status)) return false;
|
|
122
|
-
|
|
123
|
-
this.killProcess(entry);
|
|
124
|
-
task.status = "cancelled";
|
|
125
|
-
task.endTime = Date.now();
|
|
126
|
-
this.clearTimeout(entry);
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// -----------------------------------------------------------------------
|
|
131
|
-
// Lifecycle (test cleanup / singleton teardown)
|
|
132
|
-
// -----------------------------------------------------------------------
|
|
133
|
-
|
|
134
|
-
/** Shut down the manager — kill all processes, clear state, stop timers. */
|
|
135
|
-
destroy(): void {
|
|
136
|
-
if (this.cleanupTimer) {
|
|
137
|
-
clearInterval(this.cleanupTimer);
|
|
138
|
-
this.cleanupTimer = null;
|
|
139
|
-
}
|
|
140
|
-
for (const [, entry] of this.tasks) {
|
|
141
|
-
if (!isTerminal(entry.task.status)) {
|
|
142
|
-
this.killProcess(entry);
|
|
143
|
-
}
|
|
144
|
-
this.clearTimeout(entry);
|
|
145
|
-
}
|
|
146
|
-
this.tasks.clear();
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// -----------------------------------------------------------------------
|
|
150
|
-
// Internals
|
|
151
|
-
// -----------------------------------------------------------------------
|
|
152
|
-
|
|
153
|
-
private spawnTask(
|
|
154
|
-
id: string,
|
|
155
|
-
options: BackgroundRunOptions,
|
|
156
|
-
entry: TaskEntry,
|
|
157
|
-
): void {
|
|
158
|
-
const { task } = entry;
|
|
159
|
-
const args = options.args ?? [];
|
|
160
|
-
const env = options.env;
|
|
161
|
-
const timeout = options.timeout ?? 30_000;
|
|
162
|
-
|
|
163
|
-
try {
|
|
164
|
-
const isWindows = process.platform === "win32";
|
|
165
|
-
|
|
166
|
-
// On Windows, wrap everything in cmd.exe /c so PATH and .exe
|
|
167
|
-
// resolution work the way users expect.
|
|
168
|
-
// Sanitize: strip shell metacharacters from the command to prevent
|
|
169
|
-
// command injection via LLM-generated command strings.
|
|
170
|
-
const command = isWindows ? "cmd.exe" : options.command;
|
|
171
|
-
const sanitizedCommand = isWindows
|
|
172
|
-
? options.command.replace(/[&|;<>^%!]/g, "")
|
|
173
|
-
: options.command;
|
|
174
|
-
const sanitizedArgs = args.map((a) =>
|
|
175
|
-
a.replace(/[&|;<>^%!]/g, ""),
|
|
176
|
-
);
|
|
177
|
-
const commandArgs = isWindows
|
|
178
|
-
? ["/d", "/c", sanitizedCommand, ...sanitizedArgs]
|
|
179
|
-
: args;
|
|
180
|
-
|
|
181
|
-
const child = spawn(command, commandArgs, {
|
|
182
|
-
cwd: task.cwd,
|
|
183
|
-
env: env ? { ...process.env, ...env } : undefined,
|
|
184
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
entry.process = child;
|
|
188
|
-
task.status = "running";
|
|
189
|
-
|
|
190
|
-
child.stdout?.on("data", (data: Buffer) => {
|
|
191
|
-
task.output += data.toString();
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
child.stderr?.on("data", (data: Buffer) => {
|
|
195
|
-
task.errorOutput += data.toString();
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
child.on("error", (err: Error) => {
|
|
199
|
-
task.status = "failed";
|
|
200
|
-
task.errorOutput += `\n[spawn error] ${err.message}`;
|
|
201
|
-
task.endTime = Date.now();
|
|
202
|
-
this.clearTimeout(entry);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
child.on("close", (code: number | null) => {
|
|
206
|
-
task.exitCode = code;
|
|
207
|
-
task.endTime = Date.now();
|
|
208
|
-
if (task.status === "running" || task.status === "pending") {
|
|
209
|
-
task.status = code === 0 ? "completed" : "failed";
|
|
210
|
-
}
|
|
211
|
-
this.clearTimeout(entry);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
// Timeout enforcement
|
|
215
|
-
if (timeout > 0) {
|
|
216
|
-
entry.timeoutId = setTimeout(() => {
|
|
217
|
-
if (!isTerminal(task.status)) {
|
|
218
|
-
this.killProcess(entry);
|
|
219
|
-
task.status = "timed_out";
|
|
220
|
-
task.endTime = Date.now();
|
|
221
|
-
}
|
|
222
|
-
}, timeout);
|
|
223
|
-
}
|
|
224
|
-
} catch (err) {
|
|
225
|
-
task.status = "failed";
|
|
226
|
-
task.errorOutput = `[exception] ${String(err)}`;
|
|
227
|
-
task.endTime = Date.now();
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
private async killProcess(entry: TaskEntry): Promise<void> {
|
|
232
|
-
if (!entry.process) return;
|
|
233
|
-
|
|
234
|
-
if (process.platform === "win32") {
|
|
235
|
-
// Forceful tree-kill via taskkill (more reliable than SIGTERM on Windows)
|
|
236
|
-
try {
|
|
237
|
-
await new Promise<void>((resolve, reject) => {
|
|
238
|
-
exec(`taskkill /pid ${entry.process!.pid} /f /t`, (err) => {
|
|
239
|
-
if (err) reject(err);
|
|
240
|
-
else resolve();
|
|
241
|
-
});
|
|
242
|
-
});
|
|
243
|
-
} catch {
|
|
244
|
-
// taskkill failed — process may already be dead
|
|
245
|
-
}
|
|
246
|
-
// Also try SIGTERM as a graceful fallback
|
|
247
|
-
try {
|
|
248
|
-
entry.process.kill("SIGTERM");
|
|
249
|
-
} catch {
|
|
250
|
-
/* already dead */
|
|
251
|
-
}
|
|
252
|
-
} else {
|
|
253
|
-
try {
|
|
254
|
-
entry.process.kill("SIGTERM");
|
|
255
|
-
} catch {
|
|
256
|
-
/* already dead */
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
private clearTimeout(entry: TaskEntry): void {
|
|
262
|
-
if (entry.timeoutId !== undefined) {
|
|
263
|
-
clearTimeout(entry.timeoutId);
|
|
264
|
-
entry.timeoutId = undefined;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
private startCleanup(): void {
|
|
269
|
-
this.cleanupTimer = setInterval(() => this.sweepStale(), CLEANUP_INTERVAL_MS);
|
|
270
|
-
this.cleanupTimer?.unref();
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Remove stale tasks:
|
|
275
|
-
* - Completed/failed tasks older than TASK_MAX_AGE_MS
|
|
276
|
-
* - Zombie processes (status "running" but process handle is dead)
|
|
277
|
-
*/
|
|
278
|
-
private sweepStale(): void {
|
|
279
|
-
const now = Date.now();
|
|
280
|
-
for (const [id, entry] of this.tasks) {
|
|
281
|
-
const { task } = entry;
|
|
282
|
-
|
|
283
|
-
// Completed/failed tasks older than threshold
|
|
284
|
-
if (task.endTime && now - task.endTime > TASK_MAX_AGE_MS) {
|
|
285
|
-
this.clearTimeout(entry);
|
|
286
|
-
this.tasks.delete(id);
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Check for zombie processes: status "running" but process has exited
|
|
291
|
-
// Use exitCode !== null as the reliable cross-platform check
|
|
292
|
-
if (task.status === "running" && entry.process) {
|
|
293
|
-
if (entry.process.exitCode !== null) {
|
|
294
|
-
// Process exited but the close event wasn't processed (zombie)
|
|
295
|
-
task.status = "failed";
|
|
296
|
-
task.endTime = Date.now();
|
|
297
|
-
this.tasks.delete(id);
|
|
298
|
-
}
|
|
299
|
-
} else if (task.status === "running" && !entry.process) {
|
|
300
|
-
// Process reference is gone but status not updated
|
|
301
|
-
task.status = "failed";
|
|
302
|
-
task.endTime = Date.now();
|
|
303
|
-
this.tasks.delete(id);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// ---------------------------------------------------------------------------
|
|
310
|
-
// Helpers
|
|
311
|
-
// ---------------------------------------------------------------------------
|
|
312
|
-
|
|
313
|
-
function isTerminal(status: BackgroundTaskStatus): boolean {
|
|
314
|
-
return (
|
|
315
|
-
status === "completed" ||
|
|
316
|
-
status === "failed" ||
|
|
317
|
-
status === "timed_out" ||
|
|
318
|
-
status === "cancelled"
|
|
319
|
-
);
|
|
320
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// ErrorRecoveryHook — PostToolUse, priority=50, phase=LATE
|
|
3
|
-
//
|
|
4
|
-
// After sub-agent call, check if output indicates error.
|
|
5
|
-
// Use the RecoveryHandler to match patterns and inject recovery actions.
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
|
|
8
|
-
import { HookPhase, HookResult } from "../types.ts";
|
|
9
|
-
import type { HookContext, PostToolUseHook } from "../types.ts";
|
|
10
|
-
import { RecoveryHandler } from "../../recovery/handler.ts";
|
|
11
|
-
import type { ErrorContext } from "../../recovery/interfaces.ts";
|
|
12
|
-
|
|
13
|
-
export const errorRecoveryHook: PostToolUseHook = {
|
|
14
|
-
metadata: {
|
|
15
|
-
name: "error-recovery",
|
|
16
|
-
priority: 50,
|
|
17
|
-
phase: HookPhase.LATE,
|
|
18
|
-
dependencies: [],
|
|
19
|
-
errorHandling: "isolate",
|
|
20
|
-
},
|
|
21
|
-
|
|
22
|
-
async execute(context: HookContext, output: string) {
|
|
23
|
-
// Check if the output looks like an error
|
|
24
|
-
const isErrorOutput = looksLikeError(output);
|
|
25
|
-
if (!isErrorOutput) {
|
|
26
|
-
return { result: HookResult.CONTINUE };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Classify the error and get a recovery action
|
|
30
|
-
const handler = RecoveryHandler.getInstance();
|
|
31
|
-
const errorContext: ErrorContext = {
|
|
32
|
-
sessionId: context.sessionId,
|
|
33
|
-
error: new Error(output.slice(0, 500)), // Truncate for classification
|
|
34
|
-
attempt: context._recoveryAttempt ?? 0,
|
|
35
|
-
timestamp: Date.now(),
|
|
36
|
-
agent: context.agent,
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const action = handler.handleError(errorContext);
|
|
40
|
-
|
|
41
|
-
// Build a recovery instruction based on the action
|
|
42
|
-
const recoveryInstruction = buildRecoveryInstruction(action);
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
result: HookResult.INJECT,
|
|
46
|
-
modifiedOutput: output,
|
|
47
|
-
injectRecovery: recoveryInstruction,
|
|
48
|
-
};
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Heuristic check: does the output look like an error?
|
|
54
|
-
* Looks for common error patterns in tool output.
|
|
55
|
-
*/
|
|
56
|
-
function looksLikeError(output: string): boolean {
|
|
57
|
-
if (!output || output.length === 0) return false;
|
|
58
|
-
|
|
59
|
-
const errorPatterns = [
|
|
60
|
-
/error/i,
|
|
61
|
-
/exception/i,
|
|
62
|
-
/failed/i,
|
|
63
|
-
/failure/i,
|
|
64
|
-
/unable to/i,
|
|
65
|
-
/could not/i,
|
|
66
|
-
/not found/i,
|
|
67
|
-
/ECONNREFUSED/i,
|
|
68
|
-
/ETIMEDOUT/i,
|
|
69
|
-
/rate.?limited/i,
|
|
70
|
-
/too many requests/i,
|
|
71
|
-
/context.?length/i,
|
|
72
|
-
/token.?limit/i,
|
|
73
|
-
/parse.?error/i,
|
|
74
|
-
/syntax.?error/i,
|
|
75
|
-
/timeout/i,
|
|
76
|
-
/execution.?timed.?out/i,
|
|
77
|
-
];
|
|
78
|
-
|
|
79
|
-
// Check first 2000 chars to avoid false positives in long output
|
|
80
|
-
const head = output.slice(0, 2000);
|
|
81
|
-
return errorPatterns.some((p) => p.test(head));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Build a recovery instruction string from a RecoveryAction.
|
|
86
|
-
*/
|
|
87
|
-
function buildRecoveryInstruction(
|
|
88
|
-
action: { type: string; delay?: number; maxAttempts?: number; reason: string; modifyPrompt?: string },
|
|
89
|
-
): string {
|
|
90
|
-
const parts: string[] = [
|
|
91
|
-
`[HOOK: Error Recovery]`,
|
|
92
|
-
`Action: ${action.type}`,
|
|
93
|
-
`Reason: ${action.reason}`,
|
|
94
|
-
];
|
|
95
|
-
|
|
96
|
-
if (action.delay) {
|
|
97
|
-
parts.push(`Delay: ${action.delay}ms before retry`);
|
|
98
|
-
}
|
|
99
|
-
if (action.maxAttempts) {
|
|
100
|
-
parts.push(`Max attempts: ${action.maxAttempts}`);
|
|
101
|
-
}
|
|
102
|
-
if (action.modifyPrompt) {
|
|
103
|
-
parts.push(`Modification: ${action.modifyPrompt}`);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return parts.join("\n");
|
|
107
|
-
}
|