@indigoai-us/hq-cli 5.134.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.
- package/CHANGELOG.md +28 -0
- package/dist/command-catalog.generated.d.ts +71 -0
- package/dist/command-catalog.generated.js +93 -0
- package/dist/commands/bot-jobs.d.ts +127 -0
- package/dist/commands/bot-jobs.js +445 -0
- package/dist/commands/bot.js +3 -0
- package/dist/commands/lanes.d.ts +4 -2
- package/dist/commands/lanes.js +150 -2
- package/dist/lib/bot/api.d.ts +66 -0
- package/dist/lib/bot/api.js +38 -0
- package/dist/lib/bot/index.d.ts +3 -0
- package/dist/lib/bot/index.js +3 -0
- package/dist/lib/bot/inflight.d.ts +42 -0
- package/dist/lib/bot/job-completions.d.ts +91 -0
- package/dist/lib/bot/job-completions.js +132 -0
- package/dist/lib/bot/job-run.d.ts +109 -0
- package/dist/lib/bot/job-run.js +246 -0
- package/dist/lib/bot/paths.d.ts +9 -1
- package/dist/lib/bot/paths.js +14 -0
- package/dist/lib/bot/prompt.d.ts +12 -0
- package/dist/lib/bot/prompt.js +31 -0
- package/dist/lib/bot/room-policy.d.ts +18 -1
- package/dist/lib/bot/room-policy.js +20 -2
- package/dist/lib/bot/run.d.ts +23 -1
- package/dist/lib/bot/run.js +404 -7
- package/dist/lib/lanes/config.d.ts +7 -0
- package/dist/lib/lanes/config.js +28 -1
- package/dist/lib/lanes/index.d.ts +5 -1
- package/dist/lib/lanes/index.js +3 -1
- package/dist/lib/lanes/monitor.d.ts +48 -0
- package/dist/lib/lanes/monitor.js +409 -0
- package/dist/lib/lanes/spawn.d.ts +10 -1
- package/dist/lib/lanes/spawn.js +60 -5
- package/dist/lib/lanes/types.d.ts +6 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watch.d.ts +22 -0
- package/dist/lib/lanes/watch.js +122 -0
- package/package.json +1 -1
package/dist/lib/bot/run.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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",
|
|
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);
|
|
@@ -18,6 +18,11 @@ export declare const DEFAULT_MIN_AVAILABLE_MEM_MB = 1024;
|
|
|
18
18
|
* run compact". Override per provider with `compact_after_ms`.
|
|
19
19
|
*/
|
|
20
20
|
export declare const DEFAULT_COMPACT_AFTER_MS: number;
|
|
21
|
+
/**
|
|
22
|
+
* Claude Code's auto-compaction window, in tokens. Override with
|
|
23
|
+
* `providers.claude.auto_compact_tokens`.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_AUTO_COMPACT_TOKENS = 200000;
|
|
21
26
|
/**
|
|
22
27
|
* Watcher `lane_progress` throttle: at most one thread event per five minutes
|
|
23
28
|
* per lane, and only when the tick saw new activity. Override per provider
|
|
@@ -66,6 +71,8 @@ export interface ConfigWriteDeps {
|
|
|
66
71
|
export declare function writeLanesConfig(hqRoot: string, config: LanesConfig, deps?: ConfigWriteDeps): void;
|
|
67
72
|
/** Resume-time compaction threshold for a provider, defaulting to one hour. */
|
|
68
73
|
export declare function providerCompactAfterMs(provider: ProviderConfig): number;
|
|
74
|
+
/** Claude Code auto-compaction threshold, defaulting to 200,000 tokens. */
|
|
75
|
+
export declare function providerAutoCompactTokens(provider: ProviderConfig): number;
|
|
69
76
|
/** Minimum gap between two `lane_progress` events, defaulting to five minutes. */
|
|
70
77
|
export declare function providerProgressEventMinIntervalMs(provider: ProviderConfig): number;
|
|
71
78
|
/** Hold for a reset-less 429 on this provider's accounts. */
|
package/dist/lib/lanes/config.js
CHANGED
|
@@ -22,6 +22,11 @@ export const DEFAULT_MIN_AVAILABLE_MEM_MB = 1024;
|
|
|
22
22
|
* run compact". Override per provider with `compact_after_ms`.
|
|
23
23
|
*/
|
|
24
24
|
export const DEFAULT_COMPACT_AFTER_MS = 60 * 60 * 1000;
|
|
25
|
+
/**
|
|
26
|
+
* Claude Code's auto-compaction window, in tokens. Override with
|
|
27
|
+
* `providers.claude.auto_compact_tokens`.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_AUTO_COMPACT_TOKENS = 200_000;
|
|
25
30
|
/**
|
|
26
31
|
* Watcher `lane_progress` throttle: at most one thread event per five minutes
|
|
27
32
|
* per lane, and only when the tick saw new activity. Override per provider
|
|
@@ -159,6 +164,15 @@ function nonEmptyModel(value, field) {
|
|
|
159
164
|
}
|
|
160
165
|
return value.trim();
|
|
161
166
|
}
|
|
167
|
+
function parseFastModeModels(value, field) {
|
|
168
|
+
if (value === undefined)
|
|
169
|
+
return [];
|
|
170
|
+
if (!Array.isArray(value) ||
|
|
171
|
+
value.some((model) => typeof model !== "string" || model.trim().length === 0)) {
|
|
172
|
+
throw new LanesConfigError("invalid_fast_mode_models", `${field} must be an array of non-empty strings`, { field, value });
|
|
173
|
+
}
|
|
174
|
+
return value.map((model) => model.trim());
|
|
175
|
+
}
|
|
162
176
|
function rejectDuplicateHomes(provider, accounts) {
|
|
163
177
|
const seen = new Map();
|
|
164
178
|
for (const [id, account] of Object.entries(accounts)) {
|
|
@@ -219,6 +233,9 @@ function parseProvider(provider, raw, deps) {
|
|
|
219
233
|
max_queued: positiveInt(raw.max_queued, `providers.${provider}.max_queued`, DEFAULT_MAX_QUEUED),
|
|
220
234
|
accounts,
|
|
221
235
|
};
|
|
236
|
+
if (provider === "codex") {
|
|
237
|
+
parsed.fast_mode_models = parseFastModeModels(raw.fast_mode_models, `providers.${provider}.fast_mode_models`);
|
|
238
|
+
}
|
|
222
239
|
if (defaultAccount !== undefined)
|
|
223
240
|
parsed.default_account = defaultAccount;
|
|
224
241
|
if (raw.model !== undefined) {
|
|
@@ -230,6 +247,9 @@ function parseProvider(provider, raw, deps) {
|
|
|
230
247
|
if (raw.compact_after_ms !== undefined) {
|
|
231
248
|
parsed.compact_after_ms = positiveInt(raw.compact_after_ms, `providers.${provider}.compact_after_ms`);
|
|
232
249
|
}
|
|
250
|
+
if (raw.auto_compact_tokens !== undefined) {
|
|
251
|
+
parsed.auto_compact_tokens = positiveInt(raw.auto_compact_tokens, `providers.${provider}.auto_compact_tokens`);
|
|
252
|
+
}
|
|
233
253
|
if (raw.progress_event_min_interval_ms !== undefined) {
|
|
234
254
|
parsed.progress_event_min_interval_ms = positiveInt(raw.progress_event_min_interval_ms, `providers.${provider}.progress_event_min_interval_ms`);
|
|
235
255
|
}
|
|
@@ -277,11 +297,14 @@ function parseLiveness(raw) {
|
|
|
277
297
|
export function documentedDefaultConfig(deps = defaultDeps()) {
|
|
278
298
|
const providers = {};
|
|
279
299
|
for (const provider of LANE_PROVIDERS) {
|
|
280
|
-
|
|
300
|
+
const providerConfig = {
|
|
281
301
|
max_running: DEFAULT_PROVIDER_MAX_RUNNING[provider],
|
|
282
302
|
max_queued: DEFAULT_MAX_QUEUED,
|
|
283
303
|
accounts: defaultAccountMap(provider, deps),
|
|
284
304
|
};
|
|
305
|
+
if (provider === "codex")
|
|
306
|
+
providerConfig.fast_mode_models = [];
|
|
307
|
+
providers[provider] = providerConfig;
|
|
285
308
|
}
|
|
286
309
|
return {
|
|
287
310
|
schema_version: CONFIG_SCHEMA_VERSION,
|
|
@@ -399,6 +422,10 @@ export function writeLanesConfig(hqRoot, config, deps = defaultWriteDeps()) {
|
|
|
399
422
|
export function providerCompactAfterMs(provider) {
|
|
400
423
|
return provider.compact_after_ms ?? DEFAULT_COMPACT_AFTER_MS;
|
|
401
424
|
}
|
|
425
|
+
/** Claude Code auto-compaction threshold, defaulting to 200,000 tokens. */
|
|
426
|
+
export function providerAutoCompactTokens(provider) {
|
|
427
|
+
return provider.auto_compact_tokens ?? DEFAULT_AUTO_COMPACT_TOKENS;
|
|
428
|
+
}
|
|
402
429
|
/** Minimum gap between two `lane_progress` events, defaulting to five minutes. */
|
|
403
430
|
export function providerProgressEventMinIntervalMs(provider) {
|
|
404
431
|
return provider.progress_event_min_interval_ms ?? DEFAULT_PROGRESS_EVENT_MIN_INTERVAL_MS;
|