@inetafrica/open-claudia 3.5.20 → 3.5.24
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/.env.example +8 -3
- package/CHANGELOG.md +37 -0
- package/README.md +5 -4
- package/core/actions.js +4 -0
- package/core/agent-browser-cli.js +98 -0
- package/core/auth-flow.js +7 -2
- package/core/browser-address.js +171 -0
- package/core/browser-control.js +113 -24
- package/core/browser-teach.js +59 -44
- package/core/browser-view.js +65 -4
- package/core/dream.js +132 -10
- package/core/handlers.js +16 -2
- package/core/limit-pickup.js +24 -20
- package/core/providers/claude.js +15 -8
- package/core/providers/env.js +17 -0
- package/core/providers/events.js +25 -1
- package/core/providers/utility-policy.js +31 -6
- package/core/runner.js +203 -10
- package/core/scheduler.js +57 -0
- package/core/state.js +14 -0
- package/core/system-prompt.js +15 -2
- package/core/turn-journal.js +55 -2
- package/core/usage-limits.js +110 -0
- package/core/utility-agent.js +109 -15
- package/package.json +2 -2
- package/test-fixtures/fake-agent-cli.js +46 -2
- package/test-provider-boot-matrix.js +6 -2
- package/test-provider-capabilities.js +1 -1
- package/test-provider-claude.js +7 -5
- package/test-provider-env-isolation.js +24 -0
- package/test-provider-events.js +43 -1
- package/test-provider-language.js +16 -1
- package/web.js +7 -2
package/core/providers/claude.js
CHANGED
|
@@ -18,15 +18,22 @@ const { createClaudeHookTransport } = require("./claude-hook");
|
|
|
18
18
|
// consulting this list, so `/model claude-opus-4-8` still works as a rollback.
|
|
19
19
|
// The list is consulted on tapped mb: callbacks (core/actions.js), whose job is
|
|
20
20
|
// to reject a stale button from an old message — that is the intended effect.
|
|
21
|
-
// claude-sonnet-
|
|
22
|
-
// utility work (see tierModel below), not just picker entries.
|
|
21
|
+
// claude-sonnet-5-5 and claude-haiku-5-5 STAY: they are the tier models for
|
|
22
|
+
// utility work (see tierModel below), not just picker entries. Haiku 5.5
|
|
23
|
+
// replaced Haiku 4.5 as the low tier on 9 Oct 2026 (Sumeet: "check if anything
|
|
24
|
+
// using haiku and have it shifted to the new haiku 5.5"), and Sonnet 5.5
|
|
25
|
+
// replaced Sonnet 4.6 as the medium tier the same day ("sonnet should also be
|
|
26
|
+
// just 5.5"); `/model claude-haiku-4-5-20251001` and `/model claude-sonnet-4-6`
|
|
27
|
+
// still work typed.
|
|
28
|
+
const CLAUDE_LOW_TIER_MODEL = "claude-haiku-5-5";
|
|
29
|
+
const CLAUDE_MEDIUM_TIER_MODEL = "claude-sonnet-5-5";
|
|
23
30
|
const CLAUDE_MODELS = Object.freeze([
|
|
24
31
|
"claude-fable-5-1",
|
|
25
32
|
"claude-fable-5",
|
|
26
33
|
"claude-opus-5-5",
|
|
27
34
|
"claude-opus-5",
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
CLAUDE_MEDIUM_TIER_MODEL,
|
|
36
|
+
CLAUDE_LOW_TIER_MODEL,
|
|
30
37
|
]);
|
|
31
38
|
const CLAUDE_EFFORT_VALUES = Object.freeze(["low", "medium", "high", "xhigh", "max"]);
|
|
32
39
|
// The default governs MAIN turns only: utility work is pinned by tier below
|
|
@@ -99,8 +106,8 @@ function appendModelAndLimits(args, context, defaultModel, utility) {
|
|
|
99
106
|
let model = context.model || settings.model || null;
|
|
100
107
|
if (!model && utility && context.tier) {
|
|
101
108
|
model = context.tier === "low"
|
|
102
|
-
?
|
|
103
|
-
: (context.tier === "medium" ?
|
|
109
|
+
? CLAUDE_LOW_TIER_MODEL
|
|
110
|
+
: (context.tier === "medium" ? CLAUDE_MEDIUM_TIER_MODEL : defaultModel);
|
|
104
111
|
}
|
|
105
112
|
args.push("--model", String(model || defaultModel));
|
|
106
113
|
|
|
@@ -311,8 +318,8 @@ function createClaudeProvider(options = {}) {
|
|
|
311
318
|
},
|
|
312
319
|
nativeToolsNote: "Claude Code exposes its native tools; Open Claudia's tool hook and approval policy remain authoritative.",
|
|
313
320
|
tierModel(tier) {
|
|
314
|
-
if (tier === "low") return
|
|
315
|
-
if (tier === "medium") return
|
|
321
|
+
if (tier === "low") return CLAUDE_LOW_TIER_MODEL;
|
|
322
|
+
if (tier === "medium") return CLAUDE_MEDIUM_TIER_MODEL;
|
|
316
323
|
return defaultModel;
|
|
317
324
|
},
|
|
318
325
|
};
|
package/core/providers/env.js
CHANGED
|
@@ -78,6 +78,21 @@ function pickStandardProcessEnv(source = process.env) {
|
|
|
78
78
|
return env;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// agent-browser's own settings (Chrome path and flags, the browser container's CDP port, pinned
|
|
82
|
+
// tabs, the download folder...). Every caller of agent-browser must share them: Open Claudia's
|
|
83
|
+
// Browser app and Teach me run it with this process's AGENT_BROWSER_*, and on a bot pod a session
|
|
84
|
+
// reached with different launch settings is relaunched and loses its page (with a browser
|
|
85
|
+
// container, an agent without AGENT_BROWSER_CDP would start its own Chrome instead). They configure
|
|
86
|
+
// a tool the agent runs, not a provider or the control plane, so the agent gets the same set.
|
|
87
|
+
const AGENT_BROWSER_KEY = /^AGENT_BROWSER_[A-Z0-9_]+$/;
|
|
88
|
+
|
|
89
|
+
function addAgentBrowserEnv(target, source = process.env) {
|
|
90
|
+
for (const key of Object.keys(source)) {
|
|
91
|
+
if (AGENT_BROWSER_KEY.test(key)) copyEnvValue(target, source, key);
|
|
92
|
+
}
|
|
93
|
+
return target;
|
|
94
|
+
}
|
|
95
|
+
|
|
81
96
|
function passthroughKeys(source = process.env, configured = source.AGENT_ENV_PASSTHROUGH) {
|
|
82
97
|
const keys = String(configured || "")
|
|
83
98
|
.split(",")
|
|
@@ -100,6 +115,7 @@ function addConfiguredPassthrough(target, source = process.env, configured = sou
|
|
|
100
115
|
|
|
101
116
|
function baseAgentEnv(source = process.env, options = {}) {
|
|
102
117
|
const env = pickStandardProcessEnv(source);
|
|
118
|
+
addAgentBrowserEnv(env, source);
|
|
103
119
|
addConfiguredPassthrough(env, source, options.passthrough);
|
|
104
120
|
// Strip after passthrough so a broad/operator-supplied list cannot re-open a
|
|
105
121
|
// provider credential, control-plane token, or keyring canary boundary.
|
|
@@ -141,6 +157,7 @@ module.exports = {
|
|
|
141
157
|
PROVIDER_CONFIG_ROOT_KEYS,
|
|
142
158
|
PROVIDER_CREDENTIAL_KEYS,
|
|
143
159
|
STANDARD_PROCESS_ENV_KEYS,
|
|
160
|
+
addAgentBrowserEnv,
|
|
144
161
|
addConfiguredPassthrough,
|
|
145
162
|
baseAgentEnv,
|
|
146
163
|
claudeAgentEnv,
|
package/core/providers/events.js
CHANGED
|
@@ -127,17 +127,40 @@ function createJsonlDecoder(options = {}) {
|
|
|
127
127
|
return api;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// What a provider's failure means, read in one place for chat turns
|
|
131
|
+
// (core/runner) and background calls (core/utility-agent):
|
|
132
|
+
// authError — signed out, or the sign-in died. Waiting does not fix it;
|
|
133
|
+
// /login does. Claude Code 2.1 words a dead refresh "Failed to
|
|
134
|
+
// authenticate: OAuth session expired and could not be
|
|
135
|
+
// refreshed"; Codex "Your authentication token has expired".
|
|
136
|
+
// usageLimit — a plan limit. Its reset fixes it.
|
|
137
|
+
// transient — Claude Code's refresh-lock race ("another Claude Code process
|
|
138
|
+
// is refreshing it or exited mid-refresh"): a later try works.
|
|
139
|
+
// Four fleet bots lost the 9 Oct 07:00 dream to it.
|
|
130
140
|
function providerErrorFlags(message) {
|
|
131
141
|
const text = String(message || "").toLowerCase();
|
|
142
|
+
const transient = /another claude code process is refreshing|exited mid-refresh/.test(text);
|
|
132
143
|
return {
|
|
133
|
-
authError: /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|invalid api key|no credentials|401/.test(text),
|
|
144
|
+
authError: !transient && /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|failed to authenticate|invalid api key|no credentials|401|oauth (?:session|access token|token) (?:expired|has expired|is invalid|is no longer valid)|could not be refreshed|refresh token is no longer valid|authentication token (?:has )?expired/.test(text),
|
|
134
145
|
// "You've hit your limit / session limit / weekly limit / Opus limit · resets …"
|
|
135
146
|
// is how Claude Code words a plan limit; the older patterns never matched it.
|
|
136
147
|
usageLimit: /usage limit|spend limit|monthly cycle|quota|rate limit|hit your (?:[a-z0-9'’-]+ ){0,3}limit/.test(text),
|
|
148
|
+
transient,
|
|
137
149
|
refusal: /refusals-and-fallback|usage policy|safety classifier|stop_reason['":\s]*refusal/.test(text),
|
|
138
150
|
};
|
|
139
151
|
}
|
|
140
152
|
|
|
153
|
+
// The error code of a failed run, from its normalized error event's flags.
|
|
154
|
+
// Chat turns pass PROVIDER_EVENT_ERROR as the fallback, background calls
|
|
155
|
+
// UTILITY_PROVIDER_ERROR, so both read a limit or a dead sign-in the same way.
|
|
156
|
+
function providerFailureCode(event, fallback = "PROVIDER_EVENT_ERROR") {
|
|
157
|
+
if (event?.refusal) return "PROVIDER_REFUSAL";
|
|
158
|
+
if (event?.authError) return "PROVIDER_UNAUTHENTICATED";
|
|
159
|
+
if (event?.usageLimit) return "PROVIDER_USAGE_LIMIT";
|
|
160
|
+
if (event?.transient) return "PROVIDER_TRANSIENT";
|
|
161
|
+
return fallback;
|
|
162
|
+
}
|
|
163
|
+
|
|
141
164
|
// The CLI flattens the API's stop_details into the error text; recover the
|
|
142
165
|
// human-readable pieces so the chat message can lead with the actual reason.
|
|
143
166
|
function refusalDetailsFromMessage(message) {
|
|
@@ -184,6 +207,7 @@ module.exports = {
|
|
|
184
207
|
normalizeDecoderError,
|
|
185
208
|
normalizedUsage,
|
|
186
209
|
providerErrorFlags,
|
|
210
|
+
providerFailureCode,
|
|
187
211
|
refusalDetailsFromMessage,
|
|
188
212
|
selectTerminalText,
|
|
189
213
|
usageFingerprint,
|
|
@@ -84,13 +84,29 @@ function parseProviderFallbacks(value) {
|
|
|
84
84
|
return result;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// The provider of work with a person behind it is that person's; work with
|
|
88
|
+
// nobody behind it (the dream, introspection, a check outside any chat) runs
|
|
89
|
+
// on the bot's own provider: the one its owner chats on (/backend and /login
|
|
90
|
+
// set it). It used to take the first provider that did not look signed out,
|
|
91
|
+
// so on 9 Oct 2026 every Codex bot's dream ran on a signed-out Claude.
|
|
92
|
+
function ownerActiveProvider() {
|
|
93
|
+
try {
|
|
94
|
+
const owner = require("../identity").ownerCanonical();
|
|
95
|
+
if (!owner) return null;
|
|
96
|
+
const state = require("../state");
|
|
97
|
+
return state.getActiveProvider(state.getUserState(owner));
|
|
98
|
+
} catch (_) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
function defaultActiveProvider(env = process.env) {
|
|
88
104
|
const inherited = String(env?.OC_PROVIDER || process.env.OC_PROVIDER || "").trim().toLowerCase();
|
|
89
105
|
if (PROVIDER_ID_SET.has(inherited)) return inherited;
|
|
106
|
+
let canonicalUserId = null;
|
|
107
|
+
try { canonicalUserId = require("../context").currentCanonicalUserId(); } catch (_) { canonicalUserId = null; }
|
|
108
|
+
if (!canonicalUserId) return ownerActiveProvider();
|
|
90
109
|
try {
|
|
91
|
-
const { currentCanonicalUserId } = require("../context");
|
|
92
|
-
const canonicalUserId = currentCanonicalUserId();
|
|
93
|
-
if (!canonicalUserId) return null;
|
|
94
110
|
const state = require("../state");
|
|
95
111
|
return state.getActiveProvider(state.getUserState(canonicalUserId));
|
|
96
112
|
} catch (_) {
|
|
@@ -98,6 +114,16 @@ function defaultActiveProvider(env = process.env) {
|
|
|
98
114
|
}
|
|
99
115
|
}
|
|
100
116
|
|
|
117
|
+
// With no active provider at all: the one a new chat starts on (DEFAULT_PROVIDER,
|
|
118
|
+
// else the first installed), as for a chat turn; then the first runnable.
|
|
119
|
+
function registryDefaultProvider(registry) {
|
|
120
|
+
try {
|
|
121
|
+
const selected = typeof registry.selectDefaultProvider === "function" ? registry.selectDefaultProvider()?.providerId : null;
|
|
122
|
+
if (selected && PROVIDER_ID_SET.has(selected) && runnableStatus(registry, selected).runnable) return selected;
|
|
123
|
+
} catch (_) { /* fall through */ }
|
|
124
|
+
return registryProviders(registry).find((providerId) => runnableStatus(registry, providerId).runnable) || null;
|
|
125
|
+
}
|
|
126
|
+
|
|
101
127
|
function registryProviders(registry) {
|
|
102
128
|
try {
|
|
103
129
|
const listed = registry.listProviders();
|
|
@@ -189,9 +215,7 @@ function createUtilityPolicy(options = {}) {
|
|
|
189
215
|
if (active) preferred = normalizeProvider(active);
|
|
190
216
|
else {
|
|
191
217
|
const globalDefault = envValue(env, "DEFAULT_PROVIDER");
|
|
192
|
-
preferred = globalDefault
|
|
193
|
-
? normalizeProvider(globalDefault)
|
|
194
|
-
: (registryProviders(registry).find((providerId) => runnableStatus(registry, providerId).runnable) || null);
|
|
218
|
+
preferred = globalDefault ? normalizeProvider(globalDefault) : registryDefaultProvider(registry);
|
|
195
219
|
}
|
|
196
220
|
}
|
|
197
221
|
if (!preferred) {
|
|
@@ -267,6 +291,7 @@ module.exports = {
|
|
|
267
291
|
UtilityPolicyError,
|
|
268
292
|
createUtilityPolicy,
|
|
269
293
|
defaultActiveProvider,
|
|
294
|
+
ownerActiveProvider,
|
|
270
295
|
exactModelOverride,
|
|
271
296
|
normalizePurpose,
|
|
272
297
|
normalizeTier,
|
package/core/runner.js
CHANGED
|
@@ -15,7 +15,7 @@ const {
|
|
|
15
15
|
const {
|
|
16
16
|
currentState, saveState, recordSession,
|
|
17
17
|
resetSessionUsage, admitLane, cancelTargets, getActiveProvider, getProjectKey,
|
|
18
|
-
laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes,
|
|
18
|
+
laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes, findQueued,
|
|
19
19
|
getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction, commitBranchCompaction,
|
|
20
20
|
resolveProviderSettings, getProjectSessions,
|
|
21
21
|
} = require("./state");
|
|
@@ -26,7 +26,7 @@ const usageLimits = require("./usage-limits");
|
|
|
26
26
|
const limitPickup = require("./limit-pickup");
|
|
27
27
|
const { getProvider, resolveProviderForRun } = require("./providers");
|
|
28
28
|
const { validateInvocation } = require("./providers/contract");
|
|
29
|
-
const { selectTerminalText } = require("./providers/events");
|
|
29
|
+
const { providerFailureCode, selectTerminalText } = require("./providers/events");
|
|
30
30
|
const {
|
|
31
31
|
bindRunInputs,
|
|
32
32
|
captureRunContext,
|
|
@@ -581,7 +581,7 @@ async function executeProviderInvocation(options = {}) {
|
|
|
581
581
|
const event = rawErrors[0];
|
|
582
582
|
failure = stableRunError(
|
|
583
583
|
event.message || "Provider reported an error",
|
|
584
|
-
|
|
584
|
+
providerFailureCode(event, "PROVIDER_EVENT_ERROR"),
|
|
585
585
|
event,
|
|
586
586
|
);
|
|
587
587
|
} else if (closeInfo.code !== 0) failure = stableRunError(`Provider process exited with code ${closeInfo.code}`, "PROVIDER_NONZERO_EXIT", { exitCode: closeInfo.code });
|
|
@@ -620,6 +620,9 @@ async function executeProviderInvocation(options = {}) {
|
|
|
620
620
|
delivery,
|
|
621
621
|
timings: { startedAt, finishedAt: null, durationMs: null },
|
|
622
622
|
};
|
|
623
|
+
// Every run, whatever its purpose, keeps the limit record current: a limit
|
|
624
|
+
// hit marks the provider used up until its reset, a run that worked clears it.
|
|
625
|
+
noteRunLimit(result, runContext);
|
|
623
626
|
|
|
624
627
|
const runStage = async (name, hook) => {
|
|
625
628
|
if (!hook) return;
|
|
@@ -1773,6 +1776,23 @@ function limitPlanFor(result, runContext) {
|
|
|
1773
1776
|
return keepOnResult(result, "limitPlan", plan);
|
|
1774
1777
|
}
|
|
1775
1778
|
|
|
1779
|
+
// The limit record (core/usage-limits.js) background work reads: a run a
|
|
1780
|
+
// limit stopped marks the provider used up until the reset; a run that
|
|
1781
|
+
// worked clears it. Never throws.
|
|
1782
|
+
function noteRunLimit(result, runContext) {
|
|
1783
|
+
try {
|
|
1784
|
+
const provider = runContext?.provider || result.provider;
|
|
1785
|
+
if (result.ok) { usageLimits.clearLimitBlock(provider, { model: runContext?.providerSettings?.model || null }); return; }
|
|
1786
|
+
if (result.error?.code !== "PROVIDER_USAGE_LIMIT") return;
|
|
1787
|
+
const hit = usageLimits.limitHitDetails({
|
|
1788
|
+
provider, error: result.error, rateLimit: result.rateLimit, windows: planLimitWindows(result, runContext || {}),
|
|
1789
|
+
});
|
|
1790
|
+
if (hit?.resetsAt) usageLimits.noteLimitHit({ provider, limitType: hit.limitType, resetsAt: hit.resetsAt });
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
console.warn(`[usage-limits] limit record: ${redactSensitive(String(error.message || error))}`);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1776
1796
|
// Remember each window's use; the warnings now due (80% / 95%, once per
|
|
1777
1797
|
// window). A run that cannot show them only updates the use, so the warning
|
|
1778
1798
|
// is still due on the next turn that can.
|
|
@@ -2637,16 +2657,18 @@ function cancelQueuedCallOnAbort(lane, item) {
|
|
|
2637
2657
|
|
|
2638
2658
|
// Admit waiting lanes into free slots: every lane with queued work and no run,
|
|
2639
2659
|
// oldest waiting message first, as far as the caps allow. An admitted lane runs
|
|
2640
|
-
//
|
|
2641
|
-
// captured context. Called whenever a
|
|
2642
|
-
//
|
|
2643
|
-
// slot ahead of a lane that was
|
|
2644
|
-
// considered (the global cap is
|
|
2660
|
+
// what waits in it as ONE turn: the person's queued messages together (see
|
|
2661
|
+
// takeQueueBatch), in the first message's captured context. Called whenever a
|
|
2662
|
+
// run, a compaction or a /stop frees a lane, and synchronous up to the
|
|
2663
|
+
// admission, so no newcomer can take a freed slot ahead of a lane that was
|
|
2664
|
+
// already waiting. Every state's lanes are considered (the global cap is
|
|
2665
|
+
// shared); the argument is kept for callers.
|
|
2645
2666
|
function drainQueuedMessages(_state) {
|
|
2646
2667
|
for (const { state, lane } of waitingLanes()) {
|
|
2647
2668
|
if (laneBusy(lane) || !lane.messageQueue.length) continue;
|
|
2648
2669
|
if (slotBlocker(state)) { lane.waitingForSlot = true; continue; }
|
|
2649
|
-
const
|
|
2670
|
+
const batch = takeQueueBatch(lane);
|
|
2671
|
+
const item = batch.length > 1 ? mergeQueueBatch(lane, batch) : batch[0];
|
|
2650
2672
|
item.disposeCallListener?.();
|
|
2651
2673
|
lane.preparingRun = true;
|
|
2652
2674
|
lane.waitingForSlot = false;
|
|
@@ -2666,6 +2688,163 @@ function queueInLane(lane, item) {
|
|
|
2666
2688
|
return item;
|
|
2667
2689
|
}
|
|
2668
2690
|
|
|
2691
|
+
// ── The queue: run together, remove one ────────────────────────────────
|
|
2692
|
+
// Messages a person sends while their conversation is busy used to run one
|
|
2693
|
+
// turn each, in series, every one answering without the others. Now, when the
|
|
2694
|
+
// lane frees, the queued messages run as one turn (QUEUE_BATCH=off restores
|
|
2695
|
+
// one at a time). Only plain chat messages from the same person, in the same
|
|
2696
|
+
// session, project, provider and model join; anything else (a scheduled run,
|
|
2697
|
+
// a side chat's follow-up, a limit pick-up, a live call) runs on its own, in
|
|
2698
|
+
// order. Up to QUEUE_BATCH_MAX (10) per turn.
|
|
2699
|
+
const QUEUE_BATCH_MAX = 10;
|
|
2700
|
+
function queueBatchOn() {
|
|
2701
|
+
return !/^(off|false|0|no)$/i.test(String(process.env.QUEUE_BATCH ?? config.QUEUE_BATCH ?? "on").trim());
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
function queuedSender(item) {
|
|
2705
|
+
return String(item?.store?.canonicalUserId || item?.runContext?.canonicalUserId || "");
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
function batchable(item) {
|
|
2709
|
+
const runContext = item?.runContext;
|
|
2710
|
+
return !!runContext
|
|
2711
|
+
&& !item.pendingSideChat
|
|
2712
|
+
&& !item.followMainRunId
|
|
2713
|
+
&& runContext.purpose === "foreground"
|
|
2714
|
+
&& !runContext.liveCall
|
|
2715
|
+
&& !runContext.limitPickup
|
|
2716
|
+
&& item.store?.adapter?.liveCall !== true;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
function sameBatch(a, b) {
|
|
2720
|
+
const x = a.runContext;
|
|
2721
|
+
const y = b.runContext;
|
|
2722
|
+
return !!queuedSender(a)
|
|
2723
|
+
&& queuedSender(a) === queuedSender(b)
|
|
2724
|
+
&& x.provider === y.provider
|
|
2725
|
+
&& (x.project?.dir || null) === (y.project?.dir || null)
|
|
2726
|
+
&& (x.sessionId || null) === (y.sessionId || null)
|
|
2727
|
+
&& (x.providerSettings?.model || null) === (y.providerSettings?.model || null);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
// The next turn's messages, taken off the front of the lane's queue.
|
|
2731
|
+
function takeQueueBatch(lane) {
|
|
2732
|
+
const first = lane.messageQueue.shift();
|
|
2733
|
+
const batch = [first];
|
|
2734
|
+
if (!queueBatchOn() || !batchable(first)) return batch;
|
|
2735
|
+
while (lane.messageQueue.length && batch.length < QUEUE_BATCH_MAX) {
|
|
2736
|
+
const next = lane.messageQueue[0];
|
|
2737
|
+
if (!batchable(next) || !sameBatch(first, next)) break;
|
|
2738
|
+
batch.push(lane.messageQueue.shift());
|
|
2739
|
+
}
|
|
2740
|
+
return batch;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
function queueClock(ms) {
|
|
2744
|
+
return new Date(ms).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
function batchPrompt(batch) {
|
|
2748
|
+
const n = batch.length;
|
|
2749
|
+
const parts = [`[${n} messages arrived while you were busy. Read them all, then reply once: answer each, and where a later one changes or cancels an earlier one, the later one wins.]`];
|
|
2750
|
+
batch.forEach((item, index) => {
|
|
2751
|
+
const at = Number(item.queuedAt);
|
|
2752
|
+
const when = Number.isFinite(at) && at > 0 ? ` (${queueClock(at)})` : "";
|
|
2753
|
+
parts.push(`--- Message ${index + 1} of ${n}${when} ---\n${item.runContext.userPrompt}`);
|
|
2754
|
+
});
|
|
2755
|
+
return parts.join("\n\n");
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
// One queue item for the whole batch: the first message's captured context
|
|
2759
|
+
// (its session and lineage), every message's text, images and files, and the
|
|
2760
|
+
// newest message as the one the reply answers. Every sender's caller gets the
|
|
2761
|
+
// one result; the journal keeps the batch as the first message's turn.
|
|
2762
|
+
function mergeQueueBatch(lane, batch) {
|
|
2763
|
+
const first = batch[0];
|
|
2764
|
+
const last = batch[batch.length - 1];
|
|
2765
|
+
for (const item of batch.slice(1)) {
|
|
2766
|
+
try { item.disposeCallListener?.(); } catch (_) {}
|
|
2767
|
+
}
|
|
2768
|
+
const runContext = deepFreeze({
|
|
2769
|
+
...bindRunInputs(first.runContext, {
|
|
2770
|
+
userPrompt: batchPrompt(batch),
|
|
2771
|
+
imagePaths: batch.flatMap((item) => item.runContext.imagePaths || []),
|
|
2772
|
+
attachments: batch.flatMap((item) => item.runContext.attachments || []),
|
|
2773
|
+
lastInputWasVoice: !!last.runContext.lastInputWasVoice,
|
|
2774
|
+
voiceStream: !!last.runContext.voiceStream,
|
|
2775
|
+
}),
|
|
2776
|
+
replyToMsgId: last.runContext.replyToMsgId ?? first.runContext.replyToMsgId ?? null,
|
|
2777
|
+
});
|
|
2778
|
+
try { turnJournal.dropQueued(lane.key, batch.slice(1).map((item) => item.runContext.runId).filter(Boolean)); } catch (_) {}
|
|
2779
|
+
const words = batch.map((item) => String(item.store?.inboundText || "").trim()).filter(Boolean);
|
|
2780
|
+
const store = last.store ? { ...last.store, inboundText: words.join("\n\n") } : first.store;
|
|
2781
|
+
console.log(`[queue] ${lane.key}: ${batch.length} queued messages run as one turn (run ${runContext.runId || "?"})`);
|
|
2782
|
+
return {
|
|
2783
|
+
runContext,
|
|
2784
|
+
store,
|
|
2785
|
+
queuedAt: first.queuedAt,
|
|
2786
|
+
seq: first.seq,
|
|
2787
|
+
text: batch.map((item) => item.text).filter(Boolean).join(" / "),
|
|
2788
|
+
deferred: {
|
|
2789
|
+
resolve: (result) => { for (const item of batch) item.deferred?.resolve(result); },
|
|
2790
|
+
reject: (error) => { for (const item of batch) item.deferred?.reject(error); },
|
|
2791
|
+
},
|
|
2792
|
+
};
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// The ✕ on a "Queued." notice takes that one message out of its queue
|
|
2796
|
+
// (callback "qx:<boot>:<seq>"). The boot id keeps a button from before a
|
|
2797
|
+
// restart from naming a message that got the same number since.
|
|
2798
|
+
const QUEUE_BOOT = crypto.randomBytes(3).toString("hex");
|
|
2799
|
+
|
|
2800
|
+
function queueRemoveKeyboard(item) {
|
|
2801
|
+
if (!item?.runContext || item.runContext.liveCall || !Number.isSafeInteger(item.seq)) return null;
|
|
2802
|
+
return { inline_keyboard: [[{ text: "✕ Remove from queue", callback_data: `qx:${QUEUE_BOOT}:${item.seq}` }]] };
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
function queuedWords(item) {
|
|
2806
|
+
const text = String(item?.text || item?.runContext?.userPrompt || "").replace(/\s+/g, " ").trim();
|
|
2807
|
+
return text.length > 40 ? `${text.slice(0, 39)}…` : (text || "…");
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// Only the person whose message it is, or the owner, in the chat it was sent in.
|
|
2811
|
+
function mayRemoveQueued(envelope, item, opts = {}) {
|
|
2812
|
+
const channelId = item.store?.channelId || item.runContext?.channelId || "";
|
|
2813
|
+
if (String(envelope?.channelId || "") !== String(channelId)) return false;
|
|
2814
|
+
if (opts.isOwner === true) return true;
|
|
2815
|
+
const sender = queuedSender(item);
|
|
2816
|
+
return !!sender && String(envelope?.canonicalUserId || "") === sender;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
async function handleQueueRemove(data, envelope, opts = {}) {
|
|
2820
|
+
const [, boot, seqText] = String(data || "").split(":");
|
|
2821
|
+
const seq = Number(seqText);
|
|
2822
|
+
const noticeId = envelope?.action?.sourceMessageId || null;
|
|
2823
|
+
const answer = (text) => (noticeId ? editMessage(String(noticeId), text) : send(text));
|
|
2824
|
+
const hit = boot === QUEUE_BOOT && Number.isSafeInteger(seq) ? findQueued((item) => item.seq === seq) : null;
|
|
2825
|
+
if (!hit) return answer("No longer queued: it already started (or was dropped). /stop cancels a running turn.");
|
|
2826
|
+
const { lane, item } = hit;
|
|
2827
|
+
if (!mayRemoveQueued(envelope, item, opts)) return send("That button belongs to another person.");
|
|
2828
|
+
const index = lane.messageQueue.indexOf(item);
|
|
2829
|
+
if (index >= 0) lane.messageQueue.splice(index, 1);
|
|
2830
|
+
try { item.disposeCallListener?.(); } catch (_) {}
|
|
2831
|
+
try { turnJournal.dropQueued(lane.key, [item.runContext?.runId].filter(Boolean)); } catch (_) {}
|
|
2832
|
+
if (!lane.messageQueue.length) {
|
|
2833
|
+
lane.waitingForSlot = false;
|
|
2834
|
+
if (!laneBusy(lane)) stopLaneTyping(lane);
|
|
2835
|
+
}
|
|
2836
|
+
item.deferred?.resolve({
|
|
2837
|
+
ok: false,
|
|
2838
|
+
status: "cancelled",
|
|
2839
|
+
runId: item.runContext?.runId || null,
|
|
2840
|
+
provider: item.runContext?.provider || null,
|
|
2841
|
+
project: item.runContext?.project || null,
|
|
2842
|
+
error: { code: "QUEUE_REMOVED", message: "Removed from the queue" },
|
|
2843
|
+
});
|
|
2844
|
+
console.log(`[queue] ${lane.key}: removed queued message ${seq} (${lane.messageQueue.length} left)`);
|
|
2845
|
+
return answer(`Removed from the queue: "${queuedWords(item)}"`);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2669
2848
|
// A refusal on a RESUMED session is almost always the safety classifier
|
|
2670
2849
|
// rejecting flagged material in the old context — which every resume re-sends
|
|
2671
2850
|
// — not the user's new message. Recovery: quarantine the poisoned session so
|
|
@@ -2781,6 +2960,16 @@ async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock,
|
|
|
2781
2960
|
const persistCapture = executionOptions.persistCapture !== false;
|
|
2782
2961
|
const internalCapture = capture && !persistCapture;
|
|
2783
2962
|
if (tracksMainRun) lane.activeRunContext = runContext;
|
|
2963
|
+
// The turn after a /stop: the stopped request is in the session's history
|
|
2964
|
+
// looking cut off, so this turn opens with a note that it was cancelled.
|
|
2965
|
+
if (tracksMainRun && runContext.purpose === "foreground" && !runContext.liveCall) {
|
|
2966
|
+
const stopped = turnJournal.takeStopped(runLaneKey(runContext));
|
|
2967
|
+
if (stopped) {
|
|
2968
|
+
const noted = bindRunInputs(runContext, { userPrompt: `${turnJournal.stoppedNote(stopped)}\n\n${runContext.userPrompt}` });
|
|
2969
|
+
if (lane.activeRunContext === runContext) lane.activeRunContext = noted;
|
|
2970
|
+
runContext = noted;
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2784
2973
|
// Turn journal "lock": a foreground chat turn that a restart cuts short is
|
|
2785
2974
|
// resumed after boot (core/turn-journal).
|
|
2786
2975
|
if (tracksMainRun) turnJournal.begin(runContext);
|
|
@@ -3010,7 +3199,8 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
3010
3199
|
const notice = admission.reason === "busy"
|
|
3011
3200
|
? (lane.isCompacting ? "Compacting context, will pick this up next…" : queuedNotice(lane.activeRunContext))
|
|
3012
3201
|
: waitingNotice(admission.running);
|
|
3013
|
-
|
|
3202
|
+
const keyboard = queueRemoveKeyboard(item);
|
|
3203
|
+
send(notice, keyboard ? { replyTo: replyToMsgId, keyboard } : { replyTo: replyToMsgId }).catch(() => {});
|
|
3014
3204
|
}
|
|
3015
3205
|
return deferred.promise;
|
|
3016
3206
|
}
|
|
@@ -3110,6 +3300,9 @@ function runAgentSilent(prompt, cwd, label, opts = {}) {
|
|
|
3110
3300
|
|
|
3111
3301
|
module.exports = {
|
|
3112
3302
|
queuedNotice,
|
|
3303
|
+
handleQueueRemove,
|
|
3304
|
+
takeQueueBatch,
|
|
3305
|
+
mergeQueueBatch,
|
|
3113
3306
|
waitingNotice,
|
|
3114
3307
|
deliverForegroundResult,
|
|
3115
3308
|
drainQueuedMessages,
|
package/core/scheduler.js
CHANGED
|
@@ -44,6 +44,10 @@ const { providerStatus: defaultProviderStatus } = require("./providers");
|
|
|
44
44
|
const { redactSensitive } = require("./redact");
|
|
45
45
|
|
|
46
46
|
const MISSED_WAKEUP_GRACE_MS = 60 * 60 * 1000;
|
|
47
|
+
// A new occurrence starts with no usage-limit wait behind it.
|
|
48
|
+
const LIMIT_WAIT_CLEARED = Object.freeze({
|
|
49
|
+
limitWaitCount: 0, limitWaitUntil: null, limitWaitType: null, limitWaitProvider: null, limitFirstHitAt: null,
|
|
50
|
+
});
|
|
47
51
|
const RETRY_DELAY_MS = 30 * 1000;
|
|
48
52
|
const BUSY_DELAY_MS = 30 * 1000;
|
|
49
53
|
// A watch alarm that has waited this long behind a busy room says so in the
|
|
@@ -247,6 +251,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
247
251
|
? (dependencies.jobsStore ? null : CRONS_FILE)
|
|
248
252
|
: dependencies.legacyCronsFile;
|
|
249
253
|
const runInProvider = dependencies.runAgent || ((...args) => require("./runner").runAgent(...args));
|
|
254
|
+
// What a run a usage limit stopped does (core/limit-pickup.js planFor): the
|
|
255
|
+
// same rule and wording as a chat message the limit stopped.
|
|
256
|
+
const planLimit = dependencies.planLimit || ((...args) => require("./limit-pickup").planFor(...args));
|
|
250
257
|
const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
|
|
251
258
|
const projectSessions = dependencies.getProjectSessions || getProjectSessions;
|
|
252
259
|
const timers = new Map();
|
|
@@ -510,6 +517,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
510
517
|
}
|
|
511
518
|
const polled = job.check ? pendingPollNote(job) : null;
|
|
512
519
|
if (polled) lines.push(polled);
|
|
520
|
+
if (Number(job.limitWaitCount) > 0 && Number.isFinite(Number(job.limitFirstHitAt))) {
|
|
521
|
+
lines.push(`[A usage limit stopped this run at ${new Date(Number(job.limitFirstHitAt)).toISOString()}; it waited for the reset and runs now. Check what still applies before acting.]`);
|
|
522
|
+
}
|
|
513
523
|
if (target.fallbackFrom) {
|
|
514
524
|
lines.push(`[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`);
|
|
515
525
|
} else if (target.revertedFrom) {
|
|
@@ -653,6 +663,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
653
663
|
watchLastHitAt: null,
|
|
654
664
|
watchLastClearAt: null,
|
|
655
665
|
advisoryOccurrenceId: null,
|
|
666
|
+
...LIMIT_WAIT_CLEARED,
|
|
656
667
|
};
|
|
657
668
|
const advanced = jobsStore.update(job.id, advance) || { ...latest, ...advance };
|
|
658
669
|
unscheduleJob(job.id);
|
|
@@ -705,6 +716,41 @@ function createSchedulerService(dependencies = {}) {
|
|
|
705
716
|
return { ok: false, deferred: true, status: updated.status, job: updated };
|
|
706
717
|
}
|
|
707
718
|
|
|
719
|
+
// The usage-limit plan of a failed run, or null (the runner keeps it on the
|
|
720
|
+
// result; a run that never reached delivery is planned here).
|
|
721
|
+
function limitPlanOf(result, runContext) {
|
|
722
|
+
if (result?.error?.code !== "PROVIDER_USAGE_LIMIT") return null;
|
|
723
|
+
if (result.limitPlan !== undefined) return result.limitPlan;
|
|
724
|
+
try { return planLimit(result, runContext, { now: now() }); }
|
|
725
|
+
catch (_) { return null; }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// A usage limit is not a failed attempt: the occurrence waits for the reset,
|
|
729
|
+
// as a chat message does (core/limit-pickup.js), and never moves to another
|
|
730
|
+
// provider (it may not be signed in, or be used up too). The notice was
|
|
731
|
+
// posted with the run's result.
|
|
732
|
+
function markLimitWait(job, plan, result) {
|
|
733
|
+
const latest = jobsStore.get(job.id) || job;
|
|
734
|
+
const patch = {
|
|
735
|
+
status: "deferred",
|
|
736
|
+
disabled: false,
|
|
737
|
+
attemptCount: Math.max(0, (Number(latest.attemptCount) || 1) - 1),
|
|
738
|
+
nextAttemptAt: plan.runAt,
|
|
739
|
+
lastFireAt: now(),
|
|
740
|
+
lastFireOk: false,
|
|
741
|
+
lastError: stableFailure(result),
|
|
742
|
+
limitWaitCount: (Number(latest.limitWaitCount) || 0) + 1,
|
|
743
|
+
limitWaitUntil: plan.runAt,
|
|
744
|
+
limitWaitType: plan.hit?.limitType || null,
|
|
745
|
+
limitWaitProvider: plan.hit?.provider || null,
|
|
746
|
+
limitFirstHitAt: Number(latest.limitFirstHitAt) || now(),
|
|
747
|
+
};
|
|
748
|
+
const updated = jobsStore.update(job.id, patch) || { ...latest, ...patch };
|
|
749
|
+
armRetry(updated);
|
|
750
|
+
console.log(`[scheduler] ${job.id}: ${patch.limitWaitProvider || "provider"} ${patch.limitWaitType || "usage limit"} used up; waits until ${new Date(plan.runAt).toISOString()} (wait ${patch.limitWaitCount})`);
|
|
751
|
+
return { ok: false, status: "deferred", limitWait: true, runAt: plan.runAt, job: updated, result };
|
|
752
|
+
}
|
|
753
|
+
|
|
708
754
|
async function settleSuccess(job, result, target) {
|
|
709
755
|
const successPatch = {
|
|
710
756
|
status: "succeeded",
|
|
@@ -759,6 +805,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
759
805
|
watchLastHitAt: null,
|
|
760
806
|
watchLastClearAt: null,
|
|
761
807
|
advisoryOccurrenceId: null,
|
|
808
|
+
...LIMIT_WAIT_CLEARED,
|
|
762
809
|
});
|
|
763
810
|
if (reset && timers.get(job.id)?.timeout) scheduleJob(reset);
|
|
764
811
|
return reset
|
|
@@ -909,6 +956,11 @@ function createSchedulerService(dependencies = {}) {
|
|
|
909
956
|
occurrenceId: job.occurrenceId,
|
|
910
957
|
scheduledJobId: job.id,
|
|
911
958
|
originRunId: job.originRunId || null,
|
|
959
|
+
// A run that waited for a limit's reset: a new hit says "still used up"
|
|
960
|
+
// and counts towards the most waits (limit-pickup MAX_ATTEMPTS).
|
|
961
|
+
...(Number(job.limitWaitCount) > 0 ? {
|
|
962
|
+
limitPickup: { attempts: Number(job.limitWaitCount), firstHitAt: Number(job.limitFirstHitAt) || null, limitType: job.limitWaitType || null },
|
|
963
|
+
} : {}),
|
|
912
964
|
};
|
|
913
965
|
// Admission is synchronous and precedes all notification delivery. A
|
|
914
966
|
// refusal is a failed attempt, never a rejection that drops the job.
|
|
@@ -978,6 +1030,11 @@ function createSchedulerService(dependencies = {}) {
|
|
|
978
1030
|
catch (persistError) { return keepRetrying(running, persistError); }
|
|
979
1031
|
}
|
|
980
1032
|
if (!terminalRunSucceeded(result)) {
|
|
1033
|
+
const limitPlan = limitPlanOf(result, runContext);
|
|
1034
|
+
if (limitPlan?.waits && Number.isFinite(limitPlan.runAt)) {
|
|
1035
|
+
try { return markLimitWait(running, limitPlan, result); }
|
|
1036
|
+
catch (error) { return { ...keepRetrying(running, error), result }; }
|
|
1037
|
+
}
|
|
981
1038
|
try { return markFailure(running, result, { attemptAlreadyCounted: true }); }
|
|
982
1039
|
catch (error) { return { ...keepRetrying(running, error), result }; }
|
|
983
1040
|
}
|
package/core/state.js
CHANGED
|
@@ -1045,6 +1045,19 @@ function waitingLanes() {
|
|
|
1045
1045
|
});
|
|
1046
1046
|
}
|
|
1047
1047
|
|
|
1048
|
+
// A queued message anywhere (every person's state, every conversation), for
|
|
1049
|
+
// the ✕ on its "Queued." notice. Returns { state, lane, item } or null.
|
|
1050
|
+
function findQueued(predicate) {
|
|
1051
|
+
for (const state of statesWithLanes()) {
|
|
1052
|
+
if (!(state.lanes instanceof Map)) continue;
|
|
1053
|
+
for (const lane of state.lanes.values()) {
|
|
1054
|
+
const item = lane.messageQueue.find(predicate);
|
|
1055
|
+
if (item) return { state, lane, item };
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1048
1061
|
// /stop arms lane.cancelRequested with the id of the run it stops (true only
|
|
1049
1062
|
// when no run context is known), and a run acts on the flag only when it names
|
|
1050
1063
|
// that run. A bare boolean let a stop outlive its run and cancel the next one.
|
|
@@ -1898,6 +1911,7 @@ module.exports = {
|
|
|
1898
1911
|
runningLanes,
|
|
1899
1912
|
slotBlocker,
|
|
1900
1913
|
waitingLanes,
|
|
1914
|
+
findQueued,
|
|
1901
1915
|
pruneIdleLanes,
|
|
1902
1916
|
maxParallelTurns,
|
|
1903
1917
|
maxParallelTurnsGlobal,
|