ennodia 0.5.2 → 0.5.3
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/package.json +1 -1
- package/src/diagnosis.ts +3 -1
- package/src/harnesses.ts +23 -1
- package/src/readiness.ts +2 -0
- package/src/tasks.ts +22 -3
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/diagnosis.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TaskView } from "./tasks";
|
|
2
2
|
import { truncate } from "./internal";
|
|
3
|
+
import { openCodeQuotaError } from "./harnesses";
|
|
3
4
|
|
|
4
5
|
export type TaskOutputPreview = {
|
|
5
6
|
harnessId: string;
|
|
@@ -150,7 +151,8 @@ export function hasCodexQuotaError(stdout: string): boolean {
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
function hasQuotaError(task: TaskView): boolean {
|
|
153
|
-
return task.harnessId === "
|
|
154
|
+
return task.harnessId === "opencode" ? Boolean(openCodeQuotaError(task.stderr))
|
|
155
|
+
: task.harnessId === "codex" && (hasCodexQuotaError(task.stdout) || hasCodexQuotaError(task.stderr));
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
function hasAuthenticationError(task: TaskView): boolean {
|
package/src/harnesses.ts
CHANGED
|
@@ -95,6 +95,8 @@ export type HarnessAdapter = {
|
|
|
95
95
|
extractSessionId?: (stdout: string) => string | undefined;
|
|
96
96
|
/** Some public CLIs report a semantic failure with exit code zero. */
|
|
97
97
|
failureReason?: (stdout: string, stderr: string) => string | undefined;
|
|
98
|
+
/** A confirmed native failure that must stop a still-running CLI. */
|
|
99
|
+
streamFailureReason?: (stdout: string, stderr: string) => string | undefined;
|
|
98
100
|
/** The CLI accepts operator instructions separately from the prompt. A
|
|
99
101
|
* worker then reads Ennodia's limits as its session's own, not as text
|
|
100
102
|
* inside the task, which a careful model can distrust. */
|
|
@@ -312,8 +314,10 @@ export const harnessAdapters: HarnessAdapter[] = [
|
|
|
312
314
|
notes: ["Runs through `opencode run` without permission-bypass flags."],
|
|
313
315
|
// Concurrent starts can fail while another instance holds its database.
|
|
314
316
|
startupConflict: /database is locked/i,
|
|
317
|
+
streamFailureReason: (_stdout, stderr) => openCodeQuotaError(stderr),
|
|
315
318
|
buildCommand: (commandPath, input) => {
|
|
316
|
-
|
|
319
|
+
// Retryable provider errors otherwise stay in OpenCode's private log.
|
|
320
|
+
const args = ["run", "--print-logs", "--log-level", "ERROR"];
|
|
317
321
|
|
|
318
322
|
if (input.cwd) {
|
|
319
323
|
args.push("--dir", input.cwd);
|
|
@@ -501,6 +505,24 @@ export const harnessAdapters: HarnessAdapter[] = [
|
|
|
501
505
|
},
|
|
502
506
|
];
|
|
503
507
|
|
|
508
|
+
/** OpenCode's public --print-logs stderr; never inspect worker stdout here. */
|
|
509
|
+
export function openCodeQuotaError(stderr: string): string | undefined {
|
|
510
|
+
for (const line of stderr.split(/\r?\n/)) {
|
|
511
|
+
if (!/^timestamp=\S+ level=ERROR /.test(line) ||
|
|
512
|
+
!/ message="stream error" /.test(line) ||
|
|
513
|
+
!/ small=false /.test(line) || !/ mode=primary /.test(line)) continue;
|
|
514
|
+
// Ignore small/title requests: they can fail while the main agent works.
|
|
515
|
+
const encoded = / error\.error=("(?:[^"\\]|\\.)*")(?= |$)/.exec(line)?.[1];
|
|
516
|
+
if (!encoded) continue;
|
|
517
|
+
let message: string;
|
|
518
|
+
try { message = JSON.parse(encoded); } catch { continue; }
|
|
519
|
+
if (/^AI_APICallError:\s*(?:OpenCode )?Go usage limit (?:reached|exceeded)\b/i.test(message)) {
|
|
520
|
+
return message.replace(/^AI_APICallError:\s*/i, "");
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
|
|
504
526
|
function extractCodexSessionId(stdout: string): string | undefined {
|
|
505
527
|
for (const line of stdout.split(/\r?\n/)) {
|
|
506
528
|
const event = parseJsonObject(line);
|
package/src/readiness.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { stripVTControlCharacters } from "node:util";
|
|
6
6
|
import type { CommandSpec, HarnessAdapter, HarnessDiscovery, HarnessRunInput } from "./harnesses";
|
|
7
|
+
import { openCodeQuotaError } from "./harnesses";
|
|
7
8
|
import { signalOwnedProcess } from "./process";
|
|
8
9
|
|
|
9
10
|
export type ReadinessState = "unverified" | "ready" | "login-required" | "credentials-expired" | "setup-required" | "model-unavailable" | "quota-exhausted" | "unreachable" | "updating";
|
|
@@ -42,6 +43,7 @@ export class HarnessReadinessError extends Error {
|
|
|
42
43
|
|
|
43
44
|
/** Recognize native failure envelopes, never quoted prose inside an answer. */
|
|
44
45
|
export function nativeReadinessFailure(stdout: string, stderr: string, allowPlainStdout = false): ReadinessState | undefined {
|
|
46
|
+
if (openCodeQuotaError(stderr)) return "quota-exhausted";
|
|
45
47
|
const stderrLines = stripVTControlCharacters(stderr).split(/\r?\n/);
|
|
46
48
|
const lines = [...stderrLines, ...stripVTControlCharacters(stdout).split(/\r?\n/)];
|
|
47
49
|
for (const [index, line] of lines.entries()) {
|
package/src/tasks.ts
CHANGED
|
@@ -145,6 +145,8 @@ type InternalTask = Omit<
|
|
|
145
145
|
isolatedCwd?: string;
|
|
146
146
|
extractUsage?: HarnessAdapter["extractUsage"];
|
|
147
147
|
failureReason?: HarnessAdapter["failureReason"];
|
|
148
|
+
streamFailureReason?: HarnessAdapter["streamFailureReason"];
|
|
149
|
+
streamFailure?: string;
|
|
148
150
|
/** The exact spawn, kept so a start that met a startup conflict can run again. */
|
|
149
151
|
spawnInput: TaskSpawnInput;
|
|
150
152
|
stdinText?: string;
|
|
@@ -379,6 +381,7 @@ ${noticeLines.join("\n")}
|
|
|
379
381
|
isolatedCwd,
|
|
380
382
|
extractUsage: adapter.extractUsage,
|
|
381
383
|
failureReason: adapter.failureReason,
|
|
384
|
+
streamFailureReason: adapter.streamFailureReason,
|
|
382
385
|
spawnInput,
|
|
383
386
|
stdinText: commandSpec.stdin,
|
|
384
387
|
startupConflict: adapter.startupConflict,
|
|
@@ -407,7 +410,7 @@ ${noticeLines.join("\n")}
|
|
|
407
410
|
]).then(() => undefined);
|
|
408
411
|
|
|
409
412
|
task.timeout = setTimeout(() => {
|
|
410
|
-
if (task.cancelRequested) {
|
|
413
|
+
if (task.cancelRequested || task.streamFailure) {
|
|
411
414
|
return;
|
|
412
415
|
}
|
|
413
416
|
|
|
@@ -549,7 +552,7 @@ ${noticeLines.join("\n")}
|
|
|
549
552
|
terminalStatus = "cancelled";
|
|
550
553
|
} else {
|
|
551
554
|
terminalStatus =
|
|
552
|
-
exitCode === 0 && !task.timedOut && !task.drainTimedOut
|
|
555
|
+
exitCode === 0 && !task.timedOut && !task.drainTimedOut && !task.streamFailure
|
|
553
556
|
? "succeeded"
|
|
554
557
|
: "failed";
|
|
555
558
|
}
|
|
@@ -628,7 +631,7 @@ ${noticeLines.join("\n")}
|
|
|
628
631
|
private metStartupConflict(task: InternalTask): boolean {
|
|
629
632
|
return Boolean(task.startupConflict?.test(task.stderr)) &&
|
|
630
633
|
!task.stdout.trim() &&
|
|
631
|
-
!task.cancelRequested && !task.timedOut && !task.drainTimedOut && !this.shuttingDown &&
|
|
634
|
+
!task.cancelRequested && !task.timedOut && !task.drainTimedOut && !task.streamFailure && !this.shuttingDown &&
|
|
632
635
|
(task.startupRestarts ?? 0) < MAX_STARTUP_RESTARTS &&
|
|
633
636
|
Date.now() - task.attemptStartedAtMs < STARTUP_CONFLICT_WINDOW_MS;
|
|
634
637
|
}
|
|
@@ -903,6 +906,7 @@ ${noticeLines.join("\n")}
|
|
|
903
906
|
}
|
|
904
907
|
task.streamCaptureTruncated ||= task[streamName].length + chunk.length > MAX_CAPTURE_CHARS;
|
|
905
908
|
task[streamName] = appendLimited(task[streamName], chunk);
|
|
909
|
+
this.checkStreamFailure(task);
|
|
906
910
|
task.lastOutputAtMs = Date.now();
|
|
907
911
|
this.pushEvent(task, { type: streamName, message: chunk });
|
|
908
912
|
this.touch(task);
|
|
@@ -912,6 +916,7 @@ ${noticeLines.join("\n")}
|
|
|
912
916
|
if (finalChunk) {
|
|
913
917
|
task.streamCaptureTruncated ||= task[streamName].length + finalChunk.length > MAX_CAPTURE_CHARS;
|
|
914
918
|
task[streamName] = appendLimited(task[streamName], finalChunk);
|
|
919
|
+
this.checkStreamFailure(task);
|
|
915
920
|
task.lastOutputAtMs = Date.now();
|
|
916
921
|
this.pushEvent(task, { type: streamName, message: finalChunk });
|
|
917
922
|
this.touch(task);
|
|
@@ -922,6 +927,20 @@ ${noticeLines.join("\n")}
|
|
|
922
927
|
}
|
|
923
928
|
}
|
|
924
929
|
|
|
930
|
+
private checkStreamFailure(task: InternalTask): void {
|
|
931
|
+
if (!task.streamFailureReason || task.streamFailure || task.cancelRequested || task.timedOut) return;
|
|
932
|
+
try {
|
|
933
|
+
const reason = task.streamFailureReason(task.stdout, task.stderr);
|
|
934
|
+
if (!reason) return;
|
|
935
|
+
task.streamFailure = reason;
|
|
936
|
+
this.pushEvent(task, { type: "error", message: reason });
|
|
937
|
+
this.terminate(task);
|
|
938
|
+
} catch (error) {
|
|
939
|
+
task.streamFailureReason = undefined;
|
|
940
|
+
this.pushEvent(task, { type: "error", message: `Adapter stream check failed: ${String(error)}` });
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
925
944
|
private toView(task: InternalTask, options: TaskViewOptions = {}): TaskView {
|
|
926
945
|
const now = Date.now();
|
|
927
946
|
const end = task.endedAtMs ?? now;
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const ENNODIA_VERSION = "0.5.
|
|
1
|
+
export const ENNODIA_VERSION = "0.5.3";
|