@indigoai-us/hq-cli 5.135.0 → 5.136.0

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.
@@ -13,6 +13,14 @@
13
13
  * - first start only: the intro DM, then (when bot.json has `kickoff`) ONE
14
14
  * model turn on that prompt as if the owner sent it, answered in the DM, so
15
15
  * a guided bot starts working without waiting for the owner to type
16
+ * - job items (channel "job"): a run the cloud fired on an EventBridge
17
+ * schedule. Nothing is scheduled on this computer. The run is one model turn
18
+ * in its own session (one per jobId), reported in the owner's DM with a
19
+ * "scheduled job" header through the same split/recovery path as every other
20
+ * reply, and reported back to the cloud EXACTLY ONCE — nothing else clears
21
+ * the job's in-progress flag. A run whose staleAfter has passed still runs,
22
+ * once, labelled a catch-up; older pending runs of the same job are reported
23
+ * skipped-precondition instead of piling up.
16
24
  * - delivered room items (channels / group chats) are eligible for replies; claimed
17
25
  * first (first-claim-wins), one session per room, reply posted in the room
18
26
  * - conversations run side by side: up to MAX_PARALLEL_TURNS turns at once,
@@ -57,6 +65,8 @@ import { FALLBACK_CLAUDE_PERMISSION_MODE, isClaudeBypassDisabled } from "./runti
57
65
  import { runRuntimeTurn, RuntimeError } from "./runtime/index.js";
58
66
  import { dmSessionScope } from "./paths.js";
59
67
  import { isDmItem, resolveReplyTarget } from "./room-policy.js";
