@indigoai-us/hq-cli 5.345.76 → 5.345.78
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/CHANGELOG.md +38 -0
- package/dist/command-catalog.generated.d.ts +83 -3
- package/dist/command-catalog.generated.js +100 -3
- package/dist/commands/access.d.ts +1 -0
- package/dist/commands/access.js +17 -5
- package/dist/commands/bot.d.ts +12 -0
- package/dist/commands/bot.js +54 -2
- package/dist/commands/files.js +7 -2
- package/dist/commands/members.d.ts +42 -0
- package/dist/commands/members.js +151 -1
- package/dist/commands/secrets.js +1 -1
- package/dist/lib/bot/api.d.ts +11 -0
- package/dist/lib/bot/api.js +2 -0
- package/dist/lib/bot/inflight.d.ts +3 -0
- package/dist/lib/bot/probe.d.ts +57 -0
- package/dist/lib/bot/probe.js +100 -0
- package/dist/lib/bot/run.d.ts +4 -0
- package/dist/lib/bot/run.js +171 -37
- package/dist/lib/bot/runtime/codex.d.ts +45 -1
- package/dist/lib/bot/runtime/codex.js +134 -2
- package/dist/lib/bot/runtime/grok.js +2 -2
- package/dist/lib/bot/runtime/index.d.ts +8 -0
- package/dist/lib/bot/runtime/index.js +91 -5
- package/dist/lib/bot/runtime/messages-stream.js +1 -1
- package/dist/lib/bot/runtime/types.d.ts +33 -0
- package/dist/lib/bot/runtime/types.js +10 -0
- package/dist/lib/bot/runtime-failure.d.ts +113 -0
- package/dist/lib/bot/runtime-failure.js +180 -0
- package/dist/lib/bot/runtime-sign-in.js +2 -2
- package/dist/lib/bot/status.d.ts +21 -0
- package/dist/lib/lanes/spawn.js +1 -0
- package/dist/lib/lanes/store.js +41 -25
- package/package.json +1 -1
package/dist/lib/bot/run.js
CHANGED
|
@@ -73,7 +73,8 @@ import { isAgentSender, isDmItem, isMetaNonReply, resolveReplyTarget } from "./r
|
|
|
73
73
|
import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
|
|
74
74
|
import { botJobQuietOptionEnabled } from "./job-quiet-flag.js";
|
|
75
75
|
import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
|
|
76
|
-
import {
|
|
76
|
+
import { SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
|
|
77
|
+
import { classifyRuntimeError, classifyRuntimeFailure, runtimeDidNotFinishText, runtimeFailureReason, runtimeFailureText, runtimeRepairNotice, } from "./runtime-failure.js";
|
|
77
78
|
import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
|
|
78
79
|
import { captureHeartbeatIdentityMismatch, identityMismatchMessage, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
|
|
79
80
|
import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
|
|
@@ -181,13 +182,19 @@ export const SETUP_WORKER_ID = SETUP_BOT_WORKER_ID;
|
|
|
181
182
|
/** The one message a person gets when a turn on their message did not finish. */
|
|
182
183
|
export function didNotFinishText(reason, progressPosted) {
|
|
183
184
|
const r = reason.trim().replace(/[.\s]+$/, "");
|
|
184
|
-
|
|
185
|
+
const sentence = r ? `${r[0].toUpperCase()}${r.slice(1)}` : r;
|
|
186
|
+
return `I didn't finish that. ${sentence}.${progressPosted ? " The messages above show how far I got." : ""}`;
|
|
185
187
|
}
|
|
186
188
|
export { containsNoReplySentinel };
|
|
187
189
|
/** Grok emits is_error with this message when the model produces only reasoning tokens. */
|
|
188
190
|
export function isReasoningOnlyError(excerpt) {
|
|
189
191
|
return excerpt.includes("reasoning_only");
|
|
190
192
|
}
|
|
193
|
+
/** The one note a person gets when the bot pauses after repeated failures. */
|
|
194
|
+
export function holdNoticeText(holdMs, messagePending) {
|
|
195
|
+
const minutes = Math.max(1, Math.round(holdMs / 60_000));
|
|
196
|
+
return `I'm pausing for ${minutes} minute${minutes === 1 ? "" : "s"} after a few failed tries, then I'll ${messagePending ? "pick your message up" : "pick up from your next message"}.`;
|
|
197
|
+
}
|
|
191
198
|
/** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
|
|
192
199
|
export const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
|
|
193
200
|
export const RESTARTED_REASON = "I was restarted while I was working on it";
|
|
@@ -228,6 +235,9 @@ class FailureWindow {
|
|
|
228
235
|
exceeded(nowMs) {
|
|
229
236
|
return this.count(nowMs) >= this.limit;
|
|
230
237
|
}
|
|
238
|
+
clear() {
|
|
239
|
+
this.stamps.length = 0;
|
|
240
|
+
}
|
|
231
241
|
}
|
|
232
242
|
function defaultSleep(ms, signal) {
|
|
233
243
|
return new Promise((resolve) => {
|
|
@@ -493,7 +503,9 @@ export async function runBot(deps) {
|
|
|
493
503
|
return { stop: async () => { }, done, status: () => null, idle: () => true };
|
|
494
504
|
}
|
|
495
505
|
state = "running";
|
|
496
|
-
|
|
506
|
+
// A fresh process has a fresh failure window; whatever the last one recorded
|
|
507
|
+
// (a bot that exited after repeated failures on an older hq-cli) is over.
|
|
508
|
+
setStatus({ state: "running", lastActivityAt: now().toISOString(), recentFailures: 0, modelHold: null });
|
|
497
509
|
log("info", "running");
|
|
498
510
|
// Read before the kickoff can write its own marker: whatever is here now was
|
|
499
511
|
// left by a previous run that was cut off mid-turn.
|
|
@@ -539,12 +551,49 @@ export async function runBot(deps) {
|
|
|
539
551
|
// ── Heartbeat loop ─────────────────────────────────────────────────────────
|
|
540
552
|
const failures = new FailureWindow(FAILURE_WINDOW_MS, FAILURE_LIMIT);
|
|
541
553
|
let modelHealth = "ok";
|
|
554
|
+
/**
|
|
555
|
+
* FAILURE_LIMIT model failures inside the window: hold model turns for a
|
|
556
|
+
* cooldown instead of exiting. The bot used to exit 0 here, which every
|
|
557
|
+
* supervisor (launchd KeepAlive{SuccessfulExit:false}, systemd on-failure,
|
|
558
|
+
* the hq daemon units) treats as "stay down", so a bot whose coding tool was
|
|
559
|
+
* briefly broken stayed dead until someone ran `hq bot start`, and never saw
|
|
560
|
+
* the updated hq-cli that fixed it. Holding keeps the process (and its
|
|
561
|
+
* hq-cli update watcher) alive; after the cooldown the window is cleared and
|
|
562
|
+
* the next inbound message runs a fresh turn.
|
|
563
|
+
*/
|
|
564
|
+
const modelHoldMs = Math.max(0, deps.modelHoldMs ?? FAILURE_WINDOW_MS);
|
|
565
|
+
let modelHoldUntil = 0;
|
|
566
|
+
const holdAfterFailures = async (excerpt, tell, messagePending = false) => {
|
|
567
|
+
const until = now().getTime() + modelHoldMs;
|
|
568
|
+
modelHoldUntil = until;
|
|
569
|
+
failures.clear();
|
|
570
|
+
modelHealth = "failed";
|
|
571
|
+
const reason = `${FAILURE_LIMIT} model failures in 10 minutes; last: ${excerpt}`;
|
|
572
|
+
setStatus({ modelHold: { until: new Date(until).toISOString(), reason: reason.slice(0, 400) }, lastError: excerpt.slice(0, 400) });
|
|
573
|
+
log("error", `${reason}. Holding model turns for ${Math.round(modelHoldMs / 60_000)} min, then trying again on the next message`);
|
|
574
|
+
// One plain note to the person, never silence: they would otherwise see
|
|
575
|
+
// nothing while their next messages wait out the hold.
|
|
576
|
+
if (tell) {
|
|
577
|
+
try {
|
|
578
|
+
await tell(holdNoticeText(modelHoldMs, messagePending));
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
log("warn", `could not say that turns are on hold: ${err instanceof Error ? err.message : String(err)}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
const liftModelHold = () => {
|
|
586
|
+
modelHoldUntil = 0;
|
|
587
|
+
setStatus({ modelHold: null });
|
|
588
|
+
log("info", "model hold over; the next message runs a fresh turn");
|
|
589
|
+
};
|
|
542
590
|
// ── Coding tool sign-in ────────────────────────────────────────────────────
|
|
543
591
|
const signInRetryMs = Math.max(0, deps.signInRetryMs ?? SIGN_IN_RETRY_MS);
|
|
544
592
|
let signInIssue = null;
|
|
545
593
|
let nextSignInRetryAt = 0;
|
|
546
594
|
/** Conversations already told this time that the bot needs a sign-in. */
|
|
547
595
|
const toldAboutSignIn = new Set();
|
|
596
|
+
const isSignInFailure = (excerpt) => classifyRuntimeFailure(excerpt) === "signed-out";
|
|
548
597
|
const markSignInBroken = (excerpt) => {
|
|
549
598
|
nextSignInRetryAt = now().getTime() + signInRetryMs;
|
|
550
599
|
modelHealth = "degraded";
|
|
@@ -563,11 +612,18 @@ export async function runBot(deps) {
|
|
|
563
612
|
log("info", `${runtime.id} sign-in works again; resuming`);
|
|
564
613
|
};
|
|
565
614
|
/** Say once per conversation that the bot cannot work until its tool signs in. */
|
|
566
|
-
const tellSignInNeeded = async (conversation, post, kickoff = false) => {
|
|
615
|
+
const tellSignInNeeded = async (conversation, post, kickoff = false, retryOf) => {
|
|
567
616
|
if (toldAboutSignIn.has(conversation))
|
|
568
617
|
return;
|
|
569
618
|
try {
|
|
570
|
-
await post(
|
|
619
|
+
await post(runtimeRepairNotice({
|
|
620
|
+
cls: "signed-out",
|
|
621
|
+
runtime: config.runtime,
|
|
622
|
+
botName: config.name,
|
|
623
|
+
context: kickoff ? "kickoff" : "message",
|
|
624
|
+
text: signInNeededText(config.runtime, kickoff),
|
|
625
|
+
...(retryOf ? { retryOf } : {}),
|
|
626
|
+
}));
|
|
571
627
|
toldAboutSignIn.add(conversation);
|
|
572
628
|
}
|
|
573
629
|
catch (err) {
|
|
@@ -774,6 +830,16 @@ export async function runBot(deps) {
|
|
|
774
830
|
}
|
|
775
831
|
modelHealth = "ok";
|
|
776
832
|
markSignInWorks();
|
|
833
|
+
// A good turn: the last recorded failure is no longer the bot's state.
|
|
834
|
+
setStatus({ recentFailures: failures.count(now().getTime()), lastError: undefined });
|
|
835
|
+
if (turn.modelFallback) {
|
|
836
|
+
const { from, to } = turn.modelFallback;
|
|
837
|
+
log("warn", `${runtime.id} on this computer is too old for model ${from || "(its configured default)"}; this turn ran on ${to} instead. Update ${runtime.id} to use ${from || "that model"} again`);
|
|
838
|
+
setStatus({ runtimeModelFallback: { from, to, at: now().toISOString() } });
|
|
839
|
+
}
|
|
840
|
+
else if (readStatus()?.runtimeModelFallback) {
|
|
841
|
+
setStatus({ runtimeModelFallback: null });
|
|
842
|
+
}
|
|
777
843
|
log("info", `model turn done in ${Math.round((now().getTime() - tTurnStart) / 1000)}s (${Math.round((tTurnStart - tReceived) / 1000)}s before it)`);
|
|
778
844
|
if (turn.sessionId) {
|
|
779
845
|
writeBotSession(dir, {
|
|
@@ -792,7 +858,7 @@ export async function runBot(deps) {
|
|
|
792
858
|
const count = failures.record(now().getTime());
|
|
793
859
|
modelHealth = count >= FAILURE_LIMIT ? "failed" : "degraded";
|
|
794
860
|
setStatus({ restarts: (readStatus()?.restarts ?? 0) + 1, recentFailures: count, lastError: excerpt.slice(0, 400) });
|
|
795
|
-
log("error", `model turn failed (${count}/${FAILURE_LIMIT} in window): ${excerpt}`);
|
|
861
|
+
log("error", `model turn failed (${count}/${FAILURE_LIMIT} in window, ${classifyRuntimeError(err)}): ${excerpt}`);
|
|
796
862
|
// A resumed session that no longer exists must not poison every turn.
|
|
797
863
|
const resumed = Boolean(err?.resumedSession);
|
|
798
864
|
if (resumed && /session|resume|not found/i.test(excerpt))
|
|
@@ -892,7 +958,30 @@ export async function runBot(deps) {
|
|
|
892
958
|
currentTurns.delete(controller);
|
|
893
959
|
}
|
|
894
960
|
};
|
|
895
|
-
const
|
|
961
|
+
const failureClass = (outcome) => classifyRuntimeError(outcome.error);
|
|
962
|
+
/** Plain reason for a failed turn: never the runtime's raw text (that goes to the log). */
|
|
963
|
+
const failureReason = (outcome) => outcome.aborted ? STOPPED_BY_OWNER_REASON : runtimeFailureReason(failureClass(outcome), runtime.id);
|
|
964
|
+
/**
|
|
965
|
+
* Post a failure notice: the plain body, plus its repair block in a DM.
|
|
966
|
+
* A server that refuses the block (400) still gets the plain body.
|
|
967
|
+
*/
|
|
968
|
+
const postRepair = async (target, notice, rootEventId) => {
|
|
969
|
+
if (target.kind === "dm" && notice.richContent) {
|
|
970
|
+
const base = { toPersonUid: target.peerUid, body: notice.text, audience: "human", ...(rootEventId ? { rootEventId } : {}) };
|
|
971
|
+
try {
|
|
972
|
+
await api.sendDm({ ...base, richContent: notice.richContent });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
catch (err) {
|
|
976
|
+
if (!(err instanceof BotApiError && err.status === 400))
|
|
977
|
+
throw err;
|
|
978
|
+
log("warn", `repair card refused (${err.code ?? err.status}); sent as plain text`);
|
|
979
|
+
}
|
|
980
|
+
await api.sendDm(base);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
await sendToTarget(target, notice.text, rootEventId, "human");
|
|
984
|
+
};
|
|
896
985
|
const postOne = (target, body, rootEventId, audience) => {
|
|
897
986
|
const root = rootEventId ? { rootEventId } : {};
|
|
898
987
|
return target.kind === "dm"
|
|
@@ -1061,7 +1150,8 @@ export async function runBot(deps) {
|
|
|
1061
1150
|
? {
|
|
1062
1151
|
status: "failed",
|
|
1063
1152
|
disposition: failure.reason.slice(0, 256),
|
|
1064
|
-
|
|
1153
|
+
// The raw runtime text stays in the bot log and status.json only.
|
|
1154
|
+
output: jobRunOutput(failure.output ?? `${failure.reason[0]?.toUpperCase() ?? ""}${failure.reason.slice(1)}.`),
|
|
1065
1155
|
}
|
|
1066
1156
|
: { status: "failed", disposition: "the run was cut off when the bot restarted" };
|
|
1067
1157
|
await completeJobRun(marker.jobId, marker.messageId, completion);
|
|
@@ -1232,12 +1322,12 @@ export async function runBot(deps) {
|
|
|
1232
1322
|
log("info", `kickoff reply sent (${outcome.reply.length} chars)`);
|
|
1233
1323
|
return;
|
|
1234
1324
|
}
|
|
1235
|
-
if (!outcome.aborted && poster.postedCount === 0 &&
|
|
1325
|
+
if (!outcome.aborted && poster.postedCount === 0 && isSignInFailure(outcome.excerpt)) {
|
|
1236
1326
|
// Nothing ran: hold the kickoff until the tool can sign in, then run it.
|
|
1237
1327
|
clearInflight(dir, KICKOFF_MESSAGE_ID);
|
|
1238
1328
|
patchBotConfig(dir, { kickoffPendingSignIn: true });
|
|
1239
1329
|
markSignInBroken(outcome.excerpt);
|
|
1240
|
-
await tellSignInNeeded("dm",
|
|
1330
|
+
await tellSignInNeeded("dm", (notice) => postRepair(target, notice), true);
|
|
1241
1331
|
return;
|
|
1242
1332
|
}
|
|
1243
1333
|
if (readBotConfig(dir)?.kickoffPendingSignIn)
|
|
@@ -1245,11 +1335,20 @@ export async function runBot(deps) {
|
|
|
1245
1335
|
let count = 0;
|
|
1246
1336
|
if (!outcome.aborted)
|
|
1247
1337
|
count = recordTurnFailure(outcome.error, "dm").count;
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1338
|
+
// The person sees one plain sentence and one next step; the raw runtime
|
|
1339
|
+
// text is in the log (recordTurnFailure) and status.json only.
|
|
1340
|
+
const cls = failureClass(outcome);
|
|
1341
|
+
const notice = outcome.aborted
|
|
1342
|
+
? { cls, text: `${didNotFinishText(failureReason(outcome), poster.postedCount > 0)} Send me any message and I'll pick up from there.` }
|
|
1343
|
+
: runtimeRepairNotice({
|
|
1344
|
+
cls,
|
|
1345
|
+
runtime: runtime.id,
|
|
1346
|
+
botName: config.name,
|
|
1347
|
+
context: "kickoff",
|
|
1348
|
+
...(outcome.started && poster.postedCount > 0 ? { text: runtimeDidNotFinishText(cls, runtime.id, true) } : {}),
|
|
1349
|
+
});
|
|
1251
1350
|
try {
|
|
1252
|
-
await
|
|
1351
|
+
await postRepair(target, notice);
|
|
1253
1352
|
clearInflight(dir, KICKOFF_MESSAGE_ID);
|
|
1254
1353
|
}
|
|
1255
1354
|
catch {
|
|
@@ -1257,7 +1356,7 @@ export async function runBot(deps) {
|
|
|
1257
1356
|
}
|
|
1258
1357
|
log("error", `kickoff turn ${outcome.aborted ? "stopped by the owner" : `failed (${count}/${FAILURE_LIMIT} in window)`}; not retried`);
|
|
1259
1358
|
if (!outcome.aborted && failures.exceeded(now().getTime())) {
|
|
1260
|
-
await
|
|
1359
|
+
await holdAfterFailures(outcome.excerpt, sendToPerson);
|
|
1261
1360
|
}
|
|
1262
1361
|
};
|
|
1263
1362
|
/**
|
|
@@ -1392,13 +1491,13 @@ export async function runBot(deps) {
|
|
|
1392
1491
|
log("info", `job ${jobId} run ${id} done (${outcome.reply.length} chars${catchUp ? ", catch-up" : ""})`);
|
|
1393
1492
|
return;
|
|
1394
1493
|
}
|
|
1395
|
-
if (!outcome.aborted && poster.postedCount === 0 &&
|
|
1494
|
+
if (!outcome.aborted && poster.postedCount === 0 && isSignInFailure(outcome.excerpt)) {
|
|
1396
1495
|
// Nothing ran: hold the run until the coding tool can sign in again. It
|
|
1397
1496
|
// is neither acked nor reported succeeded — the work is still owed.
|
|
1398
1497
|
clearInflight(dir, id);
|
|
1399
1498
|
attempts.delete(id);
|
|
1400
1499
|
markSignInBroken(outcome.excerpt);
|
|
1401
|
-
await tellSignInNeeded(`job:${jobId}`, (
|
|
1500
|
+
await tellSignInNeeded(`job:${jobId}`, (notice) => postRepair(markerTarget, notice));
|
|
1402
1501
|
log("warn", `kept scheduled run ${id} of job ${jobId} until ${runtime.id} can sign in again`);
|
|
1403
1502
|
return; // un-acked, un-reported: run once the sign-in works
|
|
1404
1503
|
}
|
|
@@ -1407,12 +1506,13 @@ export async function runBot(deps) {
|
|
|
1407
1506
|
clearInflight(dir, id);
|
|
1408
1507
|
const { excerpt } = recordTurnFailure(outcome.error, target.sessionScope);
|
|
1409
1508
|
if (failures.exceeded(now().getTime())) {
|
|
1410
|
-
await
|
|
1509
|
+
await holdAfterFailures(excerpt, sendToPerson, true);
|
|
1411
1510
|
return;
|
|
1412
1511
|
}
|
|
1413
1512
|
if (attempt >= MAX_ATTEMPTS_PER_MESSAGE) {
|
|
1414
|
-
|
|
1415
|
-
await
|
|
1513
|
+
const plain = runtimeFailureText(classifyRuntimeError(outcome.error), runtime.id);
|
|
1514
|
+
await sendToPerson(`I couldn't run this one. ${plain}`).catch(() => undefined);
|
|
1515
|
+
await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(plain), disposition: `${runtime.id} could not be started` });
|
|
1416
1516
|
markReported("failed");
|
|
1417
1517
|
markProcessed(dir, id, now().toISOString());
|
|
1418
1518
|
await api.ackInbox(config.agentUid, id);
|
|
@@ -1428,16 +1528,21 @@ export async function runBot(deps) {
|
|
|
1428
1528
|
const reason = failureReason(outcome);
|
|
1429
1529
|
// Saved BEFORE the post: if "did not finish" cannot be posted, recovery
|
|
1430
1530
|
// reports and says this real reason, not "the bot was restarted".
|
|
1431
|
-
|
|
1531
|
+
// What the cloud stores as the run's output: plain words, never the raw
|
|
1532
|
+
// runtime text (that is in the bot log and status.json).
|
|
1533
|
+
const jobOutput = outcome.aborted ? `${STOPPED_BY_OWNER_REASON}.` : runtimeFailureText(failureClass(outcome), runtime.id);
|
|
1534
|
+
patchInflight(dir, id, { jobFailure: { reason, output: jobOutput } });
|
|
1432
1535
|
try {
|
|
1433
|
-
await sendToPerson(
|
|
1536
|
+
await sendToPerson(outcome.aborted
|
|
1537
|
+
? didNotFinishText(reason, poster.postedCount > 0)
|
|
1538
|
+
: runtimeDidNotFinishText(failureClass(outcome), runtime.id, poster.postedCount > 0));
|
|
1434
1539
|
}
|
|
1435
1540
|
catch (err) {
|
|
1436
1541
|
// Leave it un-acked with the marker: the next poll reports it.
|
|
1437
1542
|
log("warn", `could not post "did not finish" for run ${id} of job ${jobId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1438
1543
|
return;
|
|
1439
1544
|
}
|
|
1440
|
-
await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(
|
|
1545
|
+
await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(jobOutput), disposition: reason.slice(0, 256) });
|
|
1441
1546
|
markReported("failed");
|
|
1442
1547
|
markProcessed(dir, id, now().toISOString());
|
|
1443
1548
|
await api.ackInbox(config.agentUid, id);
|
|
@@ -1445,7 +1550,7 @@ export async function runBot(deps) {
|
|
|
1445
1550
|
attempts.delete(id);
|
|
1446
1551
|
log("error", `scheduled run ${id} of job ${jobId} ${outcome.aborted ? "stopped by the owner" : "failed"} after it started; not re-run`);
|
|
1447
1552
|
if (!outcome.aborted && failures.exceeded(now().getTime())) {
|
|
1448
|
-
await
|
|
1553
|
+
await holdAfterFailures(outcome.excerpt, sendToPerson);
|
|
1449
1554
|
}
|
|
1450
1555
|
};
|
|
1451
1556
|
/**
|
|
@@ -1657,7 +1762,7 @@ export async function runBot(deps) {
|
|
|
1657
1762
|
});
|
|
1658
1763
|
// The model saw the note once its turn ran. A turn that never started, or
|
|
1659
1764
|
// one held for a coding-tool sign-in, runs again and keeps the note for it.
|
|
1660
|
-
if (setupNote && !toolOffer && (outcome.ok || (outcome.started && !
|
|
1765
|
+
if (setupNote && !toolOffer && (outcome.ok || (outcome.started && !isSignInFailure(outcome.excerpt)))) {
|
|
1661
1766
|
try {
|
|
1662
1767
|
patchSetupIntroState(dir, { notePending: false });
|
|
1663
1768
|
}
|
|
@@ -1666,7 +1771,7 @@ export async function runBot(deps) {
|
|
|
1666
1771
|
}
|
|
1667
1772
|
}
|
|
1668
1773
|
// Same rule for the offer: answered once the model saw the answer.
|
|
1669
|
-
if (toolOffer && toolAnswer && (outcome.ok || (outcome.started && !
|
|
1774
|
+
if (toolOffer && toolAnswer && (outcome.ok || (outcome.started && !isSignInFailure(outcome.excerpt)))) {
|
|
1670
1775
|
try {
|
|
1671
1776
|
patchSetupIntroState(dir, { kickoffDeferred: false, toolOfferAnswer: toolAnswer });
|
|
1672
1777
|
log("info", `coding tool offer answered (${toolAnswer === "kept_here" ? "kept setup here" : "wrote a message"}); setup runs here`);
|
|
@@ -1710,14 +1815,14 @@ export async function runBot(deps) {
|
|
|
1710
1815
|
return;
|
|
1711
1816
|
}
|
|
1712
1817
|
await clearThinking();
|
|
1713
|
-
if (!outcome.aborted && poster.postedCount === 0 &&
|
|
1818
|
+
if (!outcome.aborted && poster.postedCount === 0 && isSignInFailure(outcome.excerpt)) {
|
|
1714
1819
|
// The tool refused before doing anything: keep the message for when it
|
|
1715
1820
|
// can sign in again, and say so once in this conversation.
|
|
1716
1821
|
clearInflight(dir, id);
|
|
1717
1822
|
attempts.delete(id);
|
|
1718
1823
|
markSignInBroken(outcome.excerpt);
|
|
1719
1824
|
const conversation = markerTarget.kind === "dm" ? sessionScope : `room:${markerTarget.channelId}:${item.rootEventId ?? ""}`;
|
|
1720
|
-
await tellSignInNeeded(conversation,
|
|
1825
|
+
await tellSignInNeeded(conversation, (notice) => postRepair(markerTarget, notice, item.rootEventId), false, item.eventId ?? id);
|
|
1721
1826
|
log("warn", `kept ${id} until ${runtime.id} can sign in again`);
|
|
1722
1827
|
return; // un-acked: answered once the sign-in works
|
|
1723
1828
|
}
|
|
@@ -1727,12 +1832,18 @@ export async function runBot(deps) {
|
|
|
1727
1832
|
clearInflight(dir, id);
|
|
1728
1833
|
const { excerpt } = recordTurnFailure(outcome.error, sessionScope);
|
|
1729
1834
|
if (failures.exceeded(now().getTime())) {
|
|
1730
|
-
await
|
|
1835
|
+
await holdAfterFailures(excerpt, sendToPerson, true);
|
|
1731
1836
|
return;
|
|
1732
1837
|
}
|
|
1733
1838
|
if (attempt >= MAX_ATTEMPTS_PER_MESSAGE) {
|
|
1734
1839
|
try {
|
|
1735
|
-
await
|
|
1840
|
+
await postRepair(markerTarget, runtimeRepairNotice({
|
|
1841
|
+
cls: classifyRuntimeError(outcome.error),
|
|
1842
|
+
runtime: runtime.id,
|
|
1843
|
+
botName: config.name,
|
|
1844
|
+
context: "message",
|
|
1845
|
+
retryOf: item.eventId ?? id,
|
|
1846
|
+
}), item.rootEventId);
|
|
1736
1847
|
}
|
|
1737
1848
|
catch {
|
|
1738
1849
|
/* best-effort */
|
|
@@ -1762,8 +1873,9 @@ export async function runBot(deps) {
|
|
|
1762
1873
|
attempts.delete(id);
|
|
1763
1874
|
await clearThinking();
|
|
1764
1875
|
log("info", `unmentioned room turn ${id} failed with no post (${where}); acked silently`);
|
|
1876
|
+
// Nobody asked the bot here, so the hold note goes to the owner's DM, not the room.
|
|
1765
1877
|
if (failures.exceeded(now().getTime())) {
|
|
1766
|
-
await
|
|
1878
|
+
await holdAfterFailures(outcome.excerpt, (body) => sendToTarget({ kind: "dm", peerUid: config.ownerUid }, body, undefined, "human"));
|
|
1767
1879
|
}
|
|
1768
1880
|
return;
|
|
1769
1881
|
}
|
|
@@ -1771,8 +1883,21 @@ export async function runBot(deps) {
|
|
|
1771
1883
|
// DM or @-mentioned room reasoning_only: the person asked directly, so
|
|
1772
1884
|
// still say we did not finish — but with a plain reason, never the raw
|
|
1773
1885
|
// internal error string.
|
|
1774
|
-
|
|
1775
|
-
|
|
1886
|
+
if (reasoningOnly || outcome.aborted) {
|
|
1887
|
+
const reason = reasoningOnly ? "the model returned no answer" : failureReason(outcome);
|
|
1888
|
+
await sendToPerson(didNotFinishText(reason, poster.postedCount > 0));
|
|
1889
|
+
}
|
|
1890
|
+
else {
|
|
1891
|
+
const cls = failureClass(outcome);
|
|
1892
|
+
await postRepair(markerTarget, runtimeRepairNotice({
|
|
1893
|
+
cls,
|
|
1894
|
+
runtime: runtime.id,
|
|
1895
|
+
botName: config.name,
|
|
1896
|
+
context: "message",
|
|
1897
|
+
text: runtimeDidNotFinishText(cls, runtime.id, poster.postedCount > 0),
|
|
1898
|
+
retryOf: item.eventId ?? id,
|
|
1899
|
+
}), item.rootEventId);
|
|
1900
|
+
}
|
|
1776
1901
|
}
|
|
1777
1902
|
catch (err) {
|
|
1778
1903
|
// Leave it un-acked with the marker: the next poll says it did not finish.
|
|
@@ -1785,7 +1910,7 @@ export async function runBot(deps) {
|
|
|
1785
1910
|
attempts.delete(id);
|
|
1786
1911
|
log("error", `turn on ${id} ${outcome.aborted ? "stopped by the owner" : "failed"} after it started; told the owner, not re-run`);
|
|
1787
1912
|
if (!outcome.aborted && failures.exceeded(now().getTime())) {
|
|
1788
|
-
await
|
|
1913
|
+
await holdAfterFailures(outcome.excerpt, sendToPerson);
|
|
1789
1914
|
}
|
|
1790
1915
|
};
|
|
1791
1916
|
// The kickoff runs alongside the heartbeat, before the first inbox poll, so
|
|
@@ -1874,6 +1999,18 @@ export async function runBot(deps) {
|
|
|
1874
1999
|
// Queued items were never started or acked; the next process gets them.
|
|
1875
2000
|
if (pollForCliUpdate())
|
|
1876
2001
|
break;
|
|
2002
|
+
// Completions the cloud has not accepted yet: re-send the report (never
|
|
2003
|
+
// the work), alongside the poll rather than in front of it. Also during
|
|
2004
|
+
// a hold: a report owed to the cloud has nothing to do with the model.
|
|
2005
|
+
kickCompletionRetries();
|
|
2006
|
+
// Holding after repeated model failures: no inbox pulls until the
|
|
2007
|
+
// cooldown is over (the update watcher above keeps running meanwhile).
|
|
2008
|
+
if (modelHoldUntil > now().getTime()) {
|
|
2009
|
+
await sleep(Math.max(1, Math.min(inboxIntervalMs, modelHoldUntil - now().getTime())));
|
|
2010
|
+
continue;
|
|
2011
|
+
}
|
|
2012
|
+
if (modelHoldUntil)
|
|
2013
|
+
liftModelHold();
|
|
1877
2014
|
// A kickoff held for a sign-in retries on the same timer as messages.
|
|
1878
2015
|
if (busyLanes.size === 0 &&
|
|
1879
2016
|
(!signInIssue || now().getTime() >= nextSignInRetryAt) &&
|
|
@@ -1884,9 +2021,6 @@ export async function runBot(deps) {
|
|
|
1884
2021
|
log("error", `kickoff retry crashed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1885
2022
|
});
|
|
1886
2023
|
}
|
|
1887
|
-
// Completions the cloud has not accepted yet: re-send the report (never
|
|
1888
|
-
// the work), alongside the poll rather than in front of it.
|
|
1889
|
-
kickCompletionRetries();
|
|
1890
2024
|
const useInboxBackoff = await inboxPollBackoffEnabled().catch((error) => {
|
|
1891
2025
|
log("warn", `inbox poll backoff flag lookup failed (${safeErrorClass(error)}); using default-off`);
|
|
1892
2026
|
return false;
|
|
@@ -13,7 +13,51 @@
|
|
|
13
13
|
* Verified against Codex CLI on 2026-09-10 (event names; the model itself
|
|
14
14
|
* cannot run under this machine's account).
|
|
15
15
|
*/
|
|
16
|
-
import { type BotRuntime, type RuntimeStreamEvent, type RuntimeTurnInput } from "./types.js";
|
|
16
|
+
import { type BotRuntime, type RuntimeStreamEvent, type RuntimeTurnInput, type SpawnResult } from "./types.js";
|
|
17
|
+
/**
|
|
18
|
+
* The model a turn falls back to when the installed Codex is too old for the
|
|
19
|
+
* one it was asked to use (usually the person's own `model` in
|
|
20
|
+
* ~/.codex/config.toml, set by a newer Codex app) and the first choice from
|
|
21
|
+
* Codex's bundled catalog is refused as well. Every Codex CLI since mid 2026
|
|
22
|
+
* ships it.
|
|
23
|
+
*/
|
|
24
|
+
export declare const CODEX_PINNED_FALLBACK_MODEL = "gpt-5.5";
|
|
25
|
+
/**
|
|
26
|
+
* The generation number in a Codex model slug: 6 for "gpt-6-astra", 6.1 for
|
|
27
|
+
* "gpt-6.1-sol", 5.6 for "gpt-5.6-terra". Undefined when the slug has none
|
|
28
|
+
* ("codex-auto-review").
|
|
29
|
+
*/
|
|
30
|
+
export declare function codexModelGeneration(slug: string): number | undefined;
|
|
31
|
+
/** [0, 147, 0] from "codex-cli 0.147.0". */
|
|
32
|
+
export declare function parseCodexVersion(text: string | undefined): number[] | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The model Codex itself would offer first that this Codex is most likely to
|
|
35
|
+
* run, from the JSON of `codex debug models --bundled` (`{ "models": [...] }`
|
|
36
|
+
* or a bare array). Lowest `priority` wins among the models that are listed
|
|
37
|
+
* in the picker, not `exclude`d, not newer than `installed` (when the catalog
|
|
38
|
+
* carries `minimal_client_version`; the real one does not), and of an OLDER
|
|
39
|
+
* generation than `refused` (gpt-6.1-sol is no better than gpt-6-astra for a
|
|
40
|
+
* Codex that refuses gpt-6; gpt-5.6-sol is). The bundled catalog of an old
|
|
41
|
+
* Codex still lists models it cannot run, because the "requires a newer
|
|
42
|
+
* version" check is the server's, so the generation rule is what matters.
|
|
43
|
+
*/
|
|
44
|
+
export declare function pickCodexFallbackModel(catalogJson: string, opts?: {
|
|
45
|
+
exclude?: string[];
|
|
46
|
+
installed?: number[];
|
|
47
|
+
refused?: string;
|
|
48
|
+
}): string | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* The models to try, best first, after this Codex refused `failing` as too
|
|
51
|
+
* new: the best older-generation model from the bundled catalog, then the
|
|
52
|
+
* pinned fallback. At most MAX_MODEL_FALLBACK_RETRIES, never `failing` itself.
|
|
53
|
+
* `codex debug models --bundled` reads only the catalog inside the binary: no
|
|
54
|
+
* network, no sign-in, no cache write.
|
|
55
|
+
*/
|
|
56
|
+
export declare function codexFallbackModels(args: {
|
|
57
|
+
reason: string;
|
|
58
|
+
requestedModel: string | undefined;
|
|
59
|
+
run: (args: string[]) => Promise<SpawnResult>;
|
|
60
|
+
}): Promise<string[]>;
|
|
17
61
|
/** Progress events in one codex JSONL line: finished agent messages and tool starts. */
|
|
18
62
|
export declare function codexStreamEvents(line: string): RuntimeStreamEvent[];
|
|
19
63
|
export declare function codexPrompt(input: RuntimeTurnInput): string;
|
|
@@ -16,8 +16,137 @@
|
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import * as os from "node:os";
|
|
18
18
|
import * as path from "node:path";
|
|
19
|
-
import { parseJsonLine, RuntimeError } from "./types.js";
|
|
19
|
+
import { MAX_MODEL_FALLBACK_RETRIES, parseJsonLine, RuntimeError } from "./types.js";
|
|
20
20
|
import { resolveRuntimeBinary } from "./windows.js";
|
|
21
|
+
import { versionTooOldModel } from "../runtime-failure.js";
|
|
22
|
+
/**
|
|
23
|
+
* The model a turn falls back to when the installed Codex is too old for the
|
|
24
|
+
* one it was asked to use (usually the person's own `model` in
|
|
25
|
+
* ~/.codex/config.toml, set by a newer Codex app) and the first choice from
|
|
26
|
+
* Codex's bundled catalog is refused as well. Every Codex CLI since mid 2026
|
|
27
|
+
* ships it.
|
|
28
|
+
*/
|
|
29
|
+
export const CODEX_PINNED_FALLBACK_MODEL = "gpt-5.5";
|
|
30
|
+
/**
|
|
31
|
+
* The generation number in a Codex model slug: 6 for "gpt-6-astra", 6.1 for
|
|
32
|
+
* "gpt-6.1-sol", 5.6 for "gpt-5.6-terra". Undefined when the slug has none
|
|
33
|
+
* ("codex-auto-review").
|
|
34
|
+
*/
|
|
35
|
+
export function codexModelGeneration(slug) {
|
|
36
|
+
const m = /^gpt-(\d+(?:\.\d+)?)(?:$|[-.])/i.exec(slug.trim());
|
|
37
|
+
return m ? Number(m[1]) : undefined;
|
|
38
|
+
}
|
|
39
|
+
/** [0, 147, 0] from "codex-cli 0.147.0". */
|
|
40
|
+
export function parseCodexVersion(text) {
|
|
41
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(text ?? "");
|
|
42
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
|
|
43
|
+
}
|
|
44
|
+
function versionOf(value) {
|
|
45
|
+
if (typeof value === "string")
|
|
46
|
+
return parseCodexVersion(value);
|
|
47
|
+
if (Array.isArray(value) && value.length > 0 && value.every((n) => typeof n === "number" && Number.isFinite(n))) {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
function compareVersions(a, b) {
|
|
53
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
54
|
+
const d = (a[i] ?? 0) - (b[i] ?? 0);
|
|
55
|
+
if (d !== 0)
|
|
56
|
+
return d;
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The model Codex itself would offer first that this Codex is most likely to
|
|
62
|
+
* run, from the JSON of `codex debug models --bundled` (`{ "models": [...] }`
|
|
63
|
+
* or a bare array). Lowest `priority` wins among the models that are listed
|
|
64
|
+
* in the picker, not `exclude`d, not newer than `installed` (when the catalog
|
|
65
|
+
* carries `minimal_client_version`; the real one does not), and of an OLDER
|
|
66
|
+
* generation than `refused` (gpt-6.1-sol is no better than gpt-6-astra for a
|
|
67
|
+
* Codex that refuses gpt-6; gpt-5.6-sol is). The bundled catalog of an old
|
|
68
|
+
* Codex still lists models it cannot run, because the "requires a newer
|
|
69
|
+
* version" check is the server's, so the generation rule is what matters.
|
|
70
|
+
*/
|
|
71
|
+
export function pickCodexFallbackModel(catalogJson, opts = {}) {
|
|
72
|
+
let root;
|
|
73
|
+
try {
|
|
74
|
+
root = JSON.parse(catalogJson);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const list = Array.isArray(root)
|
|
80
|
+
? root
|
|
81
|
+
: root && typeof root === "object" && Array.isArray(root.models)
|
|
82
|
+
? root.models
|
|
83
|
+
: [];
|
|
84
|
+
const exclude = new Set((opts.exclude ?? []).filter(Boolean));
|
|
85
|
+
const refusedGen = opts.refused ? codexModelGeneration(opts.refused) : undefined;
|
|
86
|
+
const candidates = [];
|
|
87
|
+
list.forEach((entry, index) => {
|
|
88
|
+
if (!entry || typeof entry !== "object")
|
|
89
|
+
return;
|
|
90
|
+
const m = entry;
|
|
91
|
+
const slug = typeof m.slug === "string" ? m.slug.trim() : "";
|
|
92
|
+
if (!slug || exclude.has(slug) || !/^[\w.:/-]{1,128}$/.test(slug))
|
|
93
|
+
return;
|
|
94
|
+
if (typeof m.visibility === "string" && m.visibility !== "list")
|
|
95
|
+
return;
|
|
96
|
+
if (refusedGen !== undefined) {
|
|
97
|
+
const gen = codexModelGeneration(slug);
|
|
98
|
+
if (gen === undefined || gen >= refusedGen)
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const min = versionOf(m.minimal_client_version);
|
|
102
|
+
if (min && opts.installed && compareVersions(min, opts.installed) > 0)
|
|
103
|
+
return;
|
|
104
|
+
const priority = typeof m.priority === "number" && Number.isFinite(m.priority) ? m.priority : Number.POSITIVE_INFINITY;
|
|
105
|
+
candidates.push({ slug, priority, index });
|
|
106
|
+
});
|
|
107
|
+
candidates.sort((a, b) => a.priority - b.priority || a.index - b.index);
|
|
108
|
+
return candidates[0]?.slug;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The models to try, best first, after this Codex refused `failing` as too
|
|
112
|
+
* new: the best older-generation model from the bundled catalog, then the
|
|
113
|
+
* pinned fallback. At most MAX_MODEL_FALLBACK_RETRIES, never `failing` itself.
|
|
114
|
+
* `codex debug models --bundled` reads only the catalog inside the binary: no
|
|
115
|
+
* network, no sign-in, no cache write.
|
|
116
|
+
*/
|
|
117
|
+
export async function codexFallbackModels(args) {
|
|
118
|
+
const failing = versionTooOldModel(args.reason) ?? args.requestedModel;
|
|
119
|
+
const exclude = [failing ?? "", args.requestedModel ?? ""];
|
|
120
|
+
// With no refused model named, or one whose slug carries no generation, the
|
|
121
|
+
// catalog cannot say which models are older: the pinned fallback alone.
|
|
122
|
+
if (!failing || codexModelGeneration(failing) === undefined) {
|
|
123
|
+
return exclude.includes(CODEX_PINNED_FALLBACK_MODEL) ? [] : [CODEX_PINNED_FALLBACK_MODEL];
|
|
124
|
+
}
|
|
125
|
+
const out = [];
|
|
126
|
+
let installed;
|
|
127
|
+
try {
|
|
128
|
+
const v = await args.run(["--version"]);
|
|
129
|
+
if (v.code === 0)
|
|
130
|
+
installed = parseCodexVersion(v.stdout);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* unknown version: the catalog is still filtered by generation and visibility */
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const catalog = await args.run(["debug", "models", "--bundled"]);
|
|
137
|
+
if (catalog.code === 0) {
|
|
138
|
+
const picked = pickCodexFallbackModel(catalog.stdout, { exclude, ...(installed ? { installed } : {}), ...(failing ? { refused: failing } : {}) });
|
|
139
|
+
if (picked)
|
|
140
|
+
out.push(picked);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* older Codex without `debug models`: the pinned fallback alone */
|
|
145
|
+
}
|
|
146
|
+
if (!exclude.includes(CODEX_PINNED_FALLBACK_MODEL) && !out.includes(CODEX_PINNED_FALLBACK_MODEL))
|
|
147
|
+
out.push(CODEX_PINNED_FALLBACK_MODEL);
|
|
148
|
+
return out.slice(0, MAX_MODEL_FALLBACK_RETRIES);
|
|
149
|
+
}
|
|
21
150
|
/** Progress events in one codex JSONL line: finished agent messages and tool starts. */
|
|
22
151
|
export function codexStreamEvents(line) {
|
|
23
152
|
const ev = parseJsonLine(line);
|
|
@@ -200,7 +329,7 @@ export function createCodexRuntime() {
|
|
|
200
329
|
const fromFile = extra.outputFileText?.trim();
|
|
201
330
|
const text = (fromFile || lastMessage || "").trim();
|
|
202
331
|
if (!text) {
|
|
203
|
-
throw new RuntimeError(`codex produced no reply${errorMessage ? `: ${errorMessage.slice(0, 200)}` : ""}`, (errorMessage ?? stdout.slice(-400)).slice(0, 400), null);
|
|
332
|
+
throw new RuntimeError(`codex produced no reply${errorMessage ? `: ${errorMessage.slice(0, 200)}` : ""}`, (errorMessage ?? stdout.slice(-400)).slice(0, 400), null, errorMessage ? {} : { excerptIsOutputTail: true });
|
|
204
333
|
}
|
|
205
334
|
return { text, sessionId };
|
|
206
335
|
},
|
|
@@ -210,6 +339,9 @@ export function createCodexRuntime() {
|
|
|
210
339
|
failureReason(stdout) {
|
|
211
340
|
return codexFailureReason(stdout);
|
|
212
341
|
},
|
|
342
|
+
fallbackModelsAfterVersionMismatch(args) {
|
|
343
|
+
return codexFallbackModels(args);
|
|
344
|
+
},
|
|
213
345
|
};
|
|
214
346
|
}
|
|
215
347
|
//# sourceMappingURL=codex.js.map
|