@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 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
+ }
@@ -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
- * the response itself is ignored
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
- /** Fire-and-forget turn/start ids whose error responses end turn polling. */
584
- private readonly turnStartRpcIds = new Set<number>();
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.turnRunning = false;
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.emit({
677
- type: "error",
678
- message:
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
- : " — is the codex CLI installed in the task container, and is the container running?"),
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.emit({
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
- if (this.turnStartRpcIds.delete(id)) {
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.turnRunning = false;
726
- this.clearRateLimitsTimer();
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
- this.turnRunning = false;
772
- this.clearRateLimitsTimer();
773
- this.turnStartRpcIds.clear();
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. The eventual response is ignored (no pending waiter). */
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) return;
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.emit({
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
- requireContainerRuntimeOperational();
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.turnRunning = false;
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
- // Transport error — liveness probes will surface a dead session.
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
 
@@ -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 (one request → response cycle) is done; agent is idle.
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
- | { type: "turn_complete"; usage?: AgentUsage }
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;
@@ -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
- session.onEvent((event) => {
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 one queued/active turn and report how many remain. */
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) - 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
- this.startNextPendingPrompt(channel, agentId);
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 completeNextAcceptedPrompt(
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.shift();
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
- if (await agentTerminationLostContainerRuntime()) {
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
- const abortBoundaryOnRosterChange =
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, event.message)
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(event.message)) {
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(event.message),
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
- channel.retiredTurnBoundaries.delete(agentId);
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
- this.completeNextAcceptedPrompt(channel, agentId);
5474
- const remainingTurns = this.decrementActiveTurns(channel, agentId);
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 (await agentTerminationLostContainerRuntime()) {
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.169",
3
+ "version": "0.9.170",
4
4
  "description": "Uai host \u2014 runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",