68
+ import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOutput, machineTimeZone, planJobRuns, stripJobHeader, SUPERSEDED_DISPOSITION } from "./job-run.js";
69
+ import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
60
70
  import { isRuntimeSignInFailure, SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
61
71
  import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
62
72
  import { captureHeartbeatIdentityMismatch, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
@@ -111,6 +121,18 @@ export const MAX_PARALLEL_TURNS = 2;
111
121
  export const RECOVERY_ATTEMPT_LIMIT = 5;
112
122
  /** What the person sees when a reply could not be delivered at all. */
113
123
  export const UNDELIVERABLE_REPLY_TEXT = "I wrote a reply but couldn't deliver it. Ask me again and I'll keep it shorter.";
124
+ /**
125
+ * The same, for a scheduled run. Nobody asked, so "ask me again" is wrong: the
126
+ * run DID finish, and its outcome is still recorded with the job in HQ.
127
+ */
128
+ export const UNDELIVERABLE_JOB_REPORT_TEXT = "This scheduled run finished, but I couldn't deliver its report here. Its result is saved with the job's run history.";
129
+ /**
130
+ * Scheduled work must never crowd out the people the bot talks to. A job turn
131
+ * can run for a long time, so at most ONE runs at a time, and a job never takes
132
+ * the last free slot: a DM or a room message can always start immediately
133
+ * instead of waiting, with no explanation, behind two long jobs.
134
+ */
135
+ export const MAX_PARALLEL_JOB_TURNS = 1;
114
136
  export const KICKOFF_MESSAGE_ID = "kickoff";
115
137
  /**
116
138
  * The environment a model turn's `hq` commands run in.
@@ -158,13 +180,17 @@ export const RESTARTED_REASON = "I was restarted while I was working on it";
158
180
  /**
159
181
  * The lane an inbox item runs in: the model session its turn resumes. Owner
160
182
  * DMs retain the legacy DM session; other senders and rooms each have their
161
- * own. An unsupported item gets a lane of its own.
183
+ * own, and so does each scheduled job (two different jobs must never share a
184
+ * model session, and a job must never pollute the owner's DM conversation).
185
+ * An item that never reaches a model turn gets a lane of its own.
162
186
  */
163
187
  export function inboxLaneKey(item, config) {
164
188
  if (isDmItem(item))
165
189
  return dmSessionScope(item.fromPersonUid ?? "", config.ownerUid);
166
190
  const target = resolveReplyTarget(item, config);
167
- return target?.kind === "room" ? target.sessionScope : `item:${item.messageId}`;
191
+ if (target?.kind === "room" || target?.kind === "job")
192
+ return target.sessionScope;
193
+ return `item:${item.messageId}`;
168
194
  }
169
195
  class FailureWindow {
170
196
  windowMs;
@@ -247,6 +273,9 @@ export async function runBot(deps) {
247
273
  const turnCwd = config.hqRoot;
248
274
  // HQ's setup bot is a guided conversation: no "working on it" filler between steps.
249
275
  const isSetupBot = config.workerId === SETUP_WORKER_ID;
276
+ // Scheduled runs are reported in the owner's own zone: "every weekday at 9am"
277
+ // was set up here, so "due 13:00 UTC" would read as the wrong time.
278
+ const timeZone = machineTimeZone();
250
279
  // Personal: every turn acts as the owner. Company: every turn acts as the bot.
251
280
  const kind = effectiveBotKind(config);
252
281
  const companies = effectiveBotCompanies(config);
@@ -506,7 +535,12 @@ export async function runBot(deps) {
506
535
  // kickoff). Room prompts must keep their [#channel …] header first, and a
507
536
  // room turn can be triggered by other people, who must not be handed the
508
537
  // owner's companies. A company bot acts as itself and never gets it.
509
- if (deps.ownerContext && sessionScope === "dm" && kind === "personal") {
538
+ // Owner context also goes on the owner's scheduled jobs: `turnEnv` strips
539
+ // the machine credentials on EVERY personal-bot turn, so a job turn runs
540
+ // `hq` as the owner too and needs the same block saying whose account it
541
+ // is on. Rooms stay excluded (see above).
542
+ const actsPurelyForOwner = sessionScope === "dm" || sessionScope.startsWith("job:");
543
+ if (deps.ownerContext && actsPurelyForOwner && kind === "personal") {
510
544
  try {
511
545
  const owner = await deps.ownerContext();
512
546
  if (owner.status === "unavailable") {
@@ -692,6 +726,83 @@ export async function runBot(deps) {
692
726
  };
693
727
  return { send, saved };
694
728
  };
729
+ // ── Scheduled jobs ────────────────────────────────────────────────────────
730
+ /** Newest pending run key already picked up, per jobId (one catch-up per wake). */
731
+ const handledJobFires = new Map();
732
+ /** Runs an older fire of a job that a newer pending run supersedes. */
733
+ const supersededJobRuns = new Set();
734
+ /**
735
+ * Report one fired run as finished. This is the ONLY thing that clears the
736
+ * job's in-progress flag in the cloud, so a failure is logged loudly and the
737
+ * completion is kept for the next poll — the caller still acks, because the
738
+ * work is done and must never run twice.
739
+ */
740
+ const completeJobRun = async (jobId, messageId, completion) => {
741
+ try {
742
+ await api.completeJobRun(config.agentUid, jobId, messageId, completion);
743
+ clearPendingJobCompletion(dir, messageId);
744
+ log("info", `reported run ${messageId} of job ${jobId} as ${completion.status}`);
745
+ return true;
746
+ }
747
+ catch (err) {
748
+ const reason = err instanceof Error ? err.message : String(err);
749
+ const pending = recordPendingJobCompletion(dir, { agentUid: config.agentUid, jobId, messageId, status: completion.status, ...(completion.output !== undefined ? { output: completion.output } : {}), ...(completion.disposition !== undefined ? { disposition: completion.disposition } : {}) }, now());
750
+ log("error", `COULD NOT report run ${messageId} of job ${jobId} as ${completion.status}: ${reason}. ` +
751
+ `Until this is reported the cloud keeps the job marked in progress and skips its next fires. ` +
752
+ `Retry ${pending.attempts} in ${Math.round((pending.nextAttemptAt - now().getTime()) / 1000)}s.`);
753
+ return false;
754
+ }
755
+ };
756
+ /**
757
+ * Re-send completions the cloud has not accepted yet. Never re-runs work.
758
+ * Backed off per entry and given up on by age, so a minute-long network drop
759
+ * recovers instead of burning a fixed attempt budget in twenty seconds.
760
+ */
761
+ const retryPendingJobCompletions = async () => {
762
+ const nowMs = now().getTime();
763
+ for (const pending of readPendingJobCompletions(dir)) {
764
+ // A folder that outlived a deleted, same-named bot: never report another
765
+ // identity's run as this one's (see job-completions.ts, "After a reinstall").
766
+ if (isCompletionForOtherAgent(pending, config.agentUid)) {
767
+ clearPendingJobCompletion(dir, pending.messageId);
768
+ log("warn", `dropped an unreported run ${pending.messageId} of job ${pending.jobId}: it belongs to ${pending.agentUid}, ` +
769
+ `not this bot (${config.agentUid}) — left behind by an earlier bot with the same name`);
770
+ continue;
771
+ }
772
+ if (isCompletionExpired(pending, nowMs)) {
773
+ clearPendingJobCompletion(dir, pending.messageId);
774
+ log("error", `giving up on reporting run ${pending.messageId} of job ${pending.jobId} after ${pending.attempts} attempts ` +
775
+ `over an hour; the cloud has released the job's in-progress slot by itself by now`);
776
+ continue;
777
+ }
778
+ if (!isCompletionDue(pending, nowMs))
779
+ continue;
780
+ await completeJobRun(pending.jobId, pending.messageId, {
781
+ status: pending.status,
782
+ ...(pending.output !== undefined ? { output: pending.output } : {}),
783
+ ...(pending.disposition !== undefined ? { disposition: pending.disposition } : {}),
784
+ });
785
+ }
786
+ };
787
+ /**
788
+ * Kick the completion retries WITHOUT blocking the poll. Awaiting them in the
789
+ * inbox loop meant one hanging completion POST stalled DM delivery behind it.
790
+ * One pass at a time; the tracked promise is drained on shutdown.
791
+ */
792
+ let completionRetryTask = Promise.resolve();
793
+ let completionRetryRunning = false;
794
+ const kickCompletionRetries = () => {
795
+ if (completionRetryRunning)
796
+ return;
797
+ completionRetryRunning = true;
798
+ completionRetryTask = retryPendingJobCompletions()
799
+ .catch((err) => {
800
+ log("warn", `retrying job completions failed: ${err instanceof Error ? err.message : String(err)}`);
801
+ })
802
+ .finally(() => {
803
+ completionRetryRunning = false;
804
+ });
805
+ };
695
806
  /** Close a marker out: clear the thinking reaction, ack the message, forget it. */
696
807
  const closeMarker = async (marker, isKickoff) => {
697
808
  if (marker.target.kind === "dm" && marker.reactionEventId) {
@@ -707,6 +818,51 @@ export async function runBot(deps) {
707
818
  }
708
819
  clearInflight(dir, marker.messageId);
709
820
  };
821
+ /**
822
+ * A scheduled run's marker is about to be closed: report its REAL outcome to
823
+ * the cloud first, and record that it was reported, BEFORE the ack.
824
+ *
825
+ * - A saved reply means the work finished and only delivery failed: that is
826
+ * `succeeded`, with the report as output — including when delivery was
827
+ * given up on as impossible. The owner not getting the DM does not undo
828
+ * the work, and not reporting would jam the job for an hour.
829
+ * - No saved reply means the turn was cut off: `failed`.
830
+ * - A marker that says it was already reported is never reported again: the
831
+ * server's complete is last-write-wins, so a second report after a crash
832
+ * between reporting and acking would overwrite the recorded outcome.
833
+ */
834
+ const reportRecoveredJobRun = async (marker) => {
835
+ if (!marker.jobId || marker.jobCompleted)
836
+ return;
837
+ const finished = typeof marker.reply === "string";
838
+ // A run that failed or was stopped carries its real reason; only a marker
839
+ // with none was genuinely cut off by a restart.
840
+ const failure = marker.jobFailure;
841
+ const completion = finished
842
+ ? { status: "succeeded", output: marker.jobOutput ?? jobRunOutput(stripJobHeader(marker.reply, marker.jobHeader)) }
843
+ : failure
844
+ ? {
845
+ status: "failed",
846
+ disposition: failure.reason.slice(0, 256),
847
+ ...(failure.excerpt ? { output: jobRunOutput(failure.excerpt) } : {}),
848
+ }
849
+ : { status: "failed", disposition: "the run was cut off when the bot restarted" };
850
+ await completeJobRun(marker.jobId, marker.messageId, completion);
851
+ patchInflight(dir, marker.messageId, { jobCompleted: completion.status });
852
+ };
853
+ /**
854
+ * The body a recovery posts. A scheduled run's FIRST message carries its
855
+ * header, so a report delivered by this path still reads as a scheduled run
856
+ * and not as an unsolicited DM. A body saved in parts already has the header
857
+ * baked in (the job's own send put it there), so it is never added twice.
858
+ */
859
+ const recoveryBody = (marker) => {
860
+ // A job that failed on its own says why; "I was restarted" only when it was.
861
+ const reason = marker.jobFailure?.reason ?? RESTARTED_REASON;
862
+ const body = marker.reply ?? didNotFinishText(reason, (marker.progressPosted ?? 0) > 0);
863
+ const headerAlreadyOut = (marker.progressPosted ?? 0) > 0 || body.startsWith(marker.jobHeader ?? "\u0000");
864
+ return marker.jobHeader && !headerAlreadyOut ? `${marker.jobHeader}${body}` : body;
865
+ };
710
866
  /**
711
867
  * A turn that was cut off by a restart (or whose answer never got posted):
712
868
  * tell the person instead of running it again. Returns false when the post
@@ -720,11 +876,24 @@ export async function runBot(deps) {
720
876
  */
721
877
  const recoverInflight = async (marker) => {
722
878
  const isKickoff = marker.messageId === KICKOFF_MESSAGE_ID;
879
+ // A scheduled run left in this folder by a DIFFERENT bot (adopt into a
880
+ // folder that survived without its bot.json). Posting its report would put
881
+ // another bot's work in this bot's DM, and completing it would be a write
882
+ // under the wrong identity — the thing job-completions.ts rules out. Drop
883
+ // it: no post, no completion, no ack (the item is not in this inbox). The
884
+ // cloud releases that job's slot by itself within the hour. A marker with
885
+ // no recorded agent predates the field and is recovered as before.
886
+ if (marker.jobId && marker.agentUid && marker.agentUid !== config.agentUid) {
887
+ clearInflight(dir, marker.messageId);
888
+ log("warn", `dropped an unfinished scheduled run ${marker.messageId} of job ${marker.jobId}: it belongs to ${marker.agentUid}, ` +
889
+ `not this bot (${config.agentUid}) — left behind by an earlier bot in this folder; not posted, not reported`);
890
+ return true;
891
+ }
723
892
  if (!isKickoff && isProcessed(readInboxState(dir), marker.messageId)) {
724
893
  clearInflight(dir, marker.messageId);
725
894
  return true;
726
895
  }
727
- const parts = marker.replyParts ?? splitBody(marker.reply ?? didNotFinishText(RESTARTED_REASON, (marker.progressPosted ?? 0) > 0));
896
+ const parts = marker.replyParts ?? splitBody(recoveryBody(marker));
728
897
  const from = marker.replyParts ? Math.min(Math.max(0, marker.replyPartsDelivered ?? 0), parts.length) : 0;
729
898
  try {
730
899
  await sendParts(marker.target, parts, from, marker.rootEventId, (count) => patchInflight(dir, marker.messageId, { replyParts: parts, replyPartsDelivered: count }));
@@ -734,7 +903,18 @@ export async function runBot(deps) {
734
903
  const attempt = (marker.recoveryFailures ?? 0) + 1;
735
904
  if (attempt >= RECOVERY_ATTEMPT_LIMIT) {
736
905
  log("warn", `gave up delivering the reply to ${marker.messageId} after ${attempt} attempts (${reason}); told the owner instead`);
737
- await postOne(marker.target, UNDELIVERABLE_REPLY_TEXT, marker.rootEventId).catch(() => undefined);
906
+ if (marker.jobId) {
907
+ // Nobody asked, so "ask me again" is wrong. The header goes on only if
908
+ // no part of this run reached the chat yet.
909
+ const headerOut = (marker.progressPosted ?? 0) > 0 || (err instanceof PartialPostError ? err.delivered : from) > 0;
910
+ const text = marker.jobHeader && !headerOut ? `${marker.jobHeader}${UNDELIVERABLE_JOB_REPORT_TEXT}` : UNDELIVERABLE_JOB_REPORT_TEXT;
911
+ await postOne(marker.target, text, marker.rootEventId).catch(() => undefined);
912
+ // Giving up on DELIVERY is not giving up on the RUN: report it.
913
+ await reportRecoveredJobRun(marker);
914
+ }
915
+ else {
916
+ await postOne(marker.target, UNDELIVERABLE_REPLY_TEXT, marker.rootEventId).catch(() => undefined);
917
+ }
738
918
  await closeMarker(marker, isKickoff);
739
919
  return true;
740
920
  }
@@ -743,8 +923,11 @@ export async function runBot(deps) {
743
923
  log("warn", `could not tell the owner about unfinished ${marker.messageId} (attempt ${attempt}/${RECOVERY_ATTEMPT_LIMIT}): ${reason}`);
744
924
  return false;
745
925
  }
926
+ await reportRecoveredJobRun(marker);
746
927
  await closeMarker(marker, isKickoff);
747
- log("info", marker.reply ? `posted the saved reply for ${marker.messageId}` : `told the owner ${marker.messageId} did not finish (restart); not re-run`);
928
+ log("info", marker.reply
929
+ ? `posted the saved reply for ${marker.messageId}`
930
+ : `told the owner ${marker.messageId} did not finish (${marker.jobFailure ? marker.jobFailure.reason : "restart"}); not re-run`);
748
931
  return true;
749
932
  };
750
933
  /**
@@ -822,6 +1005,186 @@ export async function runBot(deps) {
822
1005
  await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
823
1006
  }
824
1007
  };
1008
+ /**
1009
+ * One fired job: a model turn on the job's own prompt, in the job's own
1010
+ * session, reported in the owner's DM under a "scheduled job" header, and
1011
+ * reported to the cloud exactly once.
1012
+ */
1013
+ const handleJobItem = async (item, target) => {
1014
+ const id = item.messageId;
1015
+ const { job, jobId } = target;
1016
+ const peerUid = target.peerUid;
1017
+ // An older pending run of this job: a newer one runs instead. Reported so
1018
+ // the cloud releases the slot and the history says what happened.
1019
+ if (supersededJobRuns.has(id)) {
1020
+ await completeJobRun(jobId, id, { status: "skipped-precondition", disposition: SUPERSEDED_DISPOSITION });
1021
+ markProcessed(dir, id, now().toISOString());
1022
+ await api.ackInbox(config.agentUid, id);
1023
+ supersededJobRuns.delete(id);
1024
+ log("info", `skipped older run ${id} of job ${jobId}: a newer pending run of the same job runs instead`);
1025
+ return;
1026
+ }
1027
+ const catchUp = isCatchUpRun(job, now());
1028
+ const attempt = (attempts.get(id) ?? 0) + 1;
1029
+ attempts.set(id, attempt);
1030
+ const tReceived = now().getTime();
1031
+ log("info", `received scheduled run ${id} of job ${jobId}${catchUp ? " (catch-up)" : ""}`);
1032
+ // The header goes on the first message of the turn, so the owner sees at a
1033
+ // glance that this is a scheduled run and not a reply to something they
1034
+ // said. It rides on the marker too, so a report posted by the recovery path
1035
+ // after a restart carries it as well.
1036
+ //
1037
+ // Every post goes through the SAME split/track path as any other reply
1038
+ // (trackingSend → splitBody), so a long report is delivered in parts rather
1039
+ // than refused whole. The header is only counted as delivered once a part
1040
+ // actually reached the chat, and `saved` records the body EXACTLY as it was
1041
+ // attempted (header included), so the parts the recovery path resends line
1042
+ // up with the parts already delivered and nothing is repeated or dropped.
1043
+ const header = jobReportHeader(job, catchUp, timeZone);
1044
+ const markerTarget = { kind: "dm", peerUid };
1045
+ const tracking = trackingSend(markerTarget);
1046
+ let headerOut = false;
1047
+ // What actually reached the chat, kept apart from "was anything posted".
1048
+ // `poster.postedCount` counts progress posts too, so a turn that said
1049
+ // "Checking the dashboards…" and then failed to post its REPORT would look
1050
+ // delivered — and for a scheduled run the report is the only thing the
1051
+ // owner sees; nobody will ask again.
1052
+ const deliveredBodies = [];
1053
+ let anySendFailed = false;
1054
+ /** The body of the LAST send, when that send failed (the resume point). */
1055
+ let lastFailedBody = null;
1056
+ const withHeader = (body) => (headerOut ? body : `${header}${body}`);
1057
+ const send = async (body) => {
1058
+ const full = withHeader(body);
1059
+ try {
1060
+ await tracking.send(full);
1061
+ headerOut = true;
1062
+ deliveredBodies.push(body);
1063
+ lastFailedBody = null;
1064
+ }
1065
+ catch (err) {
1066
+ anySendFailed = true;
1067
+ lastFailedBody = full;
1068
+ if (err instanceof PartialPostError && err.delivered > 0)
1069
+ headerOut = true;
1070
+ throw err;
1071
+ }
1072
+ };
1073
+ const normalize = (text) => text.replace(/\s+/g, " ").trim();
1074
+ /**
1075
+ * True only when the final report itself reached the chat in full. With no
1076
+ * failed post at all, the poster delivered everything it chose to (it skips
1077
+ * a final that repeats a progress post, so "no failures" is the reliable
1078
+ * signal). After any failure, the report must be inside a fully delivered
1079
+ * post. Erring towards resending: a duplicate beats a report that vanished.
1080
+ */
1081
+ const reportDelivered = (reply) => poster.postedCount > 0 && (!anySendFailed || deliveredBodies.some((b) => normalize(b).includes(normalize(reply))));
1082
+ /**
1083
+ * The report, saved for resending. When the LAST post was the report and
1084
+ * part of it already got through, resume that exact body at the parts
1085
+ * delivered, so nothing is repeated. Otherwise resend the report whole,
1086
+ * under its own header: the resend may come much later (after a restart)
1087
+ * and must still read as a scheduled run.
1088
+ */
1089
+ const savedReport = (reply) => {
1090
+ const jobOutput = jobRunOutput(reply);
1091
+ const failed = lastFailedBody;
1092
+ if (failed !== null && failed.trimEnd().endsWith(reply)) {
1093
+ const resumed = tracking.saved(failed);
1094
+ if ((resumed.replyPartsDelivered ?? 0) > 0)
1095
+ return { ...resumed, jobOutput };
1096
+ }
1097
+ return { ...tracking.saved(`${header}${reply}`), jobOutput };
1098
+ };
1099
+ /** Remember that this run's outcome is reported, before the ack. */
1100
+ const markReported = (status) => {
1101
+ patchInflight(dir, id, { jobCompleted: status });
1102
+ };
1103
+ const { outcome, poster } = await runTurnWithProgress({
1104
+ marker: { messageId: id, target: markerTarget, jobId, jobHeader: header, agentUid: config.agentUid },
1105
+ prompt: buildJobTurnPrompt(job, { now: now(), timeZone }),
1106
+ sessionScope: target.sessionScope,
1107
+ tReceived,
1108
+ send,
1109
+ // A scheduled run has nobody waiting in a chat: no "working on it" filler.
1110
+ workingNotice: false,
1111
+ });
1112
+ if (outcome.ok) {
1113
+ if (!reportDelivered(outcome.reply)) {
1114
+ // The work finished but the REPORT did not fully reach the DM — even if
1115
+ // a progress post did. Keep it (and the run un-reported and un-acked)
1116
+ // so the next poll delivers it, resuming at the parts already read,
1117
+ // before the run is completed. Never re-run.
1118
+ patchInflight(dir, id, savedReport(outcome.reply));
1119
+ log("warn", `report for run ${id} of job ${jobId} was not fully delivered; it is resent on the next poll`);
1120
+ return;
1121
+ }
1122
+ await completeJobRun(jobId, id, { status: "succeeded", output: jobRunOutput(outcome.reply) });
1123
+ markReported("succeeded");
1124
+ markProcessed(dir, id, now().toISOString());
1125
+ await api.ackInbox(config.agentUid, id);
1126
+ clearInflight(dir, id);
1127
+ attempts.delete(id);
1128
+ setStatus({ repliesSent: (readStatus()?.repliesSent ?? 0) + 1, lastActivityAt: now().toISOString() });
1129
+ log("info", `job ${jobId} run ${id} done (${outcome.reply.length} chars${catchUp ? ", catch-up" : ""})`);
1130
+ return;
1131
+ }
1132
+ if (!outcome.aborted && poster.postedCount === 0 && isRuntimeSignInFailure(outcome.excerpt)) {
1133
+ // Nothing ran: hold the run until the coding tool can sign in again. It
1134
+ // is neither acked nor reported succeeded — the work is still owed.
1135
+ clearInflight(dir, id);
1136
+ attempts.delete(id);
1137
+ markSignInBroken(outcome.excerpt);
1138
+ await tellSignInNeeded(`job:${jobId}`, (body) => sendToTarget(markerTarget, body));
1139
+ log("warn", `kept scheduled run ${id} of job ${jobId} until ${runtime.id} can sign in again`);
1140
+ return; // un-acked, un-reported: run once the sign-in works
1141
+ }
1142
+ if (!outcome.started) {
1143
+ // The model never ran, so nothing happened outside this Mac: retry.
1144
+ clearInflight(dir, id);
1145
+ const { excerpt } = recordTurnFailure(outcome.error, target.sessionScope);
1146
+ if (failures.exceeded(now().getTime())) {
1147
+ await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${excerpt}`);
1148
+ return;
1149
+ }
1150
+ if (attempt >= MAX_ATTEMPTS_PER_MESSAGE) {
1151
+ await send(`I couldn't run this one — ${runtime.id} kept failing: ${excerpt.slice(0, 160)}.`).catch(() => undefined);
1152
+ await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(excerpt), disposition: `${runtime.id} could not be started` });
1153
+ markReported("failed");
1154
+ markProcessed(dir, id, now().toISOString());
1155
+ await api.ackInbox(config.agentUid, id);
1156
+ attempts.delete(id);
1157
+ return;
1158
+ }
1159
+ await sleep(fullJitterDelayMs(failures.count(now().getTime()), { random: deps.random }));
1160
+ return; // un-acked; retried on the next poll
1161
+ }
1162
+ // The model started: it may already have done things, so never re-run.
1163
+ if (!outcome.aborted)
1164
+ recordTurnFailure(outcome.error, target.sessionScope);
1165
+ const reason = failureReason(outcome);
1166
+ // Saved BEFORE the post: if "did not finish" cannot be posted, recovery
1167
+ // reports and says this real reason, not "the bot was restarted".
1168
+ patchInflight(dir, id, { jobFailure: { reason, ...(outcome.excerpt ? { excerpt: outcome.excerpt } : {}) } });
1169
+ try {
1170
+ await send(didNotFinishText(reason, poster.postedCount > 0));
1171
+ }
1172
+ catch (err) {
1173
+ // Leave it un-acked with the marker: the next poll reports it.
1174
+ log("warn", `could not post "did not finish" for run ${id} of job ${jobId}: ${err instanceof Error ? err.message : String(err)}`);
1175
+ return;
1176
+ }
1177
+ await completeJobRun(jobId, id, { status: "failed", output: jobRunOutput(outcome.excerpt), disposition: reason.slice(0, 256) });
1178
+ markReported("failed");
1179
+ markProcessed(dir, id, now().toISOString());
1180
+ await api.ackInbox(config.agentUid, id);
1181
+ clearInflight(dir, id);
1182
+ attempts.delete(id);
1183
+ log("error", `scheduled run ${id} of job ${jobId} ${outcome.aborted ? "stopped by the owner" : "failed"} after it started; not re-run`);
1184
+ if (!outcome.aborted && failures.exceeded(now().getTime())) {
1185
+ await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
1186
+ }
1187
+ };
825
1188
  const handleItem = async (item) => {
826
1189
  const id = item.messageId;
827
1190
  const inboxState = readInboxState(dir);
@@ -841,9 +1204,26 @@ export async function runBot(deps) {
841
1204
  }
842
1205
  const target = resolveReplyTarget(item, config);
843
1206
  if (!target) {
1207
+ // A job item the bot cannot run. When it names a job, the cloud is still
1208
+ // holding that job's in-progress slot: acking alone would jam every later
1209
+ // fire of it for an hour, and the next fire would be just as unusable —
1210
+ // the same jam, forever. Report it so the slot is released.
1211
+ const unusableJobId = isJobItem(item) && typeof item.jobId === "string" ? item.jobId.trim() : "";
1212
+ if (unusableJobId) {
1213
+ await completeJobRun(unusableJobId, id, {
1214
+ status: "skipped-precondition",
1215
+ disposition: "the fired run carried no prompt, so there was nothing to run",
1216
+ });
1217
+ }
844
1218
  markProcessed(dir, id, now().toISOString());
845
1219
  await api.ackInbox(config.agentUid, id);
846
- log("info", `ignored inbox item ${id} (${item.channel || "dm"}${item.channelScope ? ` ${item.channelScope}` : ""}, not addressed to me)`);
1220
+ log("info", isJobItem(item)
1221
+ ? `ignored job item ${id}: it carries no ${unusableJobId ? "prompt" : "job id"}`
1222
+ : `ignored inbox item ${id} (${item.channel || "dm"}${item.channelScope ? ` ${item.channelScope}` : ""}, not addressed to me)`);
1223
+ return;
1224
+ }
1225
+ if (target.kind === "job") {
1226
+ await handleJobItem(item, target);
847
1227
  return;
848
1228
  }
849
1229
  const text = (item.text ?? "").trim();
@@ -909,6 +1289,7 @@ export async function runBot(deps) {
909
1289
  : {}),
910
1290
  });
911
1291
  if (outcome.ok) {
1292
+ // Known gap (#614, not changed here): a progress post counts, so a final answer that fails after one is not resent.
912
1293
  if (poster.postedCount === 0) {
913
1294
  // The work finished but nothing reached the chat: keep the answer —
914
1295
  // in the parts it will be posted as, and how many of them already got
@@ -1001,6 +1382,10 @@ export async function runBot(deps) {
1001
1382
  const pending = new Set();
1002
1383
  const running = new Set();
1003
1384
  const canStart = () => !stopping && state === "running" && !hasPromotionHold(dir);
1385
+ /** Job turns yield to people: one at a time, and never the last free slot. */
1386
+ const isJobLane = (lane) => lane.startsWith("job:");
1387
+ const jobLaneBudget = Math.max(1, maxParallelTurns - 1);
1388
+ const canStartJobLane = () => [...busyLanes].filter(isJobLane).length < MAX_PARALLEL_JOB_TURNS && busyLanes.size < jobLaneBudget;
1004
1389
  const pump = () => {
1005
1390
  for (const [lane, queue] of lanes) {
1006
1391
  if (busyLanes.size >= maxParallelTurns || !canStart())
@@ -1010,6 +1395,10 @@ export async function runBot(deps) {
1010
1395
  return;
1011
1396
  if (busyLanes.has(lane))
1012
1397
  continue;
1398
+ // `continue`, never `return`: a job that cannot start yet must not hold
1399
+ // up the owner's DM sitting behind it in the map.
1400
+ if (isJobLane(lane) && !canStartJobLane())
1401
+ continue;
1013
1402
  const item = queue.shift();
1014
1403
  if (queue.length === 0)
1015
1404
  lanes.delete(lane);
@@ -1067,10 +1456,17 @@ export async function runBot(deps) {
1067
1456
  log("error", `kickoff retry crashed: ${err instanceof Error ? err.message : String(err)}`);
1068
1457
  });
1069
1458
  }
1459
+ // Completions the cloud has not accepted yet: re-send the report (never
1460
+ // the work), alongside the poll rather than in front of it.
1461
+ kickCompletionRetries();
1070
1462
  try {
1071
1463
  const items = await api.pullInbox(config.agentUid);
1072
1464
  inboxFails = 0;
1073
1465
  setStatus({ lastInboxPollAt: now().toISOString(), lastInboxPollOk: true, lastInboxError: undefined });
1466
+ // One catch-up per job per wake: when several runs of the same job are
1467
+ // pending, the newest runs and the older ones are reported skipped.
1468
+ for (const id of planJobRuns(items, handledJobFires).superseded)
1469
+ supersededJobRuns.add(id);
1074
1470
  for (const item of items)
1075
1471
  enqueue(item);
1076
1472
  pump();
@@ -1093,6 +1489,7 @@ export async function runBot(deps) {
1093
1489
  // messages were never started or acked, so the inbox delivers them again.
1094
1490
  while (running.size > 0)
1095
1491
  await Promise.allSettled([...running]);
1492
+ await completionRetryTask;
1096
1493
  })();
1097
1494
  function readStatus() {
1098
1495
  return readBotStatus(dir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.135.0",
3
+ "version": "5.136.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {