@runuai/host 0.9.169 → 0.9.170
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/lib/agent-cli.ts +56 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agents/codex.ts +222 -41
- package/lib/agents/machine-durable.ts +8 -2
- package/lib/agents/types.ts +16 -2
- package/lib/orchestrator.ts +72 -33
- package/package.json +1 -1
package/lib/agent-cli.ts
CHANGED
|
@@ -236,6 +236,7 @@ import { open, realpath } from "node:fs/promises";
|
|
|
236
236
|
import { dirname, isAbsolute, join, relative, resolve, sep, basename } from "node:path";
|
|
237
237
|
import { fileURLToPath } from "node:url";
|
|
238
238
|
import { randomUUID, createHash } from "node:crypto";
|
|
239
|
+
import { spawnSync } from "node:child_process";
|
|
239
240
|
|
|
240
241
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
241
242
|
let cfg = {};
|
|
@@ -249,6 +250,14 @@ if (!TOKEN || !API_URL) {
|
|
|
249
250
|
process.exit(1);
|
|
250
251
|
}
|
|
251
252
|
|
|
253
|
+
/** The task this agent's token names: its unsigned body, read for display
|
|
254
|
+
* only; the cloud verifies the token and resolves the id itself. */
|
|
255
|
+
function tokenTaskId() {
|
|
256
|
+
if (!TOKEN.startsWith("uai_")) return null;
|
|
257
|
+
try { return JSON.parse(Buffer.from(TOKEN.slice(4).split(".")[0], "base64url").toString("utf8")).t || null; }
|
|
258
|
+
catch { return null; }
|
|
259
|
+
}
|
|
260
|
+
|
|
252
261
|
function parseFlags(args) {
|
|
253
262
|
const flags = {}; const rest = [];
|
|
254
263
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -852,6 +861,23 @@ async function main() {
|
|
|
852
861
|
must: Number(flags.must), should: Number(flags.should), nits: Number(flags.nits), verdict: flags.verdict,
|
|
853
862
|
summary: flags.summary, assignmentId: flags.assignment, expectedRevision: Number(flags.revision || 0) })); return;
|
|
854
863
|
}
|
|
864
|
+
if (group === "branch" && action === "name") {
|
|
865
|
+
// uai/<taskId>/<name>: this task's server-issued branch namespace. A PR
|
|
866
|
+
// from it links to this task exactly like one from the task branch.
|
|
867
|
+
const name = pos[0] || "";
|
|
868
|
+
const valid = name.length > 0 && name.length <= 150 && !name.includes("..") &&
|
|
869
|
+
name.split("/").every(part => /^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(part) && !part.endsWith(".lock") && !part.endsWith("."));
|
|
870
|
+
if (pos.length !== 1 || Object.keys(flags).length || !valid) {
|
|
871
|
+
console.error("uai: branch name <name> (letters, digits, '.', '_', '-' and '/'; no leading '-' or '.')"); process.exit(1);
|
|
872
|
+
}
|
|
873
|
+
const taskId = tokenTaskId();
|
|
874
|
+
if (!taskId || !/^[0-9a-hjkmnp-tv-z]{26}$/.test(taskId)) { console.error("uai: branch name: this task's id is unavailable"); process.exit(1); }
|
|
875
|
+
const branch = "uai/" + taskId + "/" + name;
|
|
876
|
+
// git's own rule, when git is here to ask.
|
|
877
|
+
const git = spawnSync("git", ["check-ref-format", "--branch", branch], { encoding: "utf8" });
|
|
878
|
+
if (!git.error && git.status !== 0) { console.error("uai: branch name: git refuses " + branch); process.exit(1); }
|
|
879
|
+
out(branch); return;
|
|
880
|
+
}
|
|
855
881
|
if (group === "crew") {
|
|
856
882
|
// ADR-157: the task's built-in Astronaut (the @astronaut Uai builds, never a
|
|
857
883
|
// persona) changes its own crew. One agent per call, always with a --why.
|
|
@@ -1306,6 +1332,31 @@ async function main() {
|
|
|
1306
1332
|
console.error("uai: task project add <projectId|slug> [--no-wait] | task project status <operationId>");
|
|
1307
1333
|
process.exit(1);
|
|
1308
1334
|
}
|
|
1335
|
+
case "task rename": {
|
|
1336
|
+
// The name is prose, not flags: read the raw tail, like todo text.
|
|
1337
|
+
const name = rest.join(" ").trim();
|
|
1338
|
+
if (!name || name.length > 120) { console.error('uai: task rename "<new name>" (1-120 characters)'); process.exit(1); }
|
|
1339
|
+
const r = await api("PATCH", "/api/agent/task", { name });
|
|
1340
|
+
out(r.changed ? "renamed the task to " + r.task.name + " (branch stays " + r.task.branch + ")" : "the task is already named " + r.task.name);
|
|
1341
|
+
break;
|
|
1342
|
+
}
|
|
1343
|
+
case "task label": {
|
|
1344
|
+
const sub = rest[0];
|
|
1345
|
+
// One shell argument is one label, literally: commas are valid in label
|
|
1346
|
+
// names, so "bug, urgent" is a single label and bug urgent is two.
|
|
1347
|
+
const names = rest.slice(1).map((n) => String(n).trim()).filter(Boolean);
|
|
1348
|
+
if ((sub !== "add" && sub !== "remove") || !names.length) { console.error('uai: task label add <name> [<name>…] | task label remove <name> [<name>…] (quote a name with spaces or commas)'); process.exit(1); }
|
|
1349
|
+
const r = await api(sub === "add" ? "POST" : "DELETE", "/api/agent/task/labels", { names });
|
|
1350
|
+
const changed = sub === "add" ? r.added : r.removed;
|
|
1351
|
+
out((changed.length ? (sub === "add" ? "added " : "removed ") + changed.join(", ") : "no change") + " — labels: " + (r.labels.map((l) => l.name).join(", ") || "none"));
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
case "task delete": {
|
|
1355
|
+
if (pos.length !== 1 || Object.keys(flags).length) { console.error("uai: task delete <childId> (a draft you created; close a started child instead)"); process.exit(1); }
|
|
1356
|
+
const r = await api("DELETE", "/api/agent/tasks/" + encodeURIComponent(pos[0]));
|
|
1357
|
+
out("deleted draft " + r.deleted);
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
1309
1360
|
case "task search": {
|
|
1310
1361
|
const q = pos.join(" ") || flags.q || "";
|
|
1311
1362
|
if (!q) { console.error("uai: task search <query>"); process.exit(1); }
|
|
@@ -1350,6 +1401,10 @@ async function main() {
|
|
|
1350
1401
|
' uai task send <childId> "text"',
|
|
1351
1402
|
' uai task answer <childId> <cardId> "option text"',
|
|
1352
1403
|
' uai task close [--child <childId>] --status finished|canceled [--reason "final report"]',
|
|
1404
|
+
" uai task delete <childId> (delete a draft you created for this task; started children are closed instead)",
|
|
1405
|
+
' uai task rename "<new name>" (rename this running task; its branch keeps its name)',
|
|
1406
|
+
' uai task label add <name> [<name>…] (label this running task; one argument per label, quote "a, b" to keep it whole; missing labels are created)',
|
|
1407
|
+
" uai task label remove <name> [<name>…]",
|
|
1353
1408
|
" uai memory search <query> [--as handle] [--limit N|all]",
|
|
1354
1409
|
" uai memory save <text> [--project id] [--tags a,b]",
|
|
1355
1410
|
" uai memory delete <id>",
|
|
@@ -1358,6 +1413,7 @@ async function main() {
|
|
|
1358
1413
|
" uai todo edit <#id> <text>",
|
|
1359
1414
|
" uai todo remove <#id>",
|
|
1360
1415
|
" uai todo claim|done|reopen|unclaim <#id>",
|
|
1416
|
+
' uai branch name <name> (prints uai/<taskId>/<name>; a PR from it links to this task: git switch -c "$(uai branch name fix-foo)")',
|
|
1361
1417
|
" uai review report --pr <number|URL> --head <full-sha> --must <n> --should <n> --nits <n> --verdict approve|changes [--summary text] [--assignment id] [--revision n]",
|
|
1362
1418
|
" uai crew outcome @agent accepted|reworked|failed [--note text] [--assignment id] [--revision n]",
|
|
1363
1419
|
" Review reports require an authorized exact-head assignment. Outcomes are Secretary assessments, never human acceptance.",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
|
|
3
|
+
import { captureHostError, scrubHostString } from "./obs";
|
|
4
|
+
|
|
5
|
+
/** Provider errors can quote requests. Keep chat copy short and on one line;
|
|
6
|
+
* diagnostic reporting retains the full length with the host's redaction. */
|
|
7
|
+
export function agentErrorMessage(message: string): string {
|
|
8
|
+
const line = stripVTControlCharacters(scrubHostString(message))
|
|
9
|
+
.replace(/[\s\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
|
|
10
|
+
return line.length > 300 ? `${line.slice(0, 299).trimEnd()}…` : line;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function reportAgentError(
|
|
14
|
+
message: string,
|
|
15
|
+
context: { taskId: string; agentId: string; engine: string; rpcCode?: number },
|
|
16
|
+
{ capture = true }: { capture?: boolean } = {},
|
|
17
|
+
): void {
|
|
18
|
+
const diagnostic = scrubHostString(message);
|
|
19
|
+
console.warn(`[agent] ${context.taskId}/${context.agentId}: ${diagnostic}`);
|
|
20
|
+
// This uses the existing disclosure/opt-out gate and Sentry scrubber.
|
|
21
|
+
if (capture) captureHostError(new Error(diagnostic), context);
|
|
22
|
+
}
|
package/lib/agents/codex.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* initialized (notification) → sent by us
|
|
14
14
|
* thread/start (request) → result `{ thread: { id, … } }`
|
|
15
15
|
* turn/start (request) → output streams as notifications;
|
|
16
|
-
*
|
|
16
|
+
* response/activity acknowledges acceptance
|
|
17
17
|
*
|
|
18
18
|
* Streaming notifications consumed (`mapCodexNotification`):
|
|
19
19
|
* item/agentMessage/delta → message_delta
|
|
@@ -39,6 +39,7 @@ import { getDb, schema } from "../db";
|
|
|
39
39
|
import { requireContainerRuntimeOperational } from "../runtime-guard";
|
|
40
40
|
import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
|
|
41
41
|
import { newId } from "../ulid";
|
|
42
|
+
import { agentErrorMessage, reportAgentError } from "../agent-errors";
|
|
42
43
|
import { attachCompatibilityKeysMatch } from "./attach-compatibility";
|
|
43
44
|
import { createAgentTransport, type LineTransport } from "./transport";
|
|
44
45
|
import { rateLimitErrorUsage, resetSecondsToEpochMs } from "./rate-limit";
|
|
@@ -76,6 +77,10 @@ const CODEX_DEFAULT_EFFORT = "medium";
|
|
|
76
77
|
export const CODEX_USAGE_WARNING_PERCENT = 90;
|
|
77
78
|
export const CODEX_USAGE_LIMITED_PERCENT = 100;
|
|
78
79
|
export const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000;
|
|
80
|
+
/** Only bounds acceptance, never the duration of an acknowledged turn. A
|
|
81
|
+
* minute allows cold starts/network setup while bounding false working state.
|
|
82
|
+
* Queued starts wait for accepted work to finish before this clock runs. */
|
|
83
|
+
export const CODEX_TURN_START_TIMEOUT_MS = 60_000;
|
|
79
84
|
|
|
80
85
|
// ---------------------------------------------------------------------------
|
|
81
86
|
// Pure protocol mapping — JSON-RPC notification → AgentEvent[].
|
|
@@ -580,8 +585,20 @@ export class CodexSession implements AgentSession {
|
|
|
580
585
|
>();
|
|
581
586
|
/** permission_request id → the JSON-RPC request id to answer with. */
|
|
582
587
|
private readonly approvals = new Map<string, number | string>();
|
|
583
|
-
/**
|
|
584
|
-
private readonly
|
|
588
|
+
/** Responses can follow notifications, so keep ids until completion. */
|
|
589
|
+
private readonly turnStarts = new Map<number, {
|
|
590
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
591
|
+
turnId: string | null;
|
|
592
|
+
accepted: boolean;
|
|
593
|
+
settles: number;
|
|
594
|
+
}>();
|
|
595
|
+
/** Merged request id → the canonical start, until its turn completes. */
|
|
596
|
+
private readonly mergedStarts = new Map<number, number>();
|
|
597
|
+
private readonly firedRequests = new Map<number, string>();
|
|
598
|
+
/** Live provider turns plus unacknowledged starts, excluding merged aliases. */
|
|
599
|
+
private outstandingTurns = 0;
|
|
600
|
+
private failed = false;
|
|
601
|
+
private cliVersion: string | null = null;
|
|
585
602
|
|
|
586
603
|
private threadId: string | null = null;
|
|
587
604
|
private readonly ready: Promise<void>;
|
|
@@ -668,20 +685,17 @@ export class CodexSession implements AgentSession {
|
|
|
668
685
|
this.proc.onLine((line) => this.onLine(line));
|
|
669
686
|
this.proc.onExit((code) => {
|
|
670
687
|
if (this.closed) return;
|
|
671
|
-
this.
|
|
672
|
-
this.clearRateLimitsTimer();
|
|
673
|
-
this.turnStartRpcIds.clear();
|
|
674
|
-
if (code !== 0) {
|
|
688
|
+
if (!this.failed && (code !== 0 || this.outstandingTurns > 0)) {
|
|
675
689
|
const tail = this.proc.stderrTail.trim();
|
|
676
|
-
this.
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
`codex process exited (${code ?? "spawn failed"})` +
|
|
690
|
+
this.failTurn(
|
|
691
|
+
(code === null ? `${this.cliLabel} process stopped unexpectedly` :
|
|
692
|
+
`${this.cliLabel} process exited (${code})${code === 0 ? " before replying to the message" : ""}`) +
|
|
680
693
|
(tail
|
|
681
694
|
? `:\n${tail}`
|
|
682
|
-
: "
|
|
683
|
-
|
|
695
|
+
: ". Please try again."),
|
|
696
|
+
);
|
|
684
697
|
}
|
|
698
|
+
this.clearTurnState();
|
|
685
699
|
this.closed = true;
|
|
686
700
|
for (const waiter of this.pending.values()) {
|
|
687
701
|
waiter.reject(new Error("codex process exited"));
|
|
@@ -691,12 +705,9 @@ export class CodexSession implements AgentSession {
|
|
|
691
705
|
});
|
|
692
706
|
|
|
693
707
|
this.ready = this.handshake().catch((err: unknown) => {
|
|
694
|
-
this.
|
|
695
|
-
type: "error",
|
|
696
|
-
message: `codex handshake failed: ${
|
|
708
|
+
this.failTurn(`${this.cliLabel} could not start: ${
|
|
697
709
|
err instanceof Error ? err.message : String(err)
|
|
698
|
-
}`,
|
|
699
|
-
});
|
|
710
|
+
}`, (err as { rpcCode?: number }).rpcCode);
|
|
700
711
|
});
|
|
701
712
|
}
|
|
702
713
|
|
|
@@ -706,7 +717,7 @@ export class CodexSession implements AgentSession {
|
|
|
706
717
|
// close() severs this generation before the durable runner necessarily
|
|
707
718
|
// exits. Ignore buffered notifications so a late thread/started cannot
|
|
708
719
|
// restore the resume id that a live roster edit just cleared.
|
|
709
|
-
if (this.closed) return;
|
|
720
|
+
if (this.closed || this.failed) return;
|
|
710
721
|
let msg: unknown;
|
|
711
722
|
try {
|
|
712
723
|
msg = JSON.parse(line);
|
|
@@ -720,10 +731,13 @@ export class CodexSession implements AgentSession {
|
|
|
720
731
|
// Response to one of our requests (id, no method).
|
|
721
732
|
if (hasId && !("method" in msg)) {
|
|
722
733
|
const id = typeof msg.id === "number" ? msg.id : -1;
|
|
723
|
-
|
|
734
|
+
const fired = this.firedRequests.get(id);
|
|
735
|
+
this.firedRequests.delete(id);
|
|
736
|
+
if (this.turnStarts.has(id) || this.mergedStarts.has(id)) {
|
|
724
737
|
if (isObj(msg.error)) {
|
|
725
|
-
this.
|
|
726
|
-
|
|
738
|
+
this.failRpc(msg.error);
|
|
739
|
+
} else {
|
|
740
|
+
this.acknowledgeTurn(id, msg.result);
|
|
727
741
|
}
|
|
728
742
|
return;
|
|
729
743
|
}
|
|
@@ -743,6 +757,11 @@ export class CodexSession implements AgentSession {
|
|
|
743
757
|
} else {
|
|
744
758
|
waiter.resolve(msg.result);
|
|
745
759
|
}
|
|
760
|
+
} else if (fired === "turn/interrupt" && isObj(msg.error)) {
|
|
761
|
+
const code = typeof msg.error.code === "number" ? msg.error.code : undefined;
|
|
762
|
+
const message = `Couldn't stop the message: ${this.cliLabel}${code === undefined ? "" : ` (code ${code})`}: ${str(msg.error.message, "Unknown error. Please try again.")}`;
|
|
763
|
+
this.reportError(message, code);
|
|
764
|
+
this.emit({ type: "notice", message: agentErrorMessage(message) });
|
|
746
765
|
}
|
|
747
766
|
return;
|
|
748
767
|
}
|
|
@@ -760,6 +779,13 @@ export class CodexSession implements AgentSession {
|
|
|
760
779
|
// Notification (method, no id).
|
|
761
780
|
if ("method" in msg) {
|
|
762
781
|
const method = str(msg.method);
|
|
782
|
+
// Account usage responses are not proof of a running turn. Turn/item
|
|
783
|
+
// activity (including an approval request below) is acceptance even on
|
|
784
|
+
// CLIs that defer the turn/start response until the turn ends.
|
|
785
|
+
if (method === "turn/started" || method.startsWith("item/") ||
|
|
786
|
+
method === "thread/tokenUsage/updated" || method === "turn/completed") {
|
|
787
|
+
this.acknowledgeActivity(msg.params, method === "turn/started");
|
|
788
|
+
}
|
|
763
789
|
if (method === "thread/tokenUsage/updated") {
|
|
764
790
|
this.turnUsage.observe(msg.params);
|
|
765
791
|
}
|
|
@@ -767,10 +793,35 @@ export class CodexSession implements AgentSession {
|
|
|
767
793
|
method === "turn/completed"
|
|
768
794
|
? this.turnUsage.complete(msg.params, this.actualModel)
|
|
769
795
|
: undefined;
|
|
796
|
+
let settles = 1;
|
|
770
797
|
if (method === "turn/completed") {
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
798
|
+
if (isObj(msg.params) && isObj(msg.params.turn) &&
|
|
799
|
+
msg.params.turn.status === "failed" && isObj(msg.params.turn.error)) {
|
|
800
|
+
this.failRpc(msg.params.turn.error, completedUsage);
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
this.outstandingTurns = Math.max(0, this.outstandingTurns - 1);
|
|
804
|
+
const turnId = this.notificationTurnId(msg.params);
|
|
805
|
+
const completed = [...this.turnStarts].find(([, turn]) => turnId !== null && turn.turnId === turnId)
|
|
806
|
+
?? [...this.turnStarts].find(([, turn]) => turn.accepted);
|
|
807
|
+
if (completed) {
|
|
808
|
+
settles = completed[1].settles;
|
|
809
|
+
this.turnStarts.delete(completed[0]);
|
|
810
|
+
this.firedRequests.delete(completed[0]);
|
|
811
|
+
for (const [alias, owner] of this.mergedStarts) {
|
|
812
|
+
if (owner !== completed[0]) continue;
|
|
813
|
+
this.mergedStarts.delete(alias);
|
|
814
|
+
this.firedRequests.delete(alias);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
this.turnRunning = this.outstandingTurns > 0;
|
|
818
|
+
if (!this.turnRunning) this.clearTurnState();
|
|
819
|
+
else this.reconcileAcceptanceTimers();
|
|
820
|
+
}
|
|
821
|
+
if (method === "error") {
|
|
822
|
+
const params = isObj(msg.params) ? msg.params : {};
|
|
823
|
+
this.failRpc(isObj(params.error) ? params.error : {});
|
|
824
|
+
return;
|
|
774
825
|
}
|
|
775
826
|
// The thread id arrives on `thread/started`; also captured from
|
|
776
827
|
// the `thread/start` response in handshake().
|
|
@@ -790,7 +841,11 @@ export class CodexSession implements AgentSession {
|
|
|
790
841
|
Date.now(),
|
|
791
842
|
completedUsage,
|
|
792
843
|
)) {
|
|
793
|
-
this.emit(ev
|
|
844
|
+
this.emit(ev.type === "turn_complete" ? {
|
|
845
|
+
...ev,
|
|
846
|
+
...(settles > 1 ? { settles } : {}),
|
|
847
|
+
...(isObj(msg.params) && isObj(msg.params.turn) && msg.params.turn.status === "interrupted" ? { aborted: true } : {}),
|
|
848
|
+
} : ev);
|
|
794
849
|
}
|
|
795
850
|
}
|
|
796
851
|
}
|
|
@@ -801,6 +856,7 @@ export class CodexSession implements AgentSession {
|
|
|
801
856
|
params: unknown,
|
|
802
857
|
): void {
|
|
803
858
|
if (APPROVAL_METHODS.has(method)) {
|
|
859
|
+
this.acknowledgeActivity(params);
|
|
804
860
|
const permId = newId();
|
|
805
861
|
this.approvals.set(permId, id);
|
|
806
862
|
const p = isObj(params) ? params : {};
|
|
@@ -851,9 +907,10 @@ export class CodexSession implements AgentSession {
|
|
|
851
907
|
|
|
852
908
|
/** Send a JSON-RPC request without awaiting it — for `turn/start`,
|
|
853
909
|
* whose output streams as notifications and whose response can take
|
|
854
|
-
* minutes.
|
|
910
|
+
* minutes. Acceptance/errors are handled separately, without a waiter. */
|
|
855
911
|
private fireRequest(method: string, params: unknown): number {
|
|
856
912
|
const id = this.nextRpcId++;
|
|
913
|
+
this.firedRequests.set(id, method);
|
|
857
914
|
this.proc.writeLine({
|
|
858
915
|
jsonrpc: "2.0",
|
|
859
916
|
id,
|
|
@@ -880,7 +937,7 @@ export class CodexSession implements AgentSession {
|
|
|
880
937
|
}
|
|
881
938
|
|
|
882
939
|
private async handshake(): Promise<void> {
|
|
883
|
-
await this.request("initialize", {
|
|
940
|
+
const initialized = await this.request("initialize", {
|
|
884
941
|
clientInfo: { name: "uai", version: "0.2" },
|
|
885
942
|
// ADR-078: unlock experimental fields. thread/resume's `excludeTurns`
|
|
886
943
|
// (resume without replaying history — see tryResumeThread) is gated on
|
|
@@ -889,6 +946,12 @@ export class CodexSession implements AgentSession {
|
|
|
889
946
|
// we fall back to a fresh thread, losing the whole conversation.
|
|
890
947
|
capabilities: { experimentalApi: true },
|
|
891
948
|
});
|
|
949
|
+
// The server's user agent starts with <client-name>/<CLI-version>; it
|
|
950
|
+
// does not use the clientInfo.version we sent (verified on codex 0.164).
|
|
951
|
+
if (isObj(initialized)) {
|
|
952
|
+
this.cliVersion = str(initialized.userAgent)
|
|
953
|
+
.match(/^[^/\s]+\/(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)(?:\s|$)/)?.[1] ?? null;
|
|
954
|
+
}
|
|
892
955
|
this.notify("initialized", {});
|
|
893
956
|
// ADR-078: a thread id from a prior host process means this is a restart —
|
|
894
957
|
// resume the SAME durable thread so the conversation survives, instead of
|
|
@@ -917,9 +980,13 @@ export class CodexSession implements AgentSession {
|
|
|
917
980
|
: null;
|
|
918
981
|
if (usage) this.emit({ type: "account_usage", usage });
|
|
919
982
|
})
|
|
920
|
-
.catch(() => {
|
|
983
|
+
.catch((error: unknown) => {
|
|
921
984
|
// Usage is advisory. Authentication/turn errors still follow their
|
|
922
985
|
// normal app-server notifications and must not be duplicated here.
|
|
986
|
+
if (!this.closed && !this.failed) {
|
|
987
|
+
this.reportError(`${this.cliLabel} usage read failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
988
|
+
(error as { rpcCode?: number }).rpcCode, false);
|
|
989
|
+
}
|
|
923
990
|
});
|
|
924
991
|
}
|
|
925
992
|
|
|
@@ -1041,28 +1108,144 @@ export class CodexSession implements AgentSession {
|
|
|
1041
1108
|
for (const h of this.handlers) h(event);
|
|
1042
1109
|
}
|
|
1043
1110
|
|
|
1111
|
+
private get cliLabel(): string {
|
|
1112
|
+
return this.cliVersion ? `codex-cli ${this.cliVersion}` : "Codex";
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private notificationTurnId(params: unknown): string | null {
|
|
1116
|
+
if (!isObj(params)) return null;
|
|
1117
|
+
return str(params.turnId) || (isObj(params.turn) ? str(params.turn.id) : "") || null;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
private acknowledgeTurn(id: number, params?: unknown): void {
|
|
1121
|
+
const turn = this.turnStarts.get(id);
|
|
1122
|
+
if (!turn) return;
|
|
1123
|
+
if (turn.timer) clearTimeout(turn.timer);
|
|
1124
|
+
turn.timer = null;
|
|
1125
|
+
turn.accepted = true;
|
|
1126
|
+
turn.turnId = this.notificationTurnId(params) ?? turn.turnId;
|
|
1127
|
+
const owner = turn.turnId === null ? undefined : [...this.turnStarts].find(
|
|
1128
|
+
([otherId, other]) => otherId !== id && other.accepted && other.turnId === turn.turnId,
|
|
1129
|
+
);
|
|
1130
|
+
if (owner) {
|
|
1131
|
+
// Codex 0.164 steers a mid-turn start into the existing turn. One
|
|
1132
|
+
// provider completion must settle every logical delivery it absorbed.
|
|
1133
|
+
owner[1].settles += turn.settles;
|
|
1134
|
+
this.turnStarts.delete(id);
|
|
1135
|
+
this.outstandingTurns = Math.max(0, this.outstandingTurns - 1);
|
|
1136
|
+
for (const [alias, target] of this.mergedStarts) {
|
|
1137
|
+
if (target === id) this.mergedStarts.set(alias, owner[0]);
|
|
1138
|
+
}
|
|
1139
|
+
this.mergedStarts.set(id, owner[0]);
|
|
1140
|
+
}
|
|
1141
|
+
this.reconcileAcceptanceTimers();
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
private acknowledgeActivity(params: unknown, started = false): void {
|
|
1145
|
+
const turnId = this.notificationTurnId(params);
|
|
1146
|
+
if (turnId && [...this.turnStarts.values()].some(turn => turn.turnId === turnId)) {
|
|
1147
|
+
// Ordinary chunks still belong to the running turn. Only an explicit
|
|
1148
|
+
// new start notification can identify another start as merged here.
|
|
1149
|
+
const pending = started && [...this.turnStarts].find(([, turn]) => !turn.accepted);
|
|
1150
|
+
if (pending) this.acknowledgeTurn(pending[0], params);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
// Associate the first activity with an already acknowledged start before
|
|
1154
|
+
// attributing it to a newer queued request. Old output must not cancel a
|
|
1155
|
+
// different request's watchdog.
|
|
1156
|
+
const unknown = [...this.turnStarts].find(([, turn]) => turn.turnId === null && turn.accepted);
|
|
1157
|
+
if (unknown) {
|
|
1158
|
+
if (turnId) unknown[1].turnId = turnId;
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
const pending = [...this.turnStarts].find(([, turn]) => !turn.accepted);
|
|
1162
|
+
if (pending) this.acknowledgeTurn(pending[0], params);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
private reconcileAcceptanceTimers(): void {
|
|
1166
|
+
// A late turn/start response can be queued behind accepted work on this
|
|
1167
|
+
// thread. Bound waiting only when the thread is free, and give queued
|
|
1168
|
+
// starts a fresh deadline when the last accepted message completes.
|
|
1169
|
+
const occupied = [...this.turnStarts.values()].some(turn => turn.accepted);
|
|
1170
|
+
for (const turn of this.turnStarts.values()) {
|
|
1171
|
+
if (occupied) {
|
|
1172
|
+
if (turn.timer) clearTimeout(turn.timer);
|
|
1173
|
+
turn.timer = null;
|
|
1174
|
+
} else if (!turn.timer) {
|
|
1175
|
+
turn.timer = setTimeout(() => {
|
|
1176
|
+
this.failTurn(`${this.cliLabel} didn't start this message within 60 seconds. Please try again.`);
|
|
1177
|
+
}, CODEX_TURN_START_TIMEOUT_MS);
|
|
1178
|
+
turn.timer.unref?.();
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
private clearTurnState(): void {
|
|
1184
|
+
this.turnRunning = false;
|
|
1185
|
+
this.outstandingTurns = 0;
|
|
1186
|
+
this.clearRateLimitsTimer();
|
|
1187
|
+
for (const turn of this.turnStarts.values()) if (turn.timer) clearTimeout(turn.timer);
|
|
1188
|
+
this.turnStarts.clear();
|
|
1189
|
+
this.mergedStarts.clear();
|
|
1190
|
+
this.firedRequests.clear();
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
private failRpc(error: Record<string, unknown>, usage?: AgentUsage): void {
|
|
1194
|
+
const code = typeof error.code === "number" ? error.code : undefined;
|
|
1195
|
+
this.failTurn(`${this.cliLabel} rejected the message${code === undefined ? "" : ` (code ${code})`}: ${str(error.message, "Unknown error. Please try again.")}`, code, usage);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
private reportError(message: string, rpcCode?: number, capture = true): void {
|
|
1199
|
+
reportAgentError(message, { taskId: this.taskId, agentId: this.agentId, engine: this.kind, rpcCode }, { capture });
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** One terminal failure per generation. Late responses, completion and
|
|
1203
|
+
* process exit cannot end this turn twice or leak into a replacement. */
|
|
1204
|
+
private failTurn(message: string, code?: number, usage?: AgentUsage): void {
|
|
1205
|
+
if (this.failed || this.closed) return;
|
|
1206
|
+
this.failed = true;
|
|
1207
|
+
this.reportError(message, code);
|
|
1208
|
+
const aborted = this.outstandingTurns > 0;
|
|
1209
|
+
const settles = this.outstandingTurns + [...this.turnStarts.values()]
|
|
1210
|
+
.reduce((merged, turn) => merged + turn.settles - 1, 0);
|
|
1211
|
+
this.clearTurnState();
|
|
1212
|
+
const display = agentErrorMessage(message);
|
|
1213
|
+
for (const event of mapCodexNotification("error", { error: { message } })) {
|
|
1214
|
+
this.emit(event.type === "error" ? { ...event, message: display,
|
|
1215
|
+
...(display === message ? {} : { diagnosticMessage: message }), code, aborted } : event);
|
|
1216
|
+
}
|
|
1217
|
+
if (aborted) this.emit({ type: "turn_complete", aborted: true,
|
|
1218
|
+
...(settles > 1 ? { settles } : {}), ...(usage ? { usage } : {}) });
|
|
1219
|
+
for (const waiter of this.pending.values()) waiter.reject(new Error(message));
|
|
1220
|
+
this.pending.clear();
|
|
1221
|
+
void this.proc.close().catch(() => {});
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1044
1224
|
async send(text: string): Promise<void> {
|
|
1045
|
-
if (this.closed)
|
|
1225
|
+
if (this.closed || this.failed) throw new Error(`${this.cliLabel} is no longer available. Please try again.`);
|
|
1226
|
+
this.outstandingTurns += 1;
|
|
1046
1227
|
await this.ready;
|
|
1047
|
-
if (this.closed) return;
|
|
1228
|
+
if (this.closed || this.failed) return;
|
|
1048
1229
|
if (!this.handshakeOk || !this.threadId) {
|
|
1049
|
-
this.
|
|
1050
|
-
type: "error",
|
|
1051
|
-
message:
|
|
1052
|
-
"codex is unavailable — the app-server handshake did not complete.",
|
|
1053
|
-
});
|
|
1230
|
+
this.failTurn(`${this.cliLabel} could not start. Please try again.`);
|
|
1054
1231
|
return;
|
|
1055
1232
|
}
|
|
1056
1233
|
|
|
1057
1234
|
// `ready` may have waited across a runtime generation change. Recheck at
|
|
1058
1235
|
// the turn/start write rather than trusting session construction time.
|
|
1059
|
-
|
|
1236
|
+
try {
|
|
1237
|
+
requireContainerRuntimeOperational();
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
this.outstandingTurns -= 1;
|
|
1240
|
+
throw error;
|
|
1241
|
+
}
|
|
1060
1242
|
const turnStartId = this.fireRequest("turn/start", {
|
|
1061
1243
|
threadId: this.threadId,
|
|
1062
1244
|
input: [{ type: "text", text }],
|
|
1063
1245
|
});
|
|
1064
|
-
this.turnStartRpcIds.add(turnStartId);
|
|
1065
1246
|
this.turnRunning = true;
|
|
1247
|
+
this.turnStarts.set(turnStartId, { timer: null, turnId: null, accepted: false, settles: 1 });
|
|
1248
|
+
this.reconcileAcceptanceTimers();
|
|
1066
1249
|
this.scheduleRateLimitsRead();
|
|
1067
1250
|
}
|
|
1068
1251
|
|
|
@@ -1093,9 +1276,7 @@ export class CodexSession implements AgentSession {
|
|
|
1093
1276
|
return;
|
|
1094
1277
|
}
|
|
1095
1278
|
this.closed = true;
|
|
1096
|
-
this.
|
|
1097
|
-
this.clearRateLimitsTimer();
|
|
1098
|
-
this.turnStartRpcIds.clear();
|
|
1279
|
+
this.clearTurnState();
|
|
1099
1280
|
for (const waiter of this.pending.values()) {
|
|
1100
1281
|
waiter.reject(new Error("session closed"));
|
|
1101
1282
|
}
|
|
@@ -451,14 +451,20 @@ export class MachineDurableProcess implements LineTransport {
|
|
|
451
451
|
if (this.#debug) this.#log(`-> ${json.slice(0, 1000)}`);
|
|
452
452
|
this.#inboxChain = this.#inboxChain
|
|
453
453
|
.then(() =>
|
|
454
|
-
appendEnvironmentFile(
|
|
454
|
+
this.#closed || this.#detached ? undefined : appendEnvironmentFile(
|
|
455
455
|
this.#environment,
|
|
456
456
|
this.#inboxPath,
|
|
457
457
|
`${inputMeta}\n${json}\n`,
|
|
458
458
|
),
|
|
459
459
|
)
|
|
460
460
|
.catch(() => {
|
|
461
|
-
|
|
461
|
+
if (this.#closed || this.#detached) return;
|
|
462
|
+
// A fresh heartbeat proves the runner lives, not that it received the
|
|
463
|
+
// message. Fail immediately through the adapter's normal error path.
|
|
464
|
+
// Never include the frame or exec stderr: either may contain secrets.
|
|
465
|
+
this.#stderrBuf = "Could not deliver the message to the agent because its input could not be written. Please try again.";
|
|
466
|
+
this.#finish(null, "inbox append failed");
|
|
467
|
+
void this.#killRemoteRunner();
|
|
462
468
|
});
|
|
463
469
|
}
|
|
464
470
|
|
package/lib/agents/types.ts
CHANGED
|
@@ -152,18 +152,32 @@ export type AgentEvent =
|
|
|
152
152
|
| { type: "permission_request"; id: string; title: string; detail: string }
|
|
153
153
|
/** The agent addressed another agent — uai routes this as a peer message. */
|
|
154
154
|
| { type: "peer_message"; toAgentId: string; text: string }
|
|
155
|
-
/** The turn
|
|
155
|
+
/** The provider turn is done; it may have absorbed multiple deliveries.
|
|
156
156
|
* `usage` carries this turn's token/cost accounting when the CLI reports it. */
|
|
157
|
-
| {
|
|
157
|
+
| {
|
|
158
|
+
type: "turn_complete";
|
|
159
|
+
usage?: AgentUsage;
|
|
160
|
+
aborted?: boolean;
|
|
161
|
+
/** Logical deliveries combined into this one provider turn (default 1). */
|
|
162
|
+
settles?: number;
|
|
163
|
+
}
|
|
158
164
|
/** Account-level capacity reported by the engine CLI. This never contains
|
|
159
165
|
* credentials or provider account identifiers. */
|
|
160
166
|
| { type: "account_usage"; usage: AgentAccountUsage }
|
|
167
|
+
/** A nonterminal operation failed; leave the current message running. */
|
|
168
|
+
| { type: "notice"; message: string }
|
|
161
169
|
/** A recoverable error surfaced by the agent. `retryable` marks errors the
|
|
162
170
|
* orchestrator can fail over from — "rate_limit" triggers account rotation
|
|
163
171
|
* (ADR-076). */
|
|
164
172
|
| {
|
|
165
173
|
type: "error";
|
|
166
174
|
message: string;
|
|
175
|
+
/** Host-only full diagnostic for classification. Never put on the wire. */
|
|
176
|
+
diagnosticMessage?: string;
|
|
177
|
+
/** Provider RPC error code, when supplied. */
|
|
178
|
+
code?: number;
|
|
179
|
+
/** The adapter has ended the accepted turn, including before output. */
|
|
180
|
+
aborted?: boolean;
|
|
167
181
|
retryable?: "rate_limit";
|
|
168
182
|
/** Parsed from the same rate-limit error text, when unambiguous. */
|
|
169
183
|
resetsAt?: number;
|
package/lib/orchestrator.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { inArray } from "drizzle-orm";
|
|
|
40
40
|
|
|
41
41
|
import { getDb, schema } from "./db";
|
|
42
42
|
import { newId } from "./ulid";
|
|
43
|
+
import { agentErrorMessage, reportAgentError } from "./agent-errors";
|
|
43
44
|
import { mockAgentFactory } from "./agents/mock";
|
|
44
45
|
import { realAgentFactory } from "./agents/factory";
|
|
45
46
|
import { resolveAgentMode } from "./agents/mode";
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
} from "./agents/session-state";
|
|
51
52
|
import {
|
|
52
53
|
type AgentEvent,
|
|
54
|
+
type AgentEventHandler,
|
|
53
55
|
type AgentSession,
|
|
54
56
|
type AgentSessionFactory,
|
|
55
57
|
type Roster,
|
|
@@ -709,7 +711,11 @@ const BROWSER_RETRY_COOLDOWN_MS = 60_000;
|
|
|
709
711
|
* poisons the runtime generation synchronously; normal detection/recovery owns
|
|
710
712
|
* the eventual ready edge and deferred-prompt replay.
|
|
711
713
|
*/
|
|
712
|
-
async function agentTerminationLostContainerRuntime(): Promise<boolean> {
|
|
714
|
+
async function agentTerminationLostContainerRuntime(channel: Channel): Promise<boolean> {
|
|
715
|
+
// Machine/self tasks have no Docker/Apple daemon. A failed Docker probe
|
|
716
|
+
// there would turn a real engine failure into silent runtime recovery.
|
|
717
|
+
if (isEnvironmentTransportProvider(channel.agentHandle?.descriptor.locator.provider) ||
|
|
718
|
+
isEnvironmentTransportProvider(getHostTask(channel.taskId)?.environmentProvider)) return false;
|
|
713
719
|
if (!backgroundContainerWorkAllowed()) return true;
|
|
714
720
|
let reachable = false;
|
|
715
721
|
try {
|
|
@@ -809,6 +815,7 @@ export interface OrchestratorTaskStopResult {
|
|
|
809
815
|
}
|
|
810
816
|
|
|
811
817
|
export class Orchestrator {
|
|
818
|
+
private readonly sessionEventHandlers = new WeakMap<AgentSession, AgentEventHandler>();
|
|
812
819
|
private readonly channels = new Map<string, Channel>();
|
|
813
820
|
private readonly channelSpecs = new Map<string, ChannelEnsureInput>();
|
|
814
821
|
/** Teardown tombstones prevent a cloud ensure from reopening a task between
|
|
@@ -2915,7 +2922,7 @@ export class Orchestrator {
|
|
|
2915
2922
|
// transport emission order across that await; otherwise the later boundary
|
|
2916
2923
|
// can consume/reorder prompt state while the earlier error still owns it.
|
|
2917
2924
|
let eventTail: Promise<void> = Promise.resolve();
|
|
2918
|
-
|
|
2925
|
+
const onEvent: AgentEventHandler = (event) => {
|
|
2919
2926
|
this.markChannelActivity(channel.taskId);
|
|
2920
2927
|
// Recycle/teardown deletes the old session before calling close().
|
|
2921
2928
|
// Adapters deliberately emit a final exit event; without this identity
|
|
@@ -2957,7 +2964,9 @@ export class Orchestrator {
|
|
|
2957
2964
|
() => channel.eventOperations.delete(operation),
|
|
2958
2965
|
() => channel.eventOperations.delete(operation),
|
|
2959
2966
|
);
|
|
2960
|
-
}
|
|
2967
|
+
};
|
|
2968
|
+
this.sessionEventHandlers.set(session, onEvent);
|
|
2969
|
+
session.onEvent(onEvent);
|
|
2961
2970
|
}
|
|
2962
2971
|
|
|
2963
2972
|
/**
|
|
@@ -4616,11 +4625,11 @@ export class Orchestrator {
|
|
|
4616
4625
|
}
|
|
4617
4626
|
}
|
|
4618
4627
|
|
|
4619
|
-
/** Consume
|
|
4620
|
-
private decrementActiveTurns(channel: Channel, agentId: string): number {
|
|
4628
|
+
/** Consume deliveries settled by one provider boundary. */
|
|
4629
|
+
private decrementActiveTurns(channel: Channel, agentId: string, settles = 1): number {
|
|
4621
4630
|
const remaining = Math.max(
|
|
4622
4631
|
0,
|
|
4623
|
-
(channel.activeTurns.get(agentId) ?? 1) -
|
|
4632
|
+
(channel.activeTurns.get(agentId) ?? 1) - settles,
|
|
4624
4633
|
);
|
|
4625
4634
|
if (remaining === 0) channel.activeTurns.delete(agentId);
|
|
4626
4635
|
else channel.activeTurns.set(agentId, remaining);
|
|
@@ -4759,26 +4768,29 @@ export class Orchestrator {
|
|
|
4759
4768
|
this.incrementActiveTurns(channel, agentId);
|
|
4760
4769
|
void session.send(text).catch((err: unknown) => {
|
|
4761
4770
|
if (turn) journal?.failed(sessionId, turn.turnId);
|
|
4771
|
+
const diagnosticMessage = `Could not send the message to ${agentId}: ${err instanceof Error ? err.message : String(err)}`;
|
|
4772
|
+
reportAgentError(diagnosticMessage, { taskId: channel.taskId, agentId, engine: session.kind },
|
|
4773
|
+
{ capture: !(err instanceof ContainerRuntimeUnavailableError) });
|
|
4762
4774
|
if (channel.sessions.get(agentId) === session) {
|
|
4763
|
-
this.removeAcceptedPrompt(channel, agentId, accepted);
|
|
4764
|
-
const remaining = this.decrementActiveTurns(channel, agentId);
|
|
4765
|
-
if (
|
|
4766
|
-
remaining === 0 &&
|
|
4767
|
-
channel.lastPrompt.get(agentId) === accepted
|
|
4768
|
-
) {
|
|
4769
|
-
channel.lastPrompt.delete(agentId);
|
|
4770
|
-
}
|
|
4771
4775
|
if (err instanceof ContainerRuntimeUnavailableError) {
|
|
4776
|
+
this.removeAcceptedPrompt(channel, agentId, accepted);
|
|
4777
|
+
const remaining = this.decrementActiveTurns(channel, agentId);
|
|
4778
|
+
if (remaining === 0 && channel.lastPrompt.get(agentId) === accepted) {
|
|
4779
|
+
channel.lastPrompt.delete(agentId);
|
|
4780
|
+
}
|
|
4772
4781
|
this.deferPromptUntilRuntimeReady(channel, agentId, accepted);
|
|
4773
4782
|
} else {
|
|
4774
|
-
|
|
4783
|
+
// Share the ordered terminal-event path with adapter failures. It
|
|
4784
|
+
// retires the active count and publishes the error/aborted boundary;
|
|
4785
|
+
// a concurrent adapter error or exit can consume it only once.
|
|
4786
|
+
this.sessionEventHandlers.get(session)?.({
|
|
4787
|
+
type: "error",
|
|
4788
|
+
message: agentErrorMessage(diagnosticMessage),
|
|
4789
|
+
diagnosticMessage,
|
|
4790
|
+
aborted: true,
|
|
4791
|
+
});
|
|
4775
4792
|
}
|
|
4776
4793
|
}
|
|
4777
|
-
console.warn(
|
|
4778
|
-
`[orchestrator] ${channel.taskId}/${agentId}: send failed: ${
|
|
4779
|
-
err instanceof Error ? err.message : String(err)
|
|
4780
|
-
}`,
|
|
4781
|
-
);
|
|
4782
4794
|
});
|
|
4783
4795
|
}
|
|
4784
4796
|
|
|
@@ -4794,13 +4806,14 @@ export class Orchestrator {
|
|
|
4794
4806
|
if (prompts.length === 0) channel.acceptedPrompts.delete(agentId);
|
|
4795
4807
|
}
|
|
4796
4808
|
|
|
4797
|
-
private
|
|
4809
|
+
private completeAcceptedPrompts(
|
|
4798
4810
|
channel: Channel,
|
|
4799
4811
|
agentId: string,
|
|
4812
|
+
settles = 1,
|
|
4800
4813
|
): void {
|
|
4801
4814
|
const prompts = channel.acceptedPrompts.get(agentId);
|
|
4802
4815
|
if (!prompts) return;
|
|
4803
|
-
prompts.
|
|
4816
|
+
prompts.splice(0, settles);
|
|
4804
4817
|
if (prompts.length === 0) channel.acceptedPrompts.delete(agentId);
|
|
4805
4818
|
}
|
|
4806
4819
|
|
|
@@ -5257,8 +5270,13 @@ export class Orchestrator {
|
|
|
5257
5270
|
});
|
|
5258
5271
|
break;
|
|
5259
5272
|
}
|
|
5273
|
+
case "notice": {
|
|
5274
|
+
this.emitSystemNote(channel.taskId, agentErrorMessage(`${agentId}: ${event.message}`));
|
|
5275
|
+
break;
|
|
5276
|
+
}
|
|
5260
5277
|
case "error": {
|
|
5261
|
-
|
|
5278
|
+
const diagnosticMessage = event.diagnosticMessage ?? event.message;
|
|
5279
|
+
if (await agentTerminationLostContainerRuntime(channel)) {
|
|
5262
5280
|
// `error` and `exit` can arrive back-to-back while the daemon probe
|
|
5263
5281
|
// is pending. Only the first terminal event still owns this session.
|
|
5264
5282
|
if (channel.sessions.get(agentId) !== session) break;
|
|
@@ -5272,7 +5290,7 @@ export class Orchestrator {
|
|
|
5272
5290
|
if (channel.sessions.get(agentId) !== session) break;
|
|
5273
5291
|
// The turn died with the session — drop its turn-state flags so a
|
|
5274
5292
|
// respawned session starts clean.
|
|
5275
|
-
|
|
5293
|
+
let abortBoundaryOnRosterChange =
|
|
5276
5294
|
this.needsAbortedTurnBoundary(channel, agentId);
|
|
5277
5295
|
this.retainRetiredTurnBoundary(
|
|
5278
5296
|
channel,
|
|
@@ -5280,6 +5298,14 @@ export class Orchestrator {
|
|
|
5280
5298
|
abortBoundaryOnRosterChange,
|
|
5281
5299
|
);
|
|
5282
5300
|
this.retireRunnerGeneration(channel, agentId);
|
|
5301
|
+
if (event.aborted) {
|
|
5302
|
+
this.emitRetiredTurnAbort(channel, agentId);
|
|
5303
|
+
const change = channel.rosterSessionChanges.get(agentId);
|
|
5304
|
+
if (change) change.abortPublished = true;
|
|
5305
|
+
// A recovery may replay the prompt, but the failed turn already
|
|
5306
|
+
// ended. Do not publish a second boundary for this generation.
|
|
5307
|
+
abortBoundaryOnRosterChange = false;
|
|
5308
|
+
}
|
|
5283
5309
|
// Claude under load (especially Docker Desktop macOS) occasionally
|
|
5284
5310
|
// unlinks ~/.claude.json mid-write during atomic config rewrites,
|
|
5285
5311
|
// and a concurrent claude spawn lands during the gap and exits 0
|
|
@@ -5325,7 +5351,7 @@ export class Orchestrator {
|
|
|
5325
5351
|
if (
|
|
5326
5352
|
agent &&
|
|
5327
5353
|
agent.kind !== "claude" &&
|
|
5328
|
-
isCredentialInvalidSignal(agent.kind,
|
|
5354
|
+
isCredentialInvalidSignal(agent.kind, diagnosticMessage)
|
|
5329
5355
|
) {
|
|
5330
5356
|
console.warn(
|
|
5331
5357
|
`[orchestrator] ${channel.taskId}/${agentId}: ${agent.kind} credential-invalid pattern matched: ${event.message.slice(0, 300)}`,
|
|
@@ -5407,7 +5433,7 @@ export class Orchestrator {
|
|
|
5407
5433
|
}
|
|
5408
5434
|
}
|
|
5409
5435
|
const isClaude = agent?.kind === "claude";
|
|
5410
|
-
if (isClaude && agent && isClaudeAuthRevoked(
|
|
5436
|
+
if (isClaude && agent && isClaudeAuthRevoked(diagnosticMessage)) {
|
|
5411
5437
|
// Token revoked/expired: the env-injected credential only changes on
|
|
5412
5438
|
// a fresh runner. Re-spawn on the re-resolved account (picks up a
|
|
5413
5439
|
// reconnected token); bounded so a still-bad token can't loop.
|
|
@@ -5424,7 +5450,7 @@ export class Orchestrator {
|
|
|
5424
5450
|
break;
|
|
5425
5451
|
}
|
|
5426
5452
|
const configMissing = CLAUDE_CONFIG_MISSING_PATTERNS.some((re) =>
|
|
5427
|
-
re.test(
|
|
5453
|
+
re.test(diagnosticMessage),
|
|
5428
5454
|
);
|
|
5429
5455
|
if (isClaude && configMissing) {
|
|
5430
5456
|
// This recovery path deliberately starts the next queued turn rather
|
|
@@ -5439,7 +5465,7 @@ export class Orchestrator {
|
|
|
5439
5465
|
break;
|
|
5440
5466
|
}
|
|
5441
5467
|
channel.lastPrompt.delete(agentId);
|
|
5442
|
-
|
|
5468
|
+
this.emitRetiredTurnAbort(channel, agentId);
|
|
5443
5469
|
this.emitHost({
|
|
5444
5470
|
kind: "agent.exit",
|
|
5445
5471
|
taskId: channel.taskId,
|
|
@@ -5469,9 +5495,11 @@ export class Orchestrator {
|
|
|
5469
5495
|
// turn's messages and wakes the mentioned peers with the full turn.
|
|
5470
5496
|
// An interrupted (ESC'd) turn goes out `aborted`: it's a half-turn, so
|
|
5471
5497
|
// the cloud discards the buffer instead of handing it to peers.
|
|
5472
|
-
const aborted = channel.interrupted.delete(agentId);
|
|
5473
|
-
|
|
5474
|
-
|
|
5498
|
+
const aborted = channel.interrupted.delete(agentId) || event.aborted === true;
|
|
5499
|
+
const settles = event.settles !== undefined && Number.isSafeInteger(event.settles) && event.settles > 0
|
|
5500
|
+
? event.settles : 1;
|
|
5501
|
+
this.completeAcceptedPrompts(channel, agentId, settles);
|
|
5502
|
+
const remainingTurns = this.decrementActiveTurns(channel, agentId, settles);
|
|
5475
5503
|
channel.openTurns.delete(agentId);
|
|
5476
5504
|
// ADR-076: a turn that completed means the current account is healthy —
|
|
5477
5505
|
// reset its rotation budget so a LATER rate-limit gets a fresh failover.
|
|
@@ -5522,7 +5550,15 @@ export class Orchestrator {
|
|
|
5522
5550
|
break;
|
|
5523
5551
|
}
|
|
5524
5552
|
case "exit":
|
|
5525
|
-
if (
|
|
5553
|
+
if (event.code === 0 && this.needsAbortedTurnBoundary(channel, agentId)) {
|
|
5554
|
+
await this.handleAgentEvent(channel, agentId, session, {
|
|
5555
|
+
type: "error",
|
|
5556
|
+
message: `${session.kind} stopped before replying to the message. Please try again.`,
|
|
5557
|
+
aborted: true,
|
|
5558
|
+
});
|
|
5559
|
+
break;
|
|
5560
|
+
}
|
|
5561
|
+
if (await agentTerminationLostContainerRuntime(channel)) {
|
|
5526
5562
|
if (channel.sessions.get(agentId) !== session) break;
|
|
5527
5563
|
await this.retireSessionForRuntimeLoss(
|
|
5528
5564
|
channel,
|
|
@@ -7319,7 +7355,10 @@ export function buildSystemPreamble(
|
|
|
7319
7355
|
`${taskBranch}\` from inside the project, then open a PR with \`gh pr`,
|
|
7320
7356
|
"create` (the container has gh authenticated). For multi-project",
|
|
7321
7357
|
"tasks, each project's PR is independent — open one per project whose",
|
|
7322
|
-
"worktree you actually changed.",
|
|
7358
|
+
"worktree you actually changed. For a separate branch, create it in",
|
|
7359
|
+
"this task's namespace — `git switch -c \"$(uai branch name fix-foo)\"`",
|
|
7360
|
+
"makes `uai/<taskId>/fix-foo`. Only task-branch and namespace PRs link to",
|
|
7361
|
+
"this task (reviews, `--pr`, Usage); a PR from any other branch does not.",
|
|
7323
7362
|
"",
|
|
7324
7363
|
"The `.uai/` directory under each task is uai's own scaffolding",
|
|
7325
7364
|
"(rendered Dockerfile, compose file, container scripts) — it is NOT",
|