klyro 0.1.63 → 1.0.1
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/dist/agent/anthropic-adapter.d.ts +36 -0
- package/dist/agent/anthropic-adapter.js +73 -16
- package/dist/agent/capabilities.d.ts +145 -0
- package/dist/agent/capabilities.js +191 -0
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +232 -0
- package/dist/agent/orchestrator.js +589 -0
- package/dist/agent/provider-adapter.d.ts +17 -0
- package/dist/agent/provider-adapter.js +35 -3
- package/dist/agent/registry.d.ts +1 -0
- package/dist/agent/registry.js +1 -0
- package/dist/agent/retry.d.ts +13 -2
- package/dist/agent/retry.js +21 -3
- package/dist/agent/runtime.d.ts +70 -8
- package/dist/agent/runtime.js +205 -34
- package/dist/agent/scoped-registry.d.ts +22 -0
- package/dist/agent/scoped-registry.js +42 -0
- package/dist/agent/task-manager.d.ts +115 -0
- package/dist/agent/task-manager.js +250 -0
- package/dist/agent/worker-spawner.d.ts +17 -12
- package/dist/agent/worker-spawner.js +26 -20
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.js +30 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/config.d.ts +9 -3
- package/dist/cli/config.js +64 -3
- package/dist/cli/dotenv.d.ts +3 -0
- package/dist/cli/dotenv.js +57 -0
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/repl.js +183 -17
- package/dist/cli/run.d.ts +10 -11
- package/dist/cli/run.js +176 -14
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.d.ts +6 -0
- package/dist/context/klyro-md.js +25 -16
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/context/trust.d.ts +42 -0
- package/dist/context/trust.js +111 -0
- package/dist/events/catalog.d.ts +99 -0
- package/dist/index.js +93 -4
- package/dist/mcp/client.d.ts +55 -0
- package/dist/mcp/client.js +294 -0
- package/dist/mcp/config.d.ts +40 -0
- package/dist/mcp/config.js +99 -0
- package/dist/mcp/policy.d.ts +13 -0
- package/dist/mcp/policy.js +12 -0
- package/dist/mcp/registry.d.ts +72 -0
- package/dist/mcp/registry.js +244 -0
- package/dist/mcp/schema.d.ts +18 -0
- package/dist/mcp/schema.js +57 -0
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +1 -0
- package/dist/policy/engine.js +91 -10
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +17 -0
- package/dist/providers/model-info.js +35 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.d.ts +9 -0
- package/dist/tools/agent/spawn-agent.js +50 -0
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-get.d.ts +8 -0
- package/dist/tools/agent/task-get.js +40 -0
- package/dist/tools/agent/task-list.d.ts +4 -0
- package/dist/tools/agent/task-list.js +41 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +71 -0
- package/dist/tools/fs/edit-file.js +65 -0
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +66 -0
- package/dist/tools/fs/write-file.js +67 -0
- package/dist/tools/plan/todo-write.d.ts +1 -1
- package/dist/tools/registry.js +12 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +1 -0
- package/dist/tools/shell/shell-exec.js +83 -11
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +18 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +55 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +23 -12
- package/dist/verification/engine.js +3 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +33 -0
- package/dist/verification/scoped.js +28 -6
- package/package.json +1 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task manager — tracks the lifecycle of a (possibly nested) agent run.
|
|
3
|
+
*
|
|
4
|
+
* Each `spawn_agent` call creates a `TaskRecord` in a `TaskManager`.
|
|
5
|
+
* The manager owns an `AbortController` per task so that the parent (or
|
|
6
|
+
* the user) can cancel a running child without tearing down the entire
|
|
7
|
+
* process. It enforces a recursion / depth cap and emits `subtask.*`
|
|
8
|
+
* events so the event bus and trace writer can see the task tree.
|
|
9
|
+
*
|
|
10
|
+
* Exactly one task manager exists per session, wired into the orchestrator
|
|
11
|
+
* (src/agent/orchestrator.ts), which the runtime reaches via
|
|
12
|
+
* `parentContext`.
|
|
13
|
+
*/
|
|
14
|
+
import { globalBus } from '../events/bus.js';
|
|
15
|
+
export class TaskManager {
|
|
16
|
+
tasks = new Map();
|
|
17
|
+
watchers = new Map();
|
|
18
|
+
counter = 0;
|
|
19
|
+
sessionId;
|
|
20
|
+
bus;
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
this.sessionId = opts.sessionId;
|
|
23
|
+
this.bus = opts.bus ?? globalBus;
|
|
24
|
+
}
|
|
25
|
+
/** Create a task. Blocks instead of running when the recursion cap is exceeded. */
|
|
26
|
+
create(opts) {
|
|
27
|
+
const id = `task_${++this.counter}`;
|
|
28
|
+
const running = opts.autoStart !== false;
|
|
29
|
+
let resolveDone;
|
|
30
|
+
const record = {
|
|
31
|
+
id,
|
|
32
|
+
parentTaskId: opts.parentTaskId,
|
|
33
|
+
sessionId: this.sessionId,
|
|
34
|
+
agentName: opts.agentName,
|
|
35
|
+
status: running ? 'running' : 'queued',
|
|
36
|
+
cwd: opts.cwd,
|
|
37
|
+
depth: opts.depth,
|
|
38
|
+
maxDepth: opts.maxDepth,
|
|
39
|
+
model: opts.model,
|
|
40
|
+
startedAt: Date.now(),
|
|
41
|
+
summary: [],
|
|
42
|
+
abortController: new AbortController(),
|
|
43
|
+
done: new Promise((res) => {
|
|
44
|
+
resolveDone = res;
|
|
45
|
+
}),
|
|
46
|
+
_resolveDone: (r) => resolveDone(r),
|
|
47
|
+
};
|
|
48
|
+
this.tasks.set(id, record);
|
|
49
|
+
// Recursion / depth guard — enforced at the boundary so a deep-deep
|
|
50
|
+
// call chain can never be constructed in the first place.
|
|
51
|
+
if (opts.depth > opts.maxDepth) {
|
|
52
|
+
record.status = 'blocked';
|
|
53
|
+
record.timeoutHandle = undefined;
|
|
54
|
+
record.error = {
|
|
55
|
+
code: 'BLOCKED',
|
|
56
|
+
message: `recursion limit: depth ${opts.depth} exceeds maxDepth ${opts.maxDepth}`,
|
|
57
|
+
};
|
|
58
|
+
this.resolve(record);
|
|
59
|
+
this.emit(record);
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
// Parent abort → child abort chain.
|
|
63
|
+
if (opts.abortOnParent) {
|
|
64
|
+
const onAbort = () => this.cancel(id, 'parent aborted');
|
|
65
|
+
if (opts.abortOnParent.aborted) {
|
|
66
|
+
onAbort();
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
opts.abortOnParent.addEventListener('abort', onAbort, { once: true });
|
|
70
|
+
record._cleanupParentAbort = () => opts.abortOnParent.removeEventListener('abort', onAbort);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Timeout: transition to 'timed_out' and abort if still live.
|
|
74
|
+
if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) {
|
|
75
|
+
record.timeoutHandle = setTimeout(() => {
|
|
76
|
+
const r = this.tasks.get(id);
|
|
77
|
+
if (r && (r.status === 'running' || r.status === 'queued')) {
|
|
78
|
+
this.cancel(id, 'timeout', 'timed_out');
|
|
79
|
+
}
|
|
80
|
+
}, opts.timeoutMs);
|
|
81
|
+
}
|
|
82
|
+
this.emit(record);
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
/** Look up a live record. */
|
|
86
|
+
get(id) {
|
|
87
|
+
return this.tasks.get(id);
|
|
88
|
+
}
|
|
89
|
+
/** List tasks, optionally filtered by parent and/or status. Returns summaries. */
|
|
90
|
+
list(filter) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const r of this.tasks.values()) {
|
|
93
|
+
if (filter?.parentTaskId !== undefined && r.parentTaskId !== filter.parentTaskId)
|
|
94
|
+
continue;
|
|
95
|
+
if (filter?.status !== undefined && r.status !== filter.status)
|
|
96
|
+
continue;
|
|
97
|
+
out.push(this.toSummary(r));
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** Transition a task to a terminal status and resolve its `done`. */
|
|
102
|
+
finish(id, status, patch) {
|
|
103
|
+
const r = this.get(id);
|
|
104
|
+
if (!r)
|
|
105
|
+
throw new Error(`unknown task: ${id}`);
|
|
106
|
+
if (status !== 'blocked' && r.timeoutHandle) {
|
|
107
|
+
clearTimeout(r.timeoutHandle);
|
|
108
|
+
r.timeoutHandle = undefined;
|
|
109
|
+
}
|
|
110
|
+
if (patch?.summary)
|
|
111
|
+
r.summary.push(...patch.summary);
|
|
112
|
+
r.status = status;
|
|
113
|
+
r.finishedAt = Date.now();
|
|
114
|
+
if (patch?.error)
|
|
115
|
+
r.error = patch.error;
|
|
116
|
+
this.resolve(r);
|
|
117
|
+
this.emit(r);
|
|
118
|
+
return r;
|
|
119
|
+
}
|
|
120
|
+
/** Cancel a live task: abort, mark cancelled/timed_out, resolve done. */
|
|
121
|
+
cancel(id, reason = 'cancelled', finalStatus = 'cancelled') {
|
|
122
|
+
const r = this.get(id);
|
|
123
|
+
if (!r)
|
|
124
|
+
throw new Error(`unknown task: ${id}`);
|
|
125
|
+
if (r.status !== 'queued' && r.status !== 'running')
|
|
126
|
+
return r; // idempotent on terminal
|
|
127
|
+
r.abortController.abort(reason);
|
|
128
|
+
r._cleanupParentAbort?.();
|
|
129
|
+
r._cleanupParentAbort = undefined;
|
|
130
|
+
if (r.timeoutHandle) {
|
|
131
|
+
clearTimeout(r.timeoutHandle);
|
|
132
|
+
r.timeoutHandle = undefined;
|
|
133
|
+
}
|
|
134
|
+
r.status = finalStatus;
|
|
135
|
+
r.finishedAt = Date.now();
|
|
136
|
+
r.error = {
|
|
137
|
+
code: finalStatus === 'timed_out' ? 'TIMED_OUT' : 'CANCELLED',
|
|
138
|
+
message: reason,
|
|
139
|
+
};
|
|
140
|
+
this.resolve(r);
|
|
141
|
+
this.emit(r);
|
|
142
|
+
return r;
|
|
143
|
+
}
|
|
144
|
+
/** Confirm a task that policy/depth prevented from running. */
|
|
145
|
+
block(id, reason) {
|
|
146
|
+
return this.finish(id, 'blocked', {
|
|
147
|
+
error: { code: 'BLOCKED', message: reason },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** Cancel `id` and every transitive descendant. */
|
|
151
|
+
async cancelTree(id, reason = 'cancelled') {
|
|
152
|
+
const ids = [id];
|
|
153
|
+
const walk = (pid) => {
|
|
154
|
+
for (const r of this.tasks.values()) {
|
|
155
|
+
if (r.parentTaskId === pid) {
|
|
156
|
+
ids.push(r.id);
|
|
157
|
+
walk(r.id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
walk(id);
|
|
162
|
+
// Children first so a parent's abort doesn't race a child still being torn down.
|
|
163
|
+
ids.reverse();
|
|
164
|
+
for (const tid of ids) {
|
|
165
|
+
this.cancel(tid, reason);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Subscribe to status transitions for one task. Delivers the current status immediately. */
|
|
169
|
+
subscribe(id, listener) {
|
|
170
|
+
let set = this.watchers.get(id);
|
|
171
|
+
if (!set) {
|
|
172
|
+
set = new Set();
|
|
173
|
+
this.watchers.set(id, set);
|
|
174
|
+
}
|
|
175
|
+
set.add(listener);
|
|
176
|
+
const r = this.get(id);
|
|
177
|
+
if (r) {
|
|
178
|
+
try {
|
|
179
|
+
listener(r);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* ignore */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return () => {
|
|
186
|
+
set.delete(listener);
|
|
187
|
+
if (set.size === 0)
|
|
188
|
+
this.watchers.delete(id);
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** Cancel every live task (used on shutdown / ctrl-c). */
|
|
192
|
+
async shutdown() {
|
|
193
|
+
for (const id of [...this.tasks.keys()]) {
|
|
194
|
+
const r = this.get(id);
|
|
195
|
+
if (r && (r.status === 'queued' || r.status === 'running')) {
|
|
196
|
+
this.cancel(id, 'shutdown');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Compact serialisable summary. */
|
|
201
|
+
toSummary(r) {
|
|
202
|
+
return {
|
|
203
|
+
id: r.id,
|
|
204
|
+
...(r.parentTaskId ? { parentTaskId: r.parentTaskId } : {}),
|
|
205
|
+
agentName: r.agentName,
|
|
206
|
+
sessionId: r.sessionId,
|
|
207
|
+
status: r.status,
|
|
208
|
+
cwd: r.cwd,
|
|
209
|
+
depth: r.depth,
|
|
210
|
+
...(typeof r.model === 'string' ? { model: r.model } : {}),
|
|
211
|
+
...(r.finishedAt ? { durationMs: r.finishedAt - r.startedAt } : {}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/* ---------------------- internals ---------------------- */
|
|
215
|
+
/** Resolve a record's `done` (idempotent). */
|
|
216
|
+
resolve(r) {
|
|
217
|
+
this._resolveOnce(r);
|
|
218
|
+
}
|
|
219
|
+
_resolveOnce(r) {
|
|
220
|
+
try {
|
|
221
|
+
r._resolveDone(r);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
/* already resolved */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Deliver a transition to the task's own watchers, then each ancestor's. */
|
|
228
|
+
emit(r) {
|
|
229
|
+
this.notify(r.id, r);
|
|
230
|
+
let pid = r.parentTaskId;
|
|
231
|
+
while (pid) {
|
|
232
|
+
const p = this.get(pid);
|
|
233
|
+
this.notify(pid, r);
|
|
234
|
+
pid = p?.parentTaskId;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
notify(id, r) {
|
|
238
|
+
const set = this.watchers.get(id);
|
|
239
|
+
if (!set)
|
|
240
|
+
return;
|
|
241
|
+
for (const fn of [...set]) {
|
|
242
|
+
try {
|
|
243
|
+
fn(r);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* ignore */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* In-process worker spawner
|
|
2
|
+
* In-process worker spawner.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* (
|
|
4
|
+
* A "worker" here is just an async task tied to an AbortController. The
|
|
5
|
+
* spawner keeps the controllers so workers can actually be cancelled
|
|
6
|
+
* (user Ctrl+C, /abort, session shutdown, or a parent signalling a child).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Real subprocess / child-process spawning belongs to a later level;
|
|
9
|
+
* this module is the single-process cancellation primitive the agent
|
|
10
|
+
* orchestration layer builds on.
|
|
11
11
|
*/
|
|
12
12
|
export interface WorkerHandle {
|
|
13
13
|
/** Stable id, monotonically increasing per spawner instance. */
|
|
14
14
|
readonly id: number;
|
|
15
15
|
/** Human-readable label, used in logs. */
|
|
16
16
|
readonly label: string;
|
|
17
|
-
/**
|
|
17
|
+
/** AbortSignal delivered to the factory so it can observe cancellation. */
|
|
18
18
|
readonly signal: AbortSignal;
|
|
19
|
+
/** The controller backing `signal` — retained so we can actually abort. */
|
|
20
|
+
readonly controller: AbortController;
|
|
19
21
|
/** Resolves when the worker's main promise settles. */
|
|
20
22
|
readonly done: Promise<void>;
|
|
21
23
|
}
|
|
@@ -28,12 +30,15 @@ export declare class WorkerSpawner {
|
|
|
28
30
|
private nextId;
|
|
29
31
|
private readonly handles;
|
|
30
32
|
/**
|
|
31
|
-
* Spawn a worker. The factory
|
|
32
|
-
*
|
|
33
|
+
* Spawn a worker. The factory receives an AbortSignal; when the worker is
|
|
34
|
+
* cancelled (via `cancel`, `cancelAll`, or the parent signal wiring) that
|
|
35
|
+
* signal fires and `done` settles.
|
|
33
36
|
*/
|
|
34
37
|
spawn(factory: (signal: AbortSignal) => Promise<void>, opts?: SpawnOptions): WorkerHandle;
|
|
35
|
-
/**
|
|
36
|
-
|
|
38
|
+
/** Cancel a single worker: fires its AbortSignal. */
|
|
39
|
+
cancel(handle: WorkerHandle, reason?: unknown): void;
|
|
40
|
+
/** Cancel every active worker by firing their AbortSignals. */
|
|
41
|
+
cancelAll(reason?: unknown): void;
|
|
37
42
|
/** Count of currently-active workers. */
|
|
38
43
|
activeCount(): number;
|
|
39
44
|
/** Snapshot of all live worker handles. */
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* In-process worker spawner
|
|
2
|
+
* In-process worker spawner.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* (
|
|
4
|
+
* A "worker" here is just an async task tied to an AbortController. The
|
|
5
|
+
* spawner keeps the controllers so workers can actually be cancelled
|
|
6
|
+
* (user Ctrl+C, /abort, session shutdown, or a parent signalling a child).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Real subprocess / child-process spawning belongs to a later level;
|
|
9
|
+
* this module is the single-process cancellation primitive the agent
|
|
10
|
+
* orchestration layer builds on.
|
|
11
11
|
*/
|
|
12
12
|
export class WorkerSpawner {
|
|
13
13
|
nextId = 0;
|
|
14
14
|
handles = new Set();
|
|
15
15
|
/**
|
|
16
|
-
* Spawn a worker. The factory
|
|
17
|
-
*
|
|
16
|
+
* Spawn a worker. The factory receives an AbortSignal; when the worker is
|
|
17
|
+
* cancelled (via `cancel`, `cancelAll`, or the parent signal wiring) that
|
|
18
|
+
* signal fires and `done` settles.
|
|
18
19
|
*/
|
|
19
20
|
spawn(factory, opts = {}) {
|
|
20
21
|
const id = this.nextId++;
|
|
@@ -31,16 +32,16 @@ export class WorkerSpawner {
|
|
|
31
32
|
id,
|
|
32
33
|
label,
|
|
33
34
|
signal: ac.signal,
|
|
35
|
+
controller: ac,
|
|
34
36
|
done,
|
|
35
37
|
};
|
|
36
38
|
this.handles.add(handle);
|
|
37
39
|
if (!autoStart) {
|
|
38
|
-
// Caller will invoke factory manually and pass the signal
|
|
39
|
-
// we just resolve immediately so .done doesn't hang.
|
|
40
|
+
// Caller will invoke the factory manually and pass the signal.
|
|
40
41
|
resolveDone();
|
|
41
42
|
return handle;
|
|
42
43
|
}
|
|
43
|
-
// Fire-and-forget;
|
|
44
|
+
// Fire-and-forget; the caller awaits handle.done.
|
|
44
45
|
factory(ac.signal).then(() => {
|
|
45
46
|
this.handles.delete(handle);
|
|
46
47
|
resolveDone();
|
|
@@ -50,20 +51,25 @@ export class WorkerSpawner {
|
|
|
50
51
|
});
|
|
51
52
|
return handle;
|
|
52
53
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
54
|
+
/** Cancel a single worker: fires its AbortSignal. */
|
|
55
|
+
cancel(handle, reason) {
|
|
56
|
+
try {
|
|
57
|
+
handle.controller.abort(reason);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* already aborted */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Cancel every active worker by firing their AbortSignals. */
|
|
64
|
+
cancelAll(reason) {
|
|
55
65
|
for (const h of [...this.handles]) {
|
|
56
66
|
try {
|
|
57
|
-
h.
|
|
67
|
+
h.controller.abort(reason);
|
|
58
68
|
}
|
|
59
69
|
catch {
|
|
60
|
-
|
|
70
|
+
/* already aborted */
|
|
61
71
|
}
|
|
62
72
|
}
|
|
63
|
-
// Real abort uses the controller stored on the handle's signal — we
|
|
64
|
-
// don't keep the controller here. In the MVP, callers can pass their
|
|
65
|
-
// own AbortController via factory; this method is a no-op stub for
|
|
66
|
-
// the contract. See `cancel(handle)` for the per-worker variant.
|
|
67
73
|
}
|
|
68
74
|
/** Count of currently-active workers. */
|
|
69
75
|
activeCount() {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git worktree isolation for parallel write-capable child agents.
|
|
3
|
+
*
|
|
4
|
+
* Each write-capable child gets its own worktree on a branch
|
|
5
|
+
* `klyro/<taskId>` so concurrent children never clobber each other's files.
|
|
6
|
+
* `task_apply` merges the branch back into the parent tree; failures,
|
|
7
|
+
* cancellations, and timeouts remove the worktree best-effort.
|
|
8
|
+
*
|
|
9
|
+
* Semantics worth knowing:
|
|
10
|
+
* - Dirty parent: the merge runs in the parent checkout, so uncommitted
|
|
11
|
+
* parent edits can conflict. On conflict the merge is ABORTED (no
|
|
12
|
+
* commit), the branch is kept, and the caller gets MERGE_CONFLICT with
|
|
13
|
+
* the file list — resolve and re-apply.
|
|
14
|
+
* - Stale branch: a leftover `klyro/<taskId>` branch (e.g. from a crashed
|
|
15
|
+
* session) makes `createWorktree` fail. Delete the stale branch
|
|
16
|
+
* (`deleteBranch`) or sweep orphaned worktrees (`pruneStaleWorktrees`)
|
|
17
|
+
* before retrying.
|
|
18
|
+
* - No-apply: tasks that never reach `succeeded` are NEVER merged — their
|
|
19
|
+
* worktrees are removed best-effort and `task_apply` reports NOT_READY.
|
|
20
|
+
*
|
|
21
|
+
* All git invocations go through `execFile` argv (no shell) with a ~30s
|
|
22
|
+
* timeout, so this is safe on Windows.
|
|
23
|
+
*/
|
|
24
|
+
/** Per-command timeout for every git invocation. */
|
|
25
|
+
export declare const GIT_TIMEOUT_MS = 30000;
|
|
26
|
+
export interface WorktreeInfo {
|
|
27
|
+
worktreePath: string;
|
|
28
|
+
branch: string;
|
|
29
|
+
}
|
|
30
|
+
export interface MergeResult {
|
|
31
|
+
merged: boolean;
|
|
32
|
+
conflictFiles: string[];
|
|
33
|
+
}
|
|
34
|
+
/** True when `cwd` is inside a git working tree. Never throws — false on any failure. */
|
|
35
|
+
export declare function ensureGitRepo(cwd: string): Promise<boolean>;
|
|
36
|
+
/** Resolve the repo top-level for any path inside it. Throws when not in a repo. */
|
|
37
|
+
export declare function repoTopLevel(cwd: string): Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* Create an isolated worktree for a task on branch `klyro/<taskId>`,
|
|
40
|
+
* rooted at `<repo>/.klyro/worktrees/<taskId>` from HEAD.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createWorktree(opts: {
|
|
43
|
+
repoCwd: string;
|
|
44
|
+
taskId: string;
|
|
45
|
+
}): Promise<WorktreeInfo>;
|
|
46
|
+
/**
|
|
47
|
+
* Merge a task branch into the current checkout (`repoCwd`) with
|
|
48
|
+
* `git merge --no-ff --no-edit`. On conflict, parses `git status
|
|
49
|
+
* --porcelain` for unmerged paths (UU/AA/DD), aborts the merge WITHOUT
|
|
50
|
+
* committing, and returns `{ merged: false, conflictFiles }`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function mergeWorktree(opts: {
|
|
53
|
+
repoCwd: string;
|
|
54
|
+
branch: string;
|
|
55
|
+
}): Promise<MergeResult>;
|
|
56
|
+
/** Remove a worktree and prune its metadata. Throws with context on failure. */
|
|
57
|
+
export declare function removeWorktree(opts: {
|
|
58
|
+
repoCwd: string;
|
|
59
|
+
worktreePath: string;
|
|
60
|
+
force?: boolean;
|
|
61
|
+
}): Promise<void>;
|
|
62
|
+
/** Delete a task branch (best-effort cleanup after a successful merge). Never throws. */
|
|
63
|
+
export declare function deleteBranch(opts: {
|
|
64
|
+
repoCwd: string;
|
|
65
|
+
branch: string;
|
|
66
|
+
}): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Remove worktrees under `.klyro/worktrees` whose task id is NOT in
|
|
69
|
+
* `activeTaskIds` (orphans from crashed/timed-out sessions). Lists
|
|
70
|
+
* `git worktree list --porcelain`, force-removes each stale entry, and
|
|
71
|
+
* returns the removed paths. Never throws — per-worktree failures are
|
|
72
|
+
* skipped and a failing `git` itself yields `[]`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function pruneStaleWorktrees(repoCwd: string, activeTaskIds: readonly string[]): Promise<string[]>;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git worktree isolation for parallel write-capable child agents.
|
|
3
|
+
*
|
|
4
|
+
* Each write-capable child gets its own worktree on a branch
|
|
5
|
+
* `klyro/<taskId>` so concurrent children never clobber each other's files.
|
|
6
|
+
* `task_apply` merges the branch back into the parent tree; failures,
|
|
7
|
+
* cancellations, and timeouts remove the worktree best-effort.
|
|
8
|
+
*
|
|
9
|
+
* Semantics worth knowing:
|
|
10
|
+
* - Dirty parent: the merge runs in the parent checkout, so uncommitted
|
|
11
|
+
* parent edits can conflict. On conflict the merge is ABORTED (no
|
|
12
|
+
* commit), the branch is kept, and the caller gets MERGE_CONFLICT with
|
|
13
|
+
* the file list — resolve and re-apply.
|
|
14
|
+
* - Stale branch: a leftover `klyro/<taskId>` branch (e.g. from a crashed
|
|
15
|
+
* session) makes `createWorktree` fail. Delete the stale branch
|
|
16
|
+
* (`deleteBranch`) or sweep orphaned worktrees (`pruneStaleWorktrees`)
|
|
17
|
+
* before retrying.
|
|
18
|
+
* - No-apply: tasks that never reach `succeeded` are NEVER merged — their
|
|
19
|
+
* worktrees are removed best-effort and `task_apply` reports NOT_READY.
|
|
20
|
+
*
|
|
21
|
+
* All git invocations go through `execFile` argv (no shell) with a ~30s
|
|
22
|
+
* timeout, so this is safe on Windows.
|
|
23
|
+
*/
|
|
24
|
+
import { execFile } from 'node:child_process';
|
|
25
|
+
import { promisify } from 'node:util';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
import { filteredEnv } from '../tools/shell/shell-exec.js';
|
|
28
|
+
const execFileAsync = promisify(execFile);
|
|
29
|
+
/** Per-command timeout for every git invocation. */
|
|
30
|
+
export const GIT_TIMEOUT_MS = 30_000;
|
|
31
|
+
async function git(repoCwd, args) {
|
|
32
|
+
// Filtered env (same call shape as shell-exec) so secrets never reach
|
|
33
|
+
// child processes. Then strip repo-pinning vars so discovery is always
|
|
34
|
+
// relative to `repoCwd` (a sandbox may export GIT_DIR for its own
|
|
35
|
+
// harness — that must not make every directory look like a repo).
|
|
36
|
+
const env = { ...filteredEnv() };
|
|
37
|
+
delete env.GIT_DIR;
|
|
38
|
+
delete env.GIT_WORK_TREE;
|
|
39
|
+
delete env.GIT_NAMESPACE;
|
|
40
|
+
const r = await execFileAsync('git', [...args], {
|
|
41
|
+
cwd: repoCwd,
|
|
42
|
+
timeout: GIT_TIMEOUT_MS,
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
45
|
+
env,
|
|
46
|
+
});
|
|
47
|
+
return { stdout: r.stdout, stderr: r.stderr };
|
|
48
|
+
}
|
|
49
|
+
/** True when `cwd` is inside a git working tree. Never throws — false on any failure. */
|
|
50
|
+
export async function ensureGitRepo(cwd) {
|
|
51
|
+
try {
|
|
52
|
+
const r = await git(cwd, ['rev-parse', '--is-inside-work-tree']);
|
|
53
|
+
return r.stdout.trim() === 'true';
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Resolve the repo top-level for any path inside it. Throws when not in a repo. */
|
|
60
|
+
export async function repoTopLevel(cwd) {
|
|
61
|
+
const r = await git(cwd, ['rev-parse', '--show-toplevel']);
|
|
62
|
+
return r.stdout.trim();
|
|
63
|
+
}
|
|
64
|
+
function branchForTask(taskId) {
|
|
65
|
+
return `klyro/${taskId}`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Create an isolated worktree for a task on branch `klyro/<taskId>`,
|
|
69
|
+
* rooted at `<repo>/.klyro/worktrees/<taskId>` from HEAD.
|
|
70
|
+
*/
|
|
71
|
+
export async function createWorktree(opts) {
|
|
72
|
+
const branch = branchForTask(opts.taskId);
|
|
73
|
+
const top = await repoTopLevel(opts.repoCwd);
|
|
74
|
+
const worktreePath = path.join(top, '.klyro', 'worktrees', opts.taskId);
|
|
75
|
+
await git(opts.repoCwd, ['worktree', 'add', '-b', branch, worktreePath, 'HEAD']);
|
|
76
|
+
return { worktreePath, branch };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Merge a task branch into the current checkout (`repoCwd`) with
|
|
80
|
+
* `git merge --no-ff --no-edit`. On conflict, parses `git status
|
|
81
|
+
* --porcelain` for unmerged paths (UU/AA/DD), aborts the merge WITHOUT
|
|
82
|
+
* committing, and returns `{ merged: false, conflictFiles }`.
|
|
83
|
+
*/
|
|
84
|
+
export async function mergeWorktree(opts) {
|
|
85
|
+
try {
|
|
86
|
+
await git(opts.repoCwd, ['merge', '--no-ff', '--no-edit', opts.branch]);
|
|
87
|
+
return { merged: true, conflictFiles: [] };
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
const conflictFiles = await unmergedFiles(opts.repoCwd);
|
|
91
|
+
try {
|
|
92
|
+
await git(opts.repoCwd, ['merge', '--abort']);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* best-effort: leave the repo as-is if abort itself fails */
|
|
96
|
+
}
|
|
97
|
+
return { merged: false, conflictFiles };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function unmergedFiles(repoCwd) {
|
|
101
|
+
try {
|
|
102
|
+
const r = await git(repoCwd, ['status', '--porcelain']);
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const line of r.stdout.split('\n')) {
|
|
105
|
+
if (line.length < 4)
|
|
106
|
+
continue;
|
|
107
|
+
const x = line[0];
|
|
108
|
+
const y = line[1];
|
|
109
|
+
const unmerged = (x === 'U' || y === 'U' || x === 'A' || y === 'A') && (x === 'U' || y === 'U' || (x === 'A' && y === 'A'));
|
|
110
|
+
// Unmerged statuses: UU, AA, DD, AU, UA, UD, DU.
|
|
111
|
+
const code = `${x}${y}`;
|
|
112
|
+
if (['UU', 'AA', 'DD', 'AU', 'UA', 'UD', 'DU'].includes(code) || unmerged) {
|
|
113
|
+
out.push(line.slice(3).trim());
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Remove a worktree and prune its metadata. Throws with context on failure. */
|
|
123
|
+
export async function removeWorktree(opts) {
|
|
124
|
+
const args = ['worktree', 'remove', ...(opts.force ? ['--force'] : []), opts.worktreePath];
|
|
125
|
+
try {
|
|
126
|
+
await git(opts.repoCwd, args);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
throw new Error(`git worktree remove failed for ${opts.worktreePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
try {
|
|
133
|
+
await git(opts.repoCwd, ['worktree', 'prune']);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* best-effort */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Delete a task branch (best-effort cleanup after a successful merge). Never throws. */
|
|
141
|
+
export async function deleteBranch(opts) {
|
|
142
|
+
try {
|
|
143
|
+
await git(opts.repoCwd, ['branch', '-D', opts.branch]);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* best-effort */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Remove worktrees under `.klyro/worktrees` whose task id is NOT in
|
|
151
|
+
* `activeTaskIds` (orphans from crashed/timed-out sessions). Lists
|
|
152
|
+
* `git worktree list --porcelain`, force-removes each stale entry, and
|
|
153
|
+
* returns the removed paths. Never throws — per-worktree failures are
|
|
154
|
+
* skipped and a failing `git` itself yields `[]`.
|
|
155
|
+
*/
|
|
156
|
+
export async function pruneStaleWorktrees(repoCwd, activeTaskIds) {
|
|
157
|
+
const removed = [];
|
|
158
|
+
try {
|
|
159
|
+
const r = await git(repoCwd, ['worktree', 'list', '--porcelain']);
|
|
160
|
+
const active = new Set(activeTaskIds);
|
|
161
|
+
for (const line of r.stdout.split('\n')) {
|
|
162
|
+
const raw = /^worktree (.+)$/.exec(line.trim())?.[1];
|
|
163
|
+
if (!raw)
|
|
164
|
+
continue;
|
|
165
|
+
// git prints forward slashes even on win32 — normalize so returned
|
|
166
|
+
// paths textually match the path.join-built worktreePath.
|
|
167
|
+
const wp = path.normalize(raw);
|
|
168
|
+
// Only manage our own namespace: <repo>/.klyro/worktrees/<taskId>.
|
|
169
|
+
const parts = wp.split(/[\\/]/);
|
|
170
|
+
const idx = parts.lastIndexOf('worktrees');
|
|
171
|
+
if (idx < 1 || parts[idx - 1] !== '.klyro' || idx + 1 >= parts.length)
|
|
172
|
+
continue;
|
|
173
|
+
const taskId = parts[idx + 1];
|
|
174
|
+
if (!taskId || active.has(taskId))
|
|
175
|
+
continue;
|
|
176
|
+
try {
|
|
177
|
+
await removeWorktree({ repoCwd, worktreePath: wp, force: true });
|
|
178
|
+
removed.push(wp);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
/* skip — never throws */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* git itself failed (not a repo, binary missing) — return what we have */
|
|
187
|
+
}
|
|
188
|
+
return removed;
|
|
189
|
+
}
|