ennodia 0.2.0 → 0.3.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/README.md +76 -119
- package/docs/evidence/building-ennodia.md +66 -0
- package/docs/evidence/docs-drift.md +73 -0
- package/docs/evidence/index.md +18 -0
- package/docs/evidence/measurement.md +40 -0
- package/docs/evidence/skill-trial.md +68 -0
- package/docs/getting-started.md +55 -32
- package/docs/guides/agent-skills.md +22 -8
- package/docs/guides/budgets-and-limits.md +1 -1
- package/docs/guides/pragmatic-mode.md +122 -0
- package/docs/guides/recipes.md +56 -0
- package/docs/guides/troubleshooting.md +48 -0
- package/docs/guides/understand-results.md +41 -0
- package/docs/index.md +27 -90
- package/docs/install.md +49 -16
- package/docs/reference/controlled-english.md +2 -3
- package/docs/reference/ennodia-io.md +27 -4
- package/docs/reference/mcp-tools.md +167 -10
- package/docs/reference/supported-harnesses.md +40 -1
- package/docs/roadmap.md +25 -0
- package/examples/docs-drift/README.md +8 -0
- package/examples/docs-drift/archive/migration.md +4 -0
- package/examples/docs-drift/check.ts +7 -0
- package/examples/docs-drift/config.ts +8 -0
- package/examples/docs-drift/demo.ts +54 -0
- package/examples/docs-drift/recorded-run.json +16 -0
- package/examples/skill-trial/README.md +8 -0
- package/examples/skill-trial/check.ts +15 -0
- package/examples/skill-trial/contract.md +7 -0
- package/examples/skill-trial/recorded-trial.json +42 -0
- package/examples/skill-trial/retry.ts +4 -0
- package/package.json +5 -2
- package/skills/pragmatic/SKILL.md +40 -0
- package/src/advisor.ts +5 -0
- package/src/budget.ts +16 -10
- package/src/cli.ts +3 -0
- package/src/compare.ts +18 -12
- package/src/compositional.ts +22 -4
- package/src/core.ts +77 -47
- package/src/diagnosis.ts +44 -7
- package/src/harnesses.ts +286 -33
- package/src/history.ts +4 -1
- package/src/index.ts +3 -0
- package/src/plan-advice.ts +5 -2
- package/src/planner.ts +24 -1
- package/src/pragmatic.ts +52 -0
- package/src/process.ts +18 -0
- package/src/runs.ts +85 -10
- package/src/server.ts +47 -17
- package/src/tasks.ts +191 -25
- package/src/version.ts +1 -1
package/src/tasks.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cpSync, existsSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { basename, join } from "node:path";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import type {
|
|
@@ -10,7 +10,9 @@ import type {
|
|
|
10
10
|
HarnessRunInput,
|
|
11
11
|
HarnessUsage,
|
|
12
12
|
} from "./harnesses";
|
|
13
|
+
import { assertNativeSandboxSupported, assertNativeSubagentsSupported, assertReasoningEffortSupported, DEFAULT_TASK_TIMEOUT_MS, withInputGuidance } from "./harnesses";
|
|
13
14
|
import { preview, tailItems } from "./internal";
|
|
15
|
+
import { signalOwnedProcess } from "./process";
|
|
14
16
|
import {
|
|
15
17
|
augmentPrompt,
|
|
16
18
|
toAppliedSkillInfo,
|
|
@@ -21,7 +23,7 @@ export type TaskStatus = "running" | "succeeded" | "failed" | "cancelled";
|
|
|
21
23
|
|
|
22
24
|
export type TaskEvent = {
|
|
23
25
|
at: string;
|
|
24
|
-
type: "started" | "stdout" | "stderr" | "exit" | "cancel" | "error";
|
|
26
|
+
type: "started" | "input-guidance" | "stdout" | "stderr" | "exit" | "cancel" | "error";
|
|
25
27
|
message?: string;
|
|
26
28
|
};
|
|
27
29
|
|
|
@@ -29,6 +31,14 @@ export type TaskView = {
|
|
|
29
31
|
id: string;
|
|
30
32
|
harnessId: string;
|
|
31
33
|
harnessName: string;
|
|
34
|
+
model?: string;
|
|
35
|
+
reasoningEffort?: HarnessRunInput["reasoningEffort"];
|
|
36
|
+
nativeSubagents?: "disabled";
|
|
37
|
+
nativeSandbox?: "read-only" | "workspace-write";
|
|
38
|
+
persistSession?: boolean;
|
|
39
|
+
continueTaskId?: string;
|
|
40
|
+
continuationTaskId?: string;
|
|
41
|
+
canContinue?: boolean;
|
|
32
42
|
status: TaskStatus;
|
|
33
43
|
cancelRequested: boolean;
|
|
34
44
|
pid?: number;
|
|
@@ -40,6 +50,9 @@ export type TaskView = {
|
|
|
40
50
|
endedAt?: string;
|
|
41
51
|
elapsedMs: number;
|
|
42
52
|
timeoutMs: number;
|
|
53
|
+
/** Execution cutoff, not an estimate of task completion. */
|
|
54
|
+
deadlineAt?: string;
|
|
55
|
+
timeoutSource?: "caller" | "task-manager-default";
|
|
43
56
|
remainingMs: number | null;
|
|
44
57
|
etaConfidence: "timeout-budget" | "unknown" | "complete";
|
|
45
58
|
lastOutputAt?: string;
|
|
@@ -58,6 +71,13 @@ export type TaskView = {
|
|
|
58
71
|
* over stdout for cross-harness comparisons: some adapters' stdout is a
|
|
59
72
|
* full session transcript, others' is just the final reply. */
|
|
60
73
|
finalMessage?: string;
|
|
74
|
+
finalMessageChars?: number;
|
|
75
|
+
/** Availability of non-whitespace answer evidence, even in compact views. */
|
|
76
|
+
hasOutput?: boolean;
|
|
77
|
+
/** The preferred answer was truncated during capture or in this view. */
|
|
78
|
+
outputTruncated?: boolean;
|
|
79
|
+
/** True when preferred answer bytes were lost during stream capture, independent of view limits. */
|
|
80
|
+
captureTruncated?: boolean;
|
|
61
81
|
/** Best-effort usage metrics parsed from the adapter's own output. */
|
|
62
82
|
usage?: HarnessUsage;
|
|
63
83
|
/** Set when this task ran against an isolated copy of the requested cwd
|
|
@@ -80,6 +100,7 @@ export type TaskSpawnInput = {
|
|
|
80
100
|
stdin: "ignore" | "pipe";
|
|
81
101
|
stdout: "pipe";
|
|
82
102
|
stderr: "pipe";
|
|
103
|
+
detached: boolean;
|
|
83
104
|
};
|
|
84
105
|
export type TaskSpawn = (input: TaskSpawnInput) => TaskProcess;
|
|
85
106
|
|
|
@@ -95,7 +116,11 @@ type InternalTask = Omit<
|
|
|
95
116
|
| "stdoutChars"
|
|
96
117
|
| "stderrChars"
|
|
97
118
|
| "eventCount"
|
|
119
|
+
| "finalMessageChars"
|
|
98
120
|
> & {
|
|
121
|
+
nativeSessionId?: string;
|
|
122
|
+
extractSessionId?: HarnessAdapter["extractSessionId"];
|
|
123
|
+
streamCaptureTruncated?: boolean;
|
|
99
124
|
createdAtMs: number;
|
|
100
125
|
updatedAtMs: number;
|
|
101
126
|
endedAtMs?: number;
|
|
@@ -105,11 +130,14 @@ type InternalTask = Omit<
|
|
|
105
130
|
settled?: Promise<void>;
|
|
106
131
|
streamReaders: Set<ReadableStreamDefaultReader<Uint8Array>>;
|
|
107
132
|
timeout?: Timer;
|
|
133
|
+
killTimeout?: Timer;
|
|
134
|
+
forceSignalled?: boolean;
|
|
108
135
|
finalMessagePath?: string;
|
|
109
136
|
/** Exact temporary directory allocated by TaskManager. Keep this separate
|
|
110
137
|
* from cwd because an adapter can override the command working directory. */
|
|
111
138
|
isolatedCwd?: string;
|
|
112
139
|
extractUsage?: HarnessAdapter["extractUsage"];
|
|
140
|
+
failureReason?: HarnessAdapter["failureReason"];
|
|
113
141
|
};
|
|
114
142
|
|
|
115
143
|
export type StartTaskResult = {
|
|
@@ -127,7 +155,6 @@ export type TaskManagerShutdownOptions = {
|
|
|
127
155
|
deadlineMs?: number;
|
|
128
156
|
};
|
|
129
157
|
|
|
130
|
-
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
131
158
|
const DEFAULT_DRAIN_TIMEOUT_MS = 1_000;
|
|
132
159
|
const DEFAULT_SHUTDOWN_DEADLINE_MS = 5_000;
|
|
133
160
|
const DEFAULT_MAX_TASKS = 200;
|
|
@@ -141,6 +168,8 @@ export class TaskManager {
|
|
|
141
168
|
private readonly maxTasks: number;
|
|
142
169
|
private readonly spawn: TaskSpawn;
|
|
143
170
|
private readonly removeIsolatedCwd: (path: string) => void;
|
|
171
|
+
private readonly ownsProcessGroups: boolean;
|
|
172
|
+
private readonly retainedTasks = new Map<string, number>();
|
|
144
173
|
private shuttingDown = false;
|
|
145
174
|
private shutdownPromise?: Promise<void>;
|
|
146
175
|
|
|
@@ -148,6 +177,7 @@ export class TaskManager {
|
|
|
148
177
|
this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
|
149
178
|
this.maxTasks = Math.max(1, options.maxTasks ?? DEFAULT_MAX_TASKS);
|
|
150
179
|
this.spawn = options.spawn ?? ((input) => Bun.spawn(input));
|
|
180
|
+
this.ownsProcessGroups = !options.spawn && process.platform !== "win32";
|
|
151
181
|
this.removeIsolatedCwd = options.removeIsolatedCwd ?? ((path) => {
|
|
152
182
|
rmSync(path, { recursive: true, force: true });
|
|
153
183
|
});
|
|
@@ -166,6 +196,29 @@ export class TaskManager {
|
|
|
166
196
|
throw new Error(`${adapter.name} is not runnable through Ennodia yet.`);
|
|
167
197
|
}
|
|
168
198
|
|
|
199
|
+
if (input.nativeSessionId) throw new Error("Use continueTaskId, not a native session ID.");
|
|
200
|
+
let previous: InternalTask | undefined;
|
|
201
|
+
if (input.continueTaskId) {
|
|
202
|
+
previous = this.tasks.get(input.continueTaskId);
|
|
203
|
+
if (!previous || previous.status === "running" || !previous.nativeSessionId || !previous.persistSession) {
|
|
204
|
+
throw new Error("Continuation requires a settled persistent task owned by this TaskManager.");
|
|
205
|
+
}
|
|
206
|
+
if (previous.continuationTaskId) throw new Error("Continue the latest task in this session, not an earlier turn.");
|
|
207
|
+
if (previous.harnessId !== adapter.id) throw new Error("Continuation must use the same harness.");
|
|
208
|
+
if (input.cwd && resolve(input.cwd) !== resolve(previous.cwd)) throw new Error("Continuation must use the same working directory.");
|
|
209
|
+
if (input.persistSession === false) throw new Error("Continuation requires session persistence.");
|
|
210
|
+
input = { ...input, cwd: previous.cwd, model: input.model ?? previous.model,
|
|
211
|
+
reasoningEffort: input.reasoningEffort ?? previous.reasoningEffort,
|
|
212
|
+
nativeSubagents: input.nativeSubagents ?? previous.nativeSubagents, persistSession: true };
|
|
213
|
+
}
|
|
214
|
+
if (input.persistSession && (!adapter.supportsSessionContinuation || input.isolateCwd)) {
|
|
215
|
+
throw new Error("Persistent sessions require a supported harness and a persistent working directory.");
|
|
216
|
+
}
|
|
217
|
+
assertReasoningEffortSupported(adapter, input.reasoningEffort);
|
|
218
|
+
assertNativeSubagentsSupported(adapter, input.nativeSubagents);
|
|
219
|
+
assertNativeSandboxSupported(adapter, input.nativeSandbox);
|
|
220
|
+
if (adapter.supportsNativeSandbox) input = { ...input, nativeSandbox: input.nativeSandbox ?? "read-only" };
|
|
221
|
+
|
|
169
222
|
const sourceCwd = input.cwd ?? process.cwd();
|
|
170
223
|
if (!existsSync(sourceCwd)) {
|
|
171
224
|
throw new Error(`Working directory does not exist: ${sourceCwd}`);
|
|
@@ -187,10 +240,22 @@ export class TaskManager {
|
|
|
187
240
|
// as a no-op.
|
|
188
241
|
const finalMessagePath = join(tmpdir(), `ennodia-final-${randomUUID()}.txt`);
|
|
189
242
|
|
|
190
|
-
const timeoutMs = input.timeoutMs ??
|
|
243
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS;
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
const deadlineMs = now + timeoutMs;
|
|
246
|
+
const deadlineAt = new Date(deadlineMs).toISOString();
|
|
247
|
+
const guidedPrompt = withInputGuidance(input.prompt, adapter);
|
|
248
|
+
const executionPrompt = `${guidedPrompt}
|
|
249
|
+
|
|
250
|
+
Ennodia execution notice:
|
|
251
|
+
Execution allowance: ${timeoutMs} ms from task launch (${input.timeoutMs === undefined ? "Ennodia default" : "caller supplied"}). Deadline (UTC): ${deadlineAt}. This is a hard cutoff, not a completion estimate or a target duration.
|
|
252
|
+
Check actual UTC time before attributing incomplete work to the deadline. Do not report a cutoff merely because the task is difficult or the answer is getting long. Continue within the remaining allowance unless complete or blocked by a demonstrated access or tool failure.
|
|
253
|
+
Configured native sandbox: ${input.nativeSandbox ?? "harness-managed; check available permissions"}. Report any mismatch between the assignment and available access before attempting the work.
|
|
254
|
+
Before the cutoff, return useful partial findings, verification performed, remaining work, and a justified continuation budget if needed. Only the caller can authorize a follow-up.
|
|
255
|
+
Use only tools and permissions actually available in this session; the caller's tools are not automatically inherited. If access is missing, report the exact tool, file, or permission and its impact. Try a permitted alternative when useful; do not repeat an unchanged failed attempt or claim success without evidence.`;
|
|
191
256
|
const augmentedPrompt = input.skills && input.skills.length > 0
|
|
192
|
-
? augmentPrompt(
|
|
193
|
-
:
|
|
257
|
+
? augmentPrompt(executionPrompt, input.skills, adapter.id)
|
|
258
|
+
: executionPrompt;
|
|
194
259
|
let commandSpec: CommandSpec;
|
|
195
260
|
let cwd: string;
|
|
196
261
|
let child: TaskProcess;
|
|
@@ -200,6 +265,7 @@ export class TaskManager {
|
|
|
200
265
|
cwd: resolvedInputCwd,
|
|
201
266
|
prompt: augmentedPrompt,
|
|
202
267
|
finalMessagePath,
|
|
268
|
+
nativeSessionId: previous?.nativeSessionId,
|
|
203
269
|
});
|
|
204
270
|
cwd = commandSpec.cwd ?? process.cwd();
|
|
205
271
|
|
|
@@ -214,6 +280,7 @@ export class TaskManager {
|
|
|
214
280
|
stdin: commandSpec.stdin === undefined ? "ignore" : "pipe",
|
|
215
281
|
stdout: "pipe",
|
|
216
282
|
stderr: "pipe",
|
|
283
|
+
detached: process.platform !== "win32",
|
|
217
284
|
});
|
|
218
285
|
} catch (error) {
|
|
219
286
|
cleanupFailedStartArtifacts(
|
|
@@ -223,11 +290,18 @@ export class TaskManager {
|
|
|
223
290
|
throw error;
|
|
224
291
|
}
|
|
225
292
|
|
|
226
|
-
const now = Date.now();
|
|
227
293
|
const task: InternalTask = {
|
|
228
294
|
id: randomUUID(),
|
|
229
295
|
harnessId: adapter.id,
|
|
230
296
|
harnessName: adapter.name,
|
|
297
|
+
model: input.model,
|
|
298
|
+
reasoningEffort: input.reasoningEffort,
|
|
299
|
+
nativeSubagents: input.nativeSubagents,
|
|
300
|
+
nativeSandbox: input.nativeSandbox,
|
|
301
|
+
persistSession: input.persistSession,
|
|
302
|
+
continueTaskId: input.continueTaskId,
|
|
303
|
+
nativeSessionId: previous?.nativeSessionId,
|
|
304
|
+
extractSessionId: input.persistSession ? adapter.extractSessionId : undefined,
|
|
231
305
|
status: "running",
|
|
232
306
|
cwd,
|
|
233
307
|
isolatedFrom,
|
|
@@ -240,6 +314,8 @@ export class TaskManager {
|
|
|
240
314
|
createdAtMs: now,
|
|
241
315
|
updatedAtMs: now,
|
|
242
316
|
timeoutMs,
|
|
317
|
+
timeoutSource: input.timeoutMs === undefined ? "task-manager-default" : "caller",
|
|
318
|
+
deadlineAt,
|
|
243
319
|
cancelRequested: false,
|
|
244
320
|
drainTimedOut: false,
|
|
245
321
|
timedOut: false,
|
|
@@ -251,6 +327,7 @@ export class TaskManager {
|
|
|
251
327
|
finalMessagePath,
|
|
252
328
|
isolatedCwd,
|
|
253
329
|
extractUsage: adapter.extractUsage,
|
|
330
|
+
failureReason: adapter.failureReason,
|
|
254
331
|
};
|
|
255
332
|
|
|
256
333
|
task.process = child;
|
|
@@ -259,8 +336,15 @@ export class TaskManager {
|
|
|
259
336
|
// Publish a task only after the process exists. A synchronous spawn
|
|
260
337
|
// failure must not leave a phantom running task that cannot settle.
|
|
261
338
|
this.tasks.set(task.id, task);
|
|
339
|
+
if (previous) previous.continuationTaskId = task.id;
|
|
262
340
|
this.pruneTasks(task.id);
|
|
263
|
-
this.pushEvent(task, { type: "started", message:
|
|
341
|
+
this.pushEvent(task, { type: "started", message: `Task started. Execution limit: ${timeoutMs}ms (${task.timeoutSource}); deadline: ${task.deadlineAt}. This is not a completion estimate.` });
|
|
342
|
+
if (guidedPrompt !== input.prompt) {
|
|
343
|
+
this.pushEvent(task, {
|
|
344
|
+
type: "input-guidance",
|
|
345
|
+
message: "Media input guidance was included in the worker prompt. Native inspection remains unverified until the worker reports evidence.",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
264
348
|
|
|
265
349
|
task.streamsDone = Promise.all([
|
|
266
350
|
this.pipeStreamSafely(task, "stdout", child.stdout),
|
|
@@ -269,7 +353,6 @@ export class TaskManager {
|
|
|
269
353
|
|
|
270
354
|
task.timeout = setTimeout(() => {
|
|
271
355
|
if (task.cancelRequested) {
|
|
272
|
-
child.kill();
|
|
273
356
|
return;
|
|
274
357
|
}
|
|
275
358
|
|
|
@@ -278,8 +361,8 @@ export class TaskManager {
|
|
|
278
361
|
type: "error",
|
|
279
362
|
message: `Timed out after ${timeoutMs}ms.`,
|
|
280
363
|
});
|
|
281
|
-
|
|
282
|
-
},
|
|
364
|
+
this.terminate(task);
|
|
365
|
+
}, Math.max(0, deadlineMs - Date.now()));
|
|
283
366
|
|
|
284
367
|
task.settled = this.watchExit(task, child);
|
|
285
368
|
void task.settled;
|
|
@@ -298,7 +381,7 @@ export class TaskManager {
|
|
|
298
381
|
type: "error",
|
|
299
382
|
message: error instanceof Error ? error.message : String(error),
|
|
300
383
|
});
|
|
301
|
-
|
|
384
|
+
this.terminate(task);
|
|
302
385
|
}
|
|
303
386
|
}
|
|
304
387
|
|
|
@@ -323,6 +406,19 @@ export class TaskManager {
|
|
|
323
406
|
return task ? this.toView(task, options) : undefined;
|
|
324
407
|
}
|
|
325
408
|
|
|
409
|
+
/** Keep evidence alive until its owning operation has recorded its result. */
|
|
410
|
+
retain(taskId: string): void {
|
|
411
|
+
if (!this.tasks.has(taskId)) throw new Error(`Unknown task: ${taskId}`);
|
|
412
|
+
this.retainedTasks.set(taskId, (this.retainedTasks.get(taskId) ?? 0) + 1);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
release(taskId: string): void {
|
|
416
|
+
const count = this.retainedTasks.get(taskId) ?? 0;
|
|
417
|
+
if (count > 1) this.retainedTasks.set(taskId, count - 1);
|
|
418
|
+
else this.retainedTasks.delete(taskId);
|
|
419
|
+
this.pruneTasks();
|
|
420
|
+
}
|
|
421
|
+
|
|
326
422
|
cancel(taskId: string): TaskView {
|
|
327
423
|
const task = this.tasks.get(taskId);
|
|
328
424
|
if (!task) {
|
|
@@ -380,7 +476,7 @@ export class TaskManager {
|
|
|
380
476
|
type: "error",
|
|
381
477
|
message: `Shutdown deadline exceeded after ${deadlineMs}ms; force-killing task.`,
|
|
382
478
|
});
|
|
383
|
-
|
|
479
|
+
this.signal(task, "SIGKILL");
|
|
384
480
|
}
|
|
385
481
|
|
|
386
482
|
await Promise.allSettled(
|
|
@@ -397,21 +493,22 @@ export class TaskManager {
|
|
|
397
493
|
task: InternalTask,
|
|
398
494
|
child: TaskProcess,
|
|
399
495
|
): Promise<void> {
|
|
496
|
+
let terminalStatus: TaskStatus = "failed";
|
|
400
497
|
try {
|
|
401
498
|
const exitCode = await child.exited;
|
|
499
|
+
if (task.killTimeout) this.signal(task, "SIGKILL");
|
|
402
500
|
await this.waitForOutputDrain(task);
|
|
403
501
|
task.exitCode = exitCode;
|
|
404
502
|
|
|
405
503
|
if (task.cancelRequested) {
|
|
406
|
-
|
|
407
|
-
} else
|
|
408
|
-
|
|
504
|
+
terminalStatus = "cancelled";
|
|
505
|
+
} else {
|
|
506
|
+
terminalStatus =
|
|
409
507
|
exitCode === 0 && !task.timedOut && !task.drainTimedOut
|
|
410
508
|
? "succeeded"
|
|
411
509
|
: "failed";
|
|
412
510
|
}
|
|
413
511
|
|
|
414
|
-
task.endedAtMs = Date.now();
|
|
415
512
|
this.pushEvent(task, {
|
|
416
513
|
type: "exit",
|
|
417
514
|
message: `Process exited with code ${exitCode}.`,
|
|
@@ -420,21 +517,37 @@ export class TaskManager {
|
|
|
420
517
|
await this.waitForOutputDrain(task);
|
|
421
518
|
|
|
422
519
|
if (task.cancelRequested) {
|
|
423
|
-
|
|
424
|
-
} else
|
|
425
|
-
|
|
520
|
+
terminalStatus = "cancelled";
|
|
521
|
+
} else {
|
|
522
|
+
terminalStatus = "failed";
|
|
426
523
|
}
|
|
427
524
|
|
|
428
|
-
task.endedAtMs = Date.now();
|
|
429
525
|
this.pushEvent(task, {
|
|
430
526
|
type: "error",
|
|
431
527
|
message: error instanceof Error ? error.message : String(error),
|
|
432
528
|
});
|
|
433
529
|
} finally {
|
|
530
|
+
// A CLI can exit before its build/shell children. Clean up the owned
|
|
531
|
+
// group before deleting an isolated checkout or reporting completion.
|
|
532
|
+
this.signal(task, "SIGKILL");
|
|
434
533
|
await this.collectFinalMessage(task);
|
|
534
|
+
if (terminalStatus === "succeeded" && task.failureReason) {
|
|
535
|
+
try {
|
|
536
|
+
const reason = task.failureReason(task.finalMessage || task.stdout, task.stderr);
|
|
537
|
+
if (reason) {
|
|
538
|
+
terminalStatus = "failed";
|
|
539
|
+
this.pushEvent(task, { type: "error", message: reason });
|
|
540
|
+
}
|
|
541
|
+
} catch (error) {
|
|
542
|
+
terminalStatus = "failed";
|
|
543
|
+
this.pushEvent(task, { type: "error", message: `Adapter result check failed: ${String(error)}` });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
435
546
|
this.collectUsage(task);
|
|
436
547
|
this.cleanupIsolatedCwd(task);
|
|
437
548
|
this.clearTimers(task);
|
|
549
|
+
task.status = task.cancelRequested ? "cancelled" : terminalStatus;
|
|
550
|
+
task.endedAtMs = Date.now();
|
|
438
551
|
this.touch(task);
|
|
439
552
|
this.pruneTasks(task.id);
|
|
440
553
|
}
|
|
@@ -464,7 +577,7 @@ export class TaskManager {
|
|
|
464
577
|
}
|
|
465
578
|
|
|
466
579
|
private collectUsage(task: InternalTask): void {
|
|
467
|
-
if (!task.extractUsage) {
|
|
580
|
+
if (!task.extractUsage || task.streamCaptureTruncated) {
|
|
468
581
|
return;
|
|
469
582
|
}
|
|
470
583
|
|
|
@@ -502,7 +615,8 @@ export class TaskManager {
|
|
|
502
615
|
}
|
|
503
616
|
|
|
504
617
|
const removable = [...this.tasks.values()]
|
|
505
|
-
.filter((task) => task.id !== protectedTaskId && task.status !== "running"
|
|
618
|
+
.filter((task) => task.id !== protectedTaskId && task.status !== "running" &&
|
|
619
|
+
!this.retainedTasks.has(task.id))
|
|
506
620
|
.sort((a, b) => a.createdAtMs - b.createdAtMs);
|
|
507
621
|
|
|
508
622
|
let removed = 0;
|
|
@@ -524,7 +638,26 @@ export class TaskManager {
|
|
|
524
638
|
this.pushEvent(task, { type: "cancel", message });
|
|
525
639
|
}
|
|
526
640
|
|
|
527
|
-
|
|
641
|
+
this.terminate(task);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private signal(task: InternalTask, signal: "SIGTERM" | "SIGKILL"): void {
|
|
645
|
+
if (!task.process || task.forceSignalled) return;
|
|
646
|
+
try {
|
|
647
|
+
signalOwnedProcess(task.process, signal, this.ownsProcessGroups);
|
|
648
|
+
if (signal === "SIGKILL") task.forceSignalled = true;
|
|
649
|
+
} catch (error) {
|
|
650
|
+
this.pushEvent(task, { type: "error", message: `Process cleanup failed: ${String(error)}` });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private terminate(task: InternalTask): void {
|
|
655
|
+
if (task.killTimeout) return;
|
|
656
|
+
this.signal(task, "SIGTERM");
|
|
657
|
+
task.killTimeout = setTimeout(() => {
|
|
658
|
+
this.pushEvent(task, { type: "error", message: "Termination grace period exceeded; force-killing task." });
|
|
659
|
+
this.signal(task, "SIGKILL");
|
|
660
|
+
}, 300);
|
|
528
661
|
}
|
|
529
662
|
|
|
530
663
|
private async waitForSettled(
|
|
@@ -638,6 +771,15 @@ export class TaskManager {
|
|
|
638
771
|
}
|
|
639
772
|
|
|
640
773
|
const chunk = decoder.decode(value, { stream: true });
|
|
774
|
+
if (streamName === "stdout" && task.extractSessionId && !task.nativeSessionId) {
|
|
775
|
+
try {
|
|
776
|
+
task.nativeSessionId = task.extractSessionId(task.stdout + chunk);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
task.extractSessionId = undefined;
|
|
779
|
+
this.pushEvent(task, { type: "error", message: `Session ID extraction failed: ${String(error)}` });
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
task.streamCaptureTruncated ||= task[streamName].length + chunk.length > MAX_CAPTURE_CHARS;
|
|
641
783
|
task[streamName] = appendLimited(task[streamName], chunk);
|
|
642
784
|
task.lastOutputAtMs = Date.now();
|
|
643
785
|
this.pushEvent(task, { type: streamName, message: chunk });
|
|
@@ -646,6 +788,7 @@ export class TaskManager {
|
|
|
646
788
|
|
|
647
789
|
const finalChunk = decoder.decode();
|
|
648
790
|
if (finalChunk) {
|
|
791
|
+
task.streamCaptureTruncated ||= task[streamName].length + finalChunk.length > MAX_CAPTURE_CHARS;
|
|
649
792
|
task[streamName] = appendLimited(task[streamName], finalChunk);
|
|
650
793
|
task.lastOutputAtMs = Date.now();
|
|
651
794
|
this.pushEvent(task, { type: streamName, message: finalChunk });
|
|
@@ -674,6 +817,14 @@ export class TaskManager {
|
|
|
674
817
|
id: task.id,
|
|
675
818
|
harnessId: task.harnessId,
|
|
676
819
|
harnessName: task.harnessName,
|
|
820
|
+
model: task.model,
|
|
821
|
+
reasoningEffort: task.reasoningEffort,
|
|
822
|
+
nativeSubagents: task.nativeSubagents,
|
|
823
|
+
nativeSandbox: task.nativeSandbox,
|
|
824
|
+
persistSession: task.persistSession,
|
|
825
|
+
continueTaskId: task.continueTaskId,
|
|
826
|
+
continuationTaskId: task.continuationTaskId,
|
|
827
|
+
canContinue: Boolean(!running && task.persistSession && task.nativeSessionId && !task.continuationTaskId),
|
|
677
828
|
status: task.status,
|
|
678
829
|
cancelRequested: task.cancelRequested,
|
|
679
830
|
pid: task.pid,
|
|
@@ -685,6 +836,8 @@ export class TaskManager {
|
|
|
685
836
|
endedAt: task.endedAtMs ? new Date(task.endedAtMs).toISOString() : undefined,
|
|
686
837
|
elapsedMs,
|
|
687
838
|
timeoutMs: task.timeoutMs,
|
|
839
|
+
timeoutSource: task.timeoutSource,
|
|
840
|
+
deadlineAt: task.deadlineAt,
|
|
688
841
|
remainingMs,
|
|
689
842
|
etaConfidence: running ? "timeout-budget" : "complete",
|
|
690
843
|
lastOutputAt: task.lastOutputAtMs
|
|
@@ -700,7 +853,16 @@ export class TaskManager {
|
|
|
700
853
|
stderr: includeOutput ? tail(task.stderr, maxOutputChars) : "",
|
|
701
854
|
events: includeEvents ? tailItems(task.events, maxEvents) : [],
|
|
702
855
|
appliedSkills: task.appliedSkills,
|
|
703
|
-
finalMessage: includeOutput
|
|
856
|
+
finalMessage: includeOutput && task.finalMessage !== undefined
|
|
857
|
+
? tail(task.finalMessage, maxOutputChars) : undefined,
|
|
858
|
+
finalMessageChars: task.finalMessage?.length ?? 0,
|
|
859
|
+
hasOutput: Boolean(task.finalMessage?.trim() || task.stdout.trim() || task.stderr.trim()),
|
|
860
|
+
captureTruncated: !task.finalMessage?.trim() && Boolean(task.streamCaptureTruncated),
|
|
861
|
+
outputTruncated: task.finalMessage?.trim()
|
|
862
|
+
? !includeOutput || task.finalMessage.length > maxOutputChars
|
|
863
|
+
: Boolean(task.streamCaptureTruncated ||
|
|
864
|
+
task.stdout.length > (includeOutput ? maxOutputChars : 0) ||
|
|
865
|
+
task.stderr.length > (includeOutput ? maxOutputChars : 0)),
|
|
704
866
|
usage: task.usage,
|
|
705
867
|
isolatedFrom: task.isolatedFrom,
|
|
706
868
|
};
|
|
@@ -726,6 +888,10 @@ export class TaskManager {
|
|
|
726
888
|
}
|
|
727
889
|
|
|
728
890
|
private clearTimers(task: InternalTask): void {
|
|
891
|
+
if (task.killTimeout) {
|
|
892
|
+
clearTimeout(task.killTimeout);
|
|
893
|
+
task.killTimeout = undefined;
|
|
894
|
+
}
|
|
729
895
|
if (task.timeout) {
|
|
730
896
|
clearTimeout(task.timeout);
|
|
731
897
|
task.timeout = undefined;
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const ENNODIA_VERSION = "0.
|
|
1
|
+
export const ENNODIA_VERSION = "0.3.0";
|