@runuai/host 0.9.169 → 0.9.171
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/credentials-activation-authority.ts +158 -10
- package/lib/legacy-runtime-retirement.ts +43 -8
- package/lib/orchestrator.ts +72 -33
- package/package.json +1 -1
- package/src/main.ts +12 -0
- package/src/protocol.ts +93 -0
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;
|