@inetafrica/open-claudia 3.5.1 → 3.5.4
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 +15 -0
- package/bin/cli.js +13 -5
- package/bin/loopback-client.js +4 -0
- package/bin/schedule.js +7 -2
- package/bot.js +6 -2
- package/channels/kazee/adapter.js +462 -64
- package/channels/kazee/state.js +78 -2
- package/channels/types.js +11 -5
- package/core/actions.js +6 -0
- package/core/approvals.js +9 -0
- package/core/context.js +11 -2
- package/core/handlers.js +161 -59
- package/core/identity.js +33 -3
- package/core/io.js +21 -8
- package/core/jobs.js +3 -0
- package/core/loopback.js +55 -7
- package/core/providers/claude.js +6 -2
- package/core/router.js +25 -13
- package/core/run-context.js +49 -8
- package/core/runner.js +302 -120
- package/core/scheduler.js +45 -3
- package/core/side-chat.js +40 -11
- package/core/speaker-attest.js +2 -0
- package/core/state.js +317 -50
- package/core/system-prompt.js +61 -14
- package/core/transcripts.js +23 -6
- package/package.json +3 -3
- package/test-approval-cards.js +24 -0
- package/test-call-speech.js +5 -3
- package/test-fixtures/fake-agent-cli.js +52 -1
- package/test-provider-compaction.js +50 -11
- package/test-provider-gateway.js +31 -10
- package/test-provider-language.js +10 -1
- package/test-provider-scheduler.js +77 -20
- package/test-provider-session-commands.js +11 -2
- package/test-provider-side-chat.js +30 -2
- package/test-run-lock.js +122 -39
- package/test-thread-lanes.js +530 -0
- package/core/queue-drain.js +0 -25
- package/test-queue-routing.js +0 -52
package/core/runner.js
CHANGED
|
@@ -13,11 +13,12 @@ const {
|
|
|
13
13
|
} = require("./config");
|
|
14
14
|
const {
|
|
15
15
|
currentState, saveState, recordSession,
|
|
16
|
-
resetSessionUsage,
|
|
16
|
+
resetSessionUsage, admitLane, cancelTargets, getActiveProvider, getProjectKey,
|
|
17
|
+
laneFor, laneForRun, peekLane, laneBusy, runLaneKey, waitingLanes, slotBlocker, pruneIdleLanes,
|
|
17
18
|
getProviderSession, setProviderSession, clearProviderSession, quarantineProviderSession, getUsageForSession, commitProviderCompaction,
|
|
18
19
|
resolveProviderSettings,
|
|
19
20
|
} = require("./state");
|
|
20
|
-
const { chatContext, currentChannelId, currentAdapter } = require("./context");
|
|
21
|
+
const { chatContext, currentChannelId, currentAdapter, currentThreadId } = require("./context");
|
|
21
22
|
const { buildTurnPrompt } = require("./system-prompt");
|
|
22
23
|
const { getProvider, resolveProviderForRun } = require("./providers");
|
|
23
24
|
const { validateInvocation } = require("./providers/contract");
|
|
@@ -942,10 +943,10 @@ function createSideChatService(dependencies = {}) {
|
|
|
942
943
|
timeoutMs: dependencies.timeoutMs,
|
|
943
944
|
killTree: dependencies.killTree,
|
|
944
945
|
killGraceMs: dependencies.killGraceMs,
|
|
945
|
-
onSpawn: dependencies.onSpawn || ((proc, runContext) => sideChatCoordinator.attachProcess(runContext.canonicalUserId, proc)),
|
|
946
|
+
onSpawn: dependencies.onSpawn || ((proc, runContext) => sideChatCoordinator.attachProcess(runContext.canonicalUserId, proc, runLaneKey(runContext))),
|
|
946
947
|
onStderr: dependencies.onStderr || ((chunk) => console.error("SIDE PROVIDER STDERR:", redactSensitive(chunk))),
|
|
947
948
|
cleanup: dependencies.cleanup || (async (proc, details, runContext) => {
|
|
948
|
-
sideChatCoordinator.detachProcess(runContext.canonicalUserId, proc);
|
|
949
|
+
sideChatCoordinator.detachProcess(runContext.canonicalUserId, proc, runLaneKey(runContext));
|
|
949
950
|
}),
|
|
950
951
|
});
|
|
951
952
|
return {
|
|
@@ -1005,7 +1006,7 @@ function effectiveCompactThreshold(state = currentState(), opts = {}) {
|
|
|
1005
1006
|
}
|
|
1006
1007
|
|
|
1007
1008
|
function shouldAutoCompact(state = currentState(), opts = {}) {
|
|
1008
|
-
if (opts.skipAutoCompact || opts.fresh || opts.continueSession || state.isCompacting) return false;
|
|
1009
|
+
if (opts.skipAutoCompact || opts.fresh || opts.continueSession || peekLane(state, opts.laneKey)?.isCompacting) return false;
|
|
1009
1010
|
if (!getProviderSession(state, getActiveProvider(state), getProjectKey(state))) return false;
|
|
1010
1011
|
const threshold = effectiveCompactThreshold(state);
|
|
1011
1012
|
if (threshold === 0) return false;
|
|
@@ -1308,10 +1309,14 @@ function createCompactionService(dependencies = {}) {
|
|
|
1308
1309
|
const ackSchemaPath = dependencies.ackSchemaPath || ensureCompactionAckSchemaPath;
|
|
1309
1310
|
const drainQueue = dependencies.drainQueue || drainQueuedMessages;
|
|
1310
1311
|
|
|
1312
|
+
// Compaction acts on ONE conversation: the lane it is asked from (/compact)
|
|
1313
|
+
// or the lane whose turn just finished (auto-compaction, admissionHeld).
|
|
1314
|
+
// Other conversations of the speaker keep running meanwhile.
|
|
1311
1315
|
async function compact(cwd, opts = {}) {
|
|
1312
1316
|
const state = getState();
|
|
1313
|
-
|
|
1314
|
-
if (
|
|
1317
|
+
const lane = laneFor(state, opts.laneKey || null);
|
|
1318
|
+
if (lane.isCompacting) return { compacted: false, reason: "Compaction already in progress." };
|
|
1319
|
+
if (lane.runningProcess || ((lane.preparingRun || lane.settling) && !opts.admissionHeld)) {
|
|
1315
1320
|
return { compacted: false, reason: "Finish or stop the active run before compacting." };
|
|
1316
1321
|
}
|
|
1317
1322
|
const provider = getActiveProvider(state);
|
|
@@ -1338,8 +1343,10 @@ function createCompactionService(dependencies = {}) {
|
|
|
1338
1343
|
fresh: false,
|
|
1339
1344
|
});
|
|
1340
1345
|
const operationId = opts.operationId || (typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `compact-${Date.now()}`);
|
|
1341
|
-
|
|
1342
|
-
state
|
|
1346
|
+
// The recent turns come from THIS conversation's transcript: the captured
|
|
1347
|
+
// project, not the state's legacy "Workspace" session (which read another file).
|
|
1348
|
+
const recentTurns = collectRecent(capturedStateView(state, summaryContext));
|
|
1349
|
+
lane.isCompacting = true;
|
|
1343
1350
|
try {
|
|
1344
1351
|
if (opts.notify) await send(opts.message || "Context is getting large, compacting first so this stays fast…");
|
|
1345
1352
|
const summaryRun = await runCapture(summaryPrompt, summaryContext.cwd || cwd, {
|
|
@@ -1421,7 +1428,9 @@ function createCompactionService(dependencies = {}) {
|
|
|
1421
1428
|
commit,
|
|
1422
1429
|
};
|
|
1423
1430
|
} finally {
|
|
1424
|
-
|
|
1431
|
+
lane.isCompacting = false;
|
|
1432
|
+
// A bare cancel flag names no run: whatever it was for is over now.
|
|
1433
|
+
if (lane.cancelRequested === true) lane.cancelRequested = false;
|
|
1425
1434
|
try { await drainQueue(state); }
|
|
1426
1435
|
catch (error) { console.warn(`[compact] queue drain failed: ${redactSensitive(error.message)}`); }
|
|
1427
1436
|
}
|
|
@@ -1666,14 +1675,17 @@ async function persistForegroundSession(result, runContext, state = currentState
|
|
|
1666
1675
|
// dropped). Success without narration → the preview only ever showed the
|
|
1667
1676
|
// streamed answer itself; delete it so the final message isn't a duplicate.
|
|
1668
1677
|
// Failure/cancel → keep the partial text but drop the lying "working" footer.
|
|
1669
|
-
async function finalizeStreamPreview(
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
const
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1678
|
+
async function finalizeStreamPreview(lane, result, runContext = null) {
|
|
1679
|
+
// Only the run that owns the lane's preview may settle it.
|
|
1680
|
+
if (runContext && lane.previewRunId && lane.previewRunId !== runContext.runId) return;
|
|
1681
|
+
if (lane.streamInterval) { clearTimeout(lane.streamInterval); lane.streamInterval = null; }
|
|
1682
|
+
const id = lane.statusMessageId;
|
|
1683
|
+
const channel = lane.statusMessageChannel;
|
|
1684
|
+
const buffer = String(lane.streamBuffer || "").trim();
|
|
1685
|
+
lane.statusMessageId = null;
|
|
1686
|
+
lane.statusMessageChannel = null;
|
|
1687
|
+
lane.streamBuffer = "";
|
|
1688
|
+
lane.previewRunId = null;
|
|
1677
1689
|
if (typeof id !== "string") return;
|
|
1678
1690
|
// A message id is only valid on the channel that minted it; a foreign-
|
|
1679
1691
|
// channel preview is left as an orphan rather than edited cross-transport.
|
|
@@ -1687,21 +1699,22 @@ async function finalizeStreamPreview(state, result) {
|
|
|
1687
1699
|
// the preview so settled chat carries just the final answer. Verbose ON keeps
|
|
1688
1700
|
// the narration trail. Either way the live preview still showed progress mid-run.
|
|
1689
1701
|
const narration = String(result.narrationText || "").trim();
|
|
1690
|
-
if (narration &&
|
|
1702
|
+
if (narration && currentState().settings?.showThinking) await editMessage(id, narration.slice(-4000), telegramHtmlOpts({ streaming: true }));
|
|
1691
1703
|
else await deleteMessage(id);
|
|
1692
1704
|
} catch (_) { /* preview cleanup is best-effort; the final answer still goes out */ }
|
|
1693
1705
|
}
|
|
1694
1706
|
|
|
1695
1707
|
async function deliverForegroundResult(result, runContext, state = currentState()) {
|
|
1696
|
-
|
|
1697
|
-
|
|
1708
|
+
const lane = laneForRun(state, runContext);
|
|
1709
|
+
if (runContext.lastInputWasVoice) lane.lastInputWasVoice = false;
|
|
1710
|
+
await finalizeStreamPreview(lane, result, runContext);
|
|
1698
1711
|
// /stop unwind: the SIGTERM (or pre-spawn abort) that follows a user cancel
|
|
1699
1712
|
// is not a failure worth announcing — /stop already replied "Cancelled.".
|
|
1700
1713
|
// The flag is consumed here (and re-cleared on the run's unwind) so it can
|
|
1701
1714
|
// never suppress a later run's genuine failure; it names the run it stopped.
|
|
1702
|
-
const stoppedHere = cancelTargets(
|
|
1715
|
+
const stoppedHere = cancelTargets(lane.cancelRequested, runContext);
|
|
1703
1716
|
if (!result.ok && (stoppedHere || result.error?.code === "RUN_CANCELLED")) {
|
|
1704
|
-
if (stoppedHere)
|
|
1717
|
+
if (stoppedHere) lane.cancelRequested = false;
|
|
1705
1718
|
return { ok: true, suppressed: "cancelled" };
|
|
1706
1719
|
}
|
|
1707
1720
|
const diagnostic = providerRunDiagnostic(result, runContext);
|
|
@@ -1788,6 +1801,9 @@ function runPrincipal(runContext) {
|
|
|
1788
1801
|
adapterId: origin.adapterId || null,
|
|
1789
1802
|
adapterType: origin.transport || null,
|
|
1790
1803
|
channelId: channelId || null,
|
|
1804
|
+
// The chat thread the run answers in (null for the main timeline), so the
|
|
1805
|
+
// loopback can route a send by the attested run rather than by env.
|
|
1806
|
+
threadId: runContext.threadId || null,
|
|
1791
1807
|
userId: origin.userId || null,
|
|
1792
1808
|
};
|
|
1793
1809
|
if (!base.adapterType || !base.channelId) {
|
|
@@ -1809,6 +1825,7 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
|
|
|
1809
1825
|
if (!compactionStage) {
|
|
1810
1826
|
if (runContext.origin?.adapterId) env.OC_CHANNEL_ADAPTER = runContext.origin.adapterId;
|
|
1811
1827
|
if (runContext.channelId) env.OC_CHANNEL_ID = String(runContext.channelId);
|
|
1828
|
+
if (runContext.threadId) env.OC_THREAD_ID = String(runContext.threadId);
|
|
1812
1829
|
if (runContext.origin?.userId) env.OC_CHANNEL_USER_ID = String(runContext.origin.userId);
|
|
1813
1830
|
if (runContext.canonicalUserId) {
|
|
1814
1831
|
env.OC_CANONICAL_USER_ID = String(runContext.canonicalUserId);
|
|
@@ -1850,35 +1867,57 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
|
|
|
1850
1867
|
return { ...invocation, env };
|
|
1851
1868
|
}
|
|
1852
1869
|
|
|
1853
|
-
//
|
|
1854
|
-
// (pre-turn "thinking" started at message receipt hands off to the
|
|
1855
|
-
// heartbeat here)
|
|
1856
|
-
//
|
|
1857
|
-
|
|
1870
|
+
// One heartbeat per lane: a new start in the same conversation supersedes the
|
|
1871
|
+
// running one (pre-turn "thinking" started at message receipt hands off to the
|
|
1872
|
+
// run's own heartbeat here), while other conversations keep their own. A
|
|
1873
|
+
// stopper only acts while its own interval is current, so a stale stopper from
|
|
1874
|
+
// a superseded start can never kill a newer heartbeat. Inside a chat thread
|
|
1875
|
+
// the indicator goes into the thread and is re-sent every 2.5 s (Kazee
|
|
1876
|
+
// expires a thread's typing indicator after 5 s).
|
|
1877
|
+
const TYPING_INTERVAL_MS = 4000;
|
|
1878
|
+
const THREAD_TYPING_INTERVAL_MS = 2500;
|
|
1879
|
+
function startTypingHeartbeat(state, store, laneKey = null) {
|
|
1858
1880
|
const adapter = store?.adapter || currentAdapter();
|
|
1859
1881
|
const channelId = store?.channelId || currentChannelId();
|
|
1860
1882
|
if (!adapter || !channelId) return () => {};
|
|
1861
|
-
|
|
1862
|
-
const
|
|
1863
|
-
|
|
1883
|
+
const lane = laneFor(state, laneKey || store?.conversationKey || null);
|
|
1884
|
+
const threadId = store ? (store.threadId || null) : currentThreadId();
|
|
1885
|
+
const opts = threadId ? { threadId } : null;
|
|
1886
|
+
if (lane.typingHeartbeat) clearInterval(lane.typingHeartbeat);
|
|
1887
|
+
const phase = () => (lane.thinkingPhase ? "thinking" : undefined);
|
|
1888
|
+
const tick = () => (opts ? adapter.typing(channelId, phase(), opts) : adapter.typing(channelId, phase())).catch(() => {});
|
|
1889
|
+
lane.thinkingPhase = true;
|
|
1864
1890
|
// Phase changes are PUSHED through this, not waited for. The interval only
|
|
1865
|
-
// samples every
|
|
1866
|
-
// inside one interval would never emit a single "typing" frame —
|
|
1867
|
-
// would sit on "thinking" for the whole turn and then stop.
|
|
1868
|
-
|
|
1891
|
+
// samples every few seconds, so a final answer that starts streaming and
|
|
1892
|
+
// finishes inside one interval would never emit a single "typing" frame —
|
|
1893
|
+
// the chip would sit on "thinking" for the whole turn and then stop.
|
|
1894
|
+
lane.typingTick = tick;
|
|
1895
|
+
lane.typingTarget = { adapter, channelId, threadId };
|
|
1869
1896
|
tick();
|
|
1870
|
-
const interval = setInterval(tick,
|
|
1871
|
-
|
|
1897
|
+
const interval = setInterval(tick, threadId ? THREAD_TYPING_INTERVAL_MS : TYPING_INTERVAL_MS);
|
|
1898
|
+
lane.typingHeartbeat = interval;
|
|
1872
1899
|
return () => {
|
|
1873
|
-
if (
|
|
1874
|
-
|
|
1875
|
-
state.typingTick = null;
|
|
1876
|
-
clearInterval(interval);
|
|
1877
|
-
state.typingHeartbeat = null;
|
|
1878
|
-
try { adapter.typingStop?.(channelId); } catch (_) {}
|
|
1900
|
+
if (lane.typingHeartbeat !== interval) return;
|
|
1901
|
+
stopLaneTyping(lane);
|
|
1879
1902
|
};
|
|
1880
1903
|
}
|
|
1881
1904
|
|
|
1905
|
+
// Stop a lane's typing heartbeat now and tell the chat (into the lane's thread).
|
|
1906
|
+
function stopLaneTyping(lane) {
|
|
1907
|
+
if (!lane) return;
|
|
1908
|
+
const target = lane.typingTarget;
|
|
1909
|
+
if (lane.typingHeartbeat) clearInterval(lane.typingHeartbeat);
|
|
1910
|
+
lane.typingHeartbeat = null;
|
|
1911
|
+
lane.thinkingPhase = false;
|
|
1912
|
+
lane.typingTick = null;
|
|
1913
|
+
lane.typingTarget = null;
|
|
1914
|
+
if (!target) return;
|
|
1915
|
+
try {
|
|
1916
|
+
if (target.threadId) target.adapter.typingStop?.(target.channelId, { threadId: target.threadId });
|
|
1917
|
+
else target.adapter.typingStop?.(target.channelId);
|
|
1918
|
+
} catch (_) {}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1882
1921
|
function settleImmediateEffects(result, runContext, { state, store, capture }) {
|
|
1883
1922
|
const stages = [
|
|
1884
1923
|
["transcript", persistForegroundTranscript],
|
|
@@ -1934,7 +1973,8 @@ function streamPreviewAllowed(runContext) {
|
|
|
1934
1973
|
// emits mid-run is appended to one throttled status message, so the user sees
|
|
1935
1974
|
// the narration as it happens instead of silence. finalizeStreamPreview
|
|
1936
1975
|
// settles the message when the run ends.
|
|
1937
|
-
function createStreamPreview({ state, store }) {
|
|
1976
|
+
function createStreamPreview({ state, store, lane: givenLane = null }) {
|
|
1977
|
+
let lane = givenLane;
|
|
1938
1978
|
let enabled = !!store
|
|
1939
1979
|
&& (store?.adapter?.type || "") !== "voice"
|
|
1940
1980
|
&& store?.adapter?.suppressStreamPreview !== true;
|
|
@@ -1946,34 +1986,60 @@ function createStreamPreview({ state, store }) {
|
|
|
1946
1986
|
catch (_) { enabled = false; }
|
|
1947
1987
|
}
|
|
1948
1988
|
const channelId = store?.channelId || null;
|
|
1989
|
+
let owner = null; // the run whose preview this is
|
|
1949
1990
|
|
|
1991
|
+
// The lane's preview belongs to one run at a time (previewRunId): a run
|
|
1992
|
+
// never writes into or flushes a message another run owns, and only a /stop
|
|
1993
|
+
// naming THIS run mutes it.
|
|
1994
|
+
const ownsPreview = () => !!owner && lane.previewRunId === owner.runId;
|
|
1950
1995
|
const flush = () => {
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1996
|
+
if (!ownsPreview()) return;
|
|
1997
|
+
lane.streamInterval = null;
|
|
1998
|
+
if (!enabled || cancelTargets(lane.cancelRequested, owner)) return;
|
|
1999
|
+
const body = String(lane.streamBuffer || "").trim();
|
|
1954
2000
|
if (!body) return;
|
|
1955
2001
|
const display = body.slice(-3800) + STREAM_PREVIEW_FOOTER;
|
|
2002
|
+
const runId = owner.runId;
|
|
1956
2003
|
// Event callbacks arrive off the provider's stdout stream, outside the
|
|
1957
2004
|
// chat AsyncLocalStorage context — rebind before any send/edit.
|
|
1958
2005
|
chatContext.run(store, async () => {
|
|
1959
2006
|
try {
|
|
1960
|
-
if (typeof
|
|
1961
|
-
await editMessage(
|
|
2007
|
+
if (typeof lane.statusMessageId === "string" && String(lane.statusMessageChannel) === String(channelId)) {
|
|
2008
|
+
await editMessage(lane.statusMessageId, display, telegramHtmlOpts({ streaming: true }));
|
|
1962
2009
|
return;
|
|
1963
2010
|
}
|
|
1964
|
-
if (
|
|
2011
|
+
if (lane.statusMessageId === true) return; // sent but not editable — leave the single notice alone
|
|
1965
2012
|
const sent = await send(display, telegramHtmlOpts());
|
|
1966
|
-
|
|
1967
|
-
|
|
2013
|
+
// Settled while the send was in flight: an id minted for a finished
|
|
2014
|
+
// run must never be adopted (and later edited) by the next one.
|
|
2015
|
+
if (lane.previewRunId !== runId) {
|
|
2016
|
+
if (sent.editable) deleteMessage(sent.messageId).catch(() => {});
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
lane.statusMessageId = sent.editable ? sent.messageId : (sent.ok ? true : null);
|
|
2020
|
+
lane.statusMessageChannel = sent.ok ? channelId : null;
|
|
1968
2021
|
} catch (_) { /* preview is best-effort */ }
|
|
1969
2022
|
}).catch(() => {});
|
|
1970
2023
|
};
|
|
1971
2024
|
|
|
1972
2025
|
const appendChunk = (chunk, runContext) => {
|
|
1973
|
-
if (!enabled || !streamPreviewAllowed(runContext)
|
|
1974
|
-
|
|
1975
|
-
if (
|
|
1976
|
-
if (
|
|
2026
|
+
if (!enabled || !streamPreviewAllowed(runContext)) return;
|
|
2027
|
+
if (!lane) lane = laneForRun(state, runContext);
|
|
2028
|
+
if (cancelTargets(lane.cancelRequested, runContext)) return;
|
|
2029
|
+
if (owner?.runId !== runContext.runId) {
|
|
2030
|
+
owner = runContext;
|
|
2031
|
+
if (lane.previewRunId !== runContext.runId) {
|
|
2032
|
+
if (lane.streamInterval) clearTimeout(lane.streamInterval);
|
|
2033
|
+
lane.previewRunId = runContext.runId;
|
|
2034
|
+
lane.statusMessageId = null;
|
|
2035
|
+
lane.statusMessageChannel = null;
|
|
2036
|
+
lane.streamBuffer = "";
|
|
2037
|
+
lane.streamInterval = null;
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
lane.streamBuffer = lane.streamBuffer ? `${lane.streamBuffer}\n\n${chunk}` : chunk;
|
|
2041
|
+
if (lane.streamBuffer.length > 20000) lane.streamBuffer = lane.streamBuffer.slice(-16000);
|
|
2042
|
+
if (!lane.streamInterval) lane.streamInterval = setTimeout(flush, STREAM_PREVIEW_THROTTLE_MS);
|
|
1977
2043
|
};
|
|
1978
2044
|
|
|
1979
2045
|
return {
|
|
@@ -1993,14 +2059,19 @@ function createStreamPreview({ state, store }) {
|
|
|
1993
2059
|
};
|
|
1994
2060
|
}
|
|
1995
2061
|
|
|
1996
|
-
function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
|
|
2062
|
+
function createForegroundService({ state, store, lane, stopTyping, capture, ownsAdmissionLock, persistCapture = true, holdsLane = null, onOwnSpawn = null }) {
|
|
2063
|
+
// Still the lane's holder? A run whose lane /stop's backstop freed (and
|
|
2064
|
+
// another run then claimed) must not touch that lane's run slot.
|
|
2065
|
+
const holds = () => (holdsLane ? holdsLane() : ownsAdmissionLock);
|
|
1997
2066
|
const callSpeechLeases = new Map();
|
|
1998
2067
|
const callStage = (stage, runContext) => {
|
|
1999
2068
|
if (capture || runContext?.liveCall !== true) return;
|
|
2000
2069
|
console.log(`${new Date().toISOString()} Kazee call provider: stage=${stage} run=${runContext.runId} elapsed=${Math.max(0, Date.now() - runContext.admittedAt)}ms`);
|
|
2001
2070
|
};
|
|
2002
2071
|
const persistEffects = !capture || persistCapture;
|
|
2003
|
-
|
|
2072
|
+
// The run's lane: given by executeAdmittedRun, else the captured run's own.
|
|
2073
|
+
const laneOf = (runContext) => lane || laneForRun(state, runContext);
|
|
2074
|
+
const preview = capture ? null : createStreamPreview({ state, store, lane });
|
|
2004
2075
|
// Chat-visibility + learning-signal layer (skill/pack/tool announcements,
|
|
2005
2076
|
// /recall + /tooltrace banners, recall/tool graph feeding). Foreground only.
|
|
2006
2077
|
const observer = capture ? null : require("./turn-observer").createTurnObserver({ state, store });
|
|
@@ -2037,13 +2108,18 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
2037
2108
|
onSpawn(proc, runContext) {
|
|
2038
2109
|
callStage("started", runContext);
|
|
2039
2110
|
callSpeechLeases.get(runContext.runId)?.activate();
|
|
2040
|
-
|
|
2041
|
-
|
|
2111
|
+
// A capture (compaction stage, sub-agent) only records its process on a
|
|
2112
|
+
// lane that has none, so it never displaces the lane's own turn.
|
|
2113
|
+
const runLane = laneOf(runContext);
|
|
2114
|
+
if (ownsAdmissionLock) onOwnSpawn?.(proc);
|
|
2115
|
+
if (ownsAdmissionLock || !runLane.runningProcess) runLane.runningProcess = proc;
|
|
2116
|
+
if (ownsAdmissionLock) runLane.preparingRun = false;
|
|
2042
2117
|
},
|
|
2043
2118
|
shouldAbort(runContext, { capture: isCapture } = {}) {
|
|
2044
2119
|
if (isCapture || !ownsAdmissionLock) return false;
|
|
2045
|
-
|
|
2046
|
-
|
|
2120
|
+
const runLane = laneOf(runContext);
|
|
2121
|
+
if (!cancelTargets(runLane.cancelRequested, runContext)) return false;
|
|
2122
|
+
runLane.cancelRequested = false;
|
|
2047
2123
|
return true;
|
|
2048
2124
|
},
|
|
2049
2125
|
onPromptBundle(bundle, runContext) {
|
|
@@ -2056,13 +2132,14 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
2056
2132
|
if (event.type === "result" || event.type === "error") callSpeechLeases.get(runContext.runId)?.close();
|
|
2057
2133
|
// Streaming text reads as "typing"; tool work re-enters "thinking" so
|
|
2058
2134
|
// the chip doesn't decay to a stale "typing" through long tool stretches.
|
|
2059
|
-
const
|
|
2060
|
-
|
|
2061
|
-
|
|
2135
|
+
const lane = laneOf(runContext);
|
|
2136
|
+
const phaseBefore = lane.thinkingPhase;
|
|
2137
|
+
if (["text_delta", "text_final", "result", "error"].includes(event.type)) lane.thinkingPhase = false;
|
|
2138
|
+
else if (event.type === "tool_start") lane.thinkingPhase = true;
|
|
2062
2139
|
// Emit on the TRANSITION rather than waiting for the next sample. Guarded
|
|
2063
2140
|
// on an actual change, so a long text stream still sends one frame, not
|
|
2064
2141
|
// one per delta.
|
|
2065
|
-
if (
|
|
2142
|
+
if (lane.thinkingPhase !== phaseBefore) { try { lane.typingTick?.(); } catch (_) {} }
|
|
2066
2143
|
if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
|
|
2067
2144
|
const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
|
|
2068
2145
|
if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
|
|
@@ -2087,10 +2164,11 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
2087
2164
|
try { require("./speaker-attest").endRun(runContext.runId); } catch (_) {}
|
|
2088
2165
|
callSpeechLeases.get(runContext.runId)?.close();
|
|
2089
2166
|
callSpeechLeases.delete(runContext.runId);
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
//
|
|
2093
|
-
|
|
2167
|
+
const runLane = laneOf(runContext);
|
|
2168
|
+
if (runLane.runningProcess === proc) runLane.runningProcess = null;
|
|
2169
|
+
// Keep the lane's lock armed while transcript/usage/session persistence
|
|
2170
|
+
// and required delivery settle after child close.
|
|
2171
|
+
if (holds()) runLane.preparingRun = true;
|
|
2094
2172
|
stopTyping();
|
|
2095
2173
|
},
|
|
2096
2174
|
idleTimeoutMs: Math.max(60000, parseInt(config.OC_TURN_IDLE_MS || process.env.OC_TURN_IDLE_MS || "", 10) || 10 * 60 * 1000),
|
|
@@ -2155,15 +2233,16 @@ function cancelledCallRunResult(runContext) {
|
|
|
2155
2233
|
return cancelledRunResult(runContext, { id: runContext.provider, label: runContext.provider }, "Call turn superseded or ended before execution");
|
|
2156
2234
|
}
|
|
2157
2235
|
|
|
2158
|
-
function cancelQueuedCallOnAbort(
|
|
2236
|
+
function cancelQueuedCallOnAbort(lane, item) {
|
|
2159
2237
|
if (item.store?.adapter?.liveCall !== true) return;
|
|
2160
2238
|
const signal = item.store.adapter.callTurn?.signal;
|
|
2161
2239
|
if (!signal) return;
|
|
2162
2240
|
const cancel = () => {
|
|
2163
|
-
const index =
|
|
2241
|
+
const index = lane.messageQueue.indexOf(item);
|
|
2164
2242
|
if (index < 0) return; // Already admitted: pre-spawn checks and speech fences take over.
|
|
2165
|
-
|
|
2243
|
+
lane.messageQueue.splice(index, 1);
|
|
2166
2244
|
item.disposeCallListener?.();
|
|
2245
|
+
if (!laneBusy(lane) && !lane.messageQueue.length) stopLaneTyping(lane);
|
|
2167
2246
|
item.deferred.resolve(cancelledCallRunResult(item.runContext));
|
|
2168
2247
|
};
|
|
2169
2248
|
item.disposeCallListener = () => signal.removeEventListener("abort", cancel);
|
|
@@ -2171,19 +2250,37 @@ function cancelQueuedCallOnAbort(state, item) {
|
|
|
2171
2250
|
if (signal.aborted) cancel();
|
|
2172
2251
|
}
|
|
2173
2252
|
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2253
|
+
// Admit waiting lanes into free slots: every lane with queued work and no run,
|
|
2254
|
+
// oldest waiting message first, as far as the caps allow. An admitted lane runs
|
|
2255
|
+
// its next queued message (one at a time within a lane) in that message's own
|
|
2256
|
+
// captured context. Called whenever a run, a compaction or a /stop frees a
|
|
2257
|
+
// lane, and synchronous up to the admission, so no newcomer can take a freed
|
|
2258
|
+
// slot ahead of a lane that was already waiting. Every state's lanes are
|
|
2259
|
+
// considered (the global cap is shared); the argument is kept for callers.
|
|
2260
|
+
function drainQueuedMessages(_state) {
|
|
2261
|
+
for (const { state, lane } of waitingLanes()) {
|
|
2262
|
+
if (laneBusy(lane) || !lane.messageQueue.length) continue;
|
|
2263
|
+
if (slotBlocker(state)) { lane.waitingForSlot = true; continue; }
|
|
2264
|
+
const item = lane.messageQueue.shift();
|
|
2265
|
+
item.disposeCallListener?.();
|
|
2266
|
+
lane.preparingRun = true;
|
|
2267
|
+
lane.waitingForSlot = false;
|
|
2268
|
+
setImmediate(() => {
|
|
2269
|
+
const execute = () => executeAdmittedRun(item.runContext, state, item.store, false, true);
|
|
2270
|
+
const promise = item.store ? chatContext.run(item.store, execute) : execute();
|
|
2271
|
+
promise.then(item.deferred.resolve, item.deferred.reject);
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2184
2274
|
return Promise.resolve();
|
|
2185
2275
|
}
|
|
2186
2276
|
|
|
2277
|
+
let queueSequence = 0;
|
|
2278
|
+
function queueInLane(lane, item) {
|
|
2279
|
+
item.seq = ++queueSequence;
|
|
2280
|
+
lane.messageQueue.push(item);
|
|
2281
|
+
return item;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2187
2284
|
// A refusal on a RESUMED session is almost always the safety classifier
|
|
2188
2285
|
// rejecting flagged material in the old context — which every resume re-sends
|
|
2189
2286
|
// — not the user's new message. Recovery: quarantine the poisoned session so
|
|
@@ -2235,13 +2332,52 @@ function refusalRetryContext(runContext, state) {
|
|
|
2235
2332
|
});
|
|
2236
2333
|
}
|
|
2237
2334
|
|
|
2335
|
+
// A run that owns its lane's admission holds the lane from admission until its
|
|
2336
|
+
// whole tail is done (delivery, memory review, auto-compaction), then frees it
|
|
2337
|
+
// and admits the next waiting lane. Captures (compaction stages, sub-agents)
|
|
2338
|
+
// run inside a lane without owning it.
|
|
2238
2339
|
async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissionLock, executionOptions = {}) {
|
|
2340
|
+
if (!ownsAdmissionLock) {
|
|
2341
|
+
const result = await runAdmitted(runContext, state, store, capture, false, executionOptions);
|
|
2342
|
+
if (!(capture && executionOptions.persistCapture === false)) await drainQueuedMessages(state);
|
|
2343
|
+
return result;
|
|
2344
|
+
}
|
|
2345
|
+
const lane = laneForRun(state, runContext);
|
|
2346
|
+
const ownerToken = {};
|
|
2347
|
+
lane.ownerToken = ownerToken;
|
|
2348
|
+
try {
|
|
2349
|
+
return await runAdmitted(runContext, state, store, capture, true, { ...executionOptions, ownerToken });
|
|
2350
|
+
} finally {
|
|
2351
|
+
// Free the lane only while this run still holds it: after /stop's
|
|
2352
|
+
// backstop released a stuck run, another run may own the lane now.
|
|
2353
|
+
if (lane.ownerToken === ownerToken) {
|
|
2354
|
+
lane.ownerToken = null;
|
|
2355
|
+
lane.settling = false;
|
|
2356
|
+
lane.preparingRun = false;
|
|
2357
|
+
// A bare cancel flag (a /stop that named no run) must not outlive the
|
|
2358
|
+
// turn that held the lane, or the next message would be cancelled.
|
|
2359
|
+
if (lane.cancelRequested === true) lane.cancelRequested = false;
|
|
2360
|
+
}
|
|
2361
|
+
await drainQueuedMessages(state);
|
|
2362
|
+
pruneIdleLanes(state);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock, executionOptions = {}) {
|
|
2367
|
+
const lane = laneForRun(state, runContext);
|
|
2239
2368
|
const tracksMainRun = ownsAdmissionLock && !capture;
|
|
2240
|
-
if (tracksMainRun) state.activeRunContext = runContext;
|
|
2241
|
-
const stopTyping = startTypingHeartbeat(state, store);
|
|
2242
2369
|
const persistCapture = executionOptions.persistCapture !== false;
|
|
2243
2370
|
const internalCapture = capture && !persistCapture;
|
|
2244
|
-
|
|
2371
|
+
if (tracksMainRun) lane.activeRunContext = runContext;
|
|
2372
|
+
if (!internalCapture) runContext = consumeOneShotBudget(runContext, state, executionOptions);
|
|
2373
|
+
const stopTyping = startTypingHeartbeat(state, store, runLaneKey(runContext));
|
|
2374
|
+
const holdsLane = () => ownsAdmissionLock
|
|
2375
|
+
&& (!executionOptions.ownerToken || lane.ownerToken === executionOptions.ownerToken);
|
|
2376
|
+
const ownProcs = new Set();
|
|
2377
|
+
const service = createForegroundService({
|
|
2378
|
+
state, store, lane, stopTyping, capture, ownsAdmissionLock, persistCapture,
|
|
2379
|
+
holdsLane, onOwnSpawn: (proc) => ownProcs.add(proc),
|
|
2380
|
+
});
|
|
2245
2381
|
let result;
|
|
2246
2382
|
try {
|
|
2247
2383
|
result = await service.executeCaptured(runContext.userPrompt, runContext, { capture });
|
|
@@ -2254,29 +2390,33 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
2254
2390
|
result = await settleImmediateEffects(result, runContext, { state, store, capture });
|
|
2255
2391
|
}
|
|
2256
2392
|
} finally {
|
|
2257
|
-
|
|
2258
|
-
if (
|
|
2393
|
+
// Only this run's own process: never one a later run put there.
|
|
2394
|
+
if (tracksMainRun && lane.runningProcess && ownProcs.has(lane.runningProcess)) lane.runningProcess = null;
|
|
2395
|
+
// The provider is done but the lane stays held until the turn's tail has
|
|
2396
|
+
// finished: `settling`, not `preparingRun`, so a /stop now has nothing to
|
|
2397
|
+
// cancel and cannot leave a stray flag for the next turn.
|
|
2398
|
+
if (holdsLane()) { lane.preparingRun = false; lane.settling = true; }
|
|
2259
2399
|
// A cancel flag that survived this run (e.g. /stop raced a run that
|
|
2260
2400
|
// finished successfully anyway) must not leak into the next turn, where
|
|
2261
2401
|
// it would falsely abort a queued run or mute a genuine failure.
|
|
2262
|
-
if (
|
|
2263
|
-
if (tracksMainRun &&
|
|
2402
|
+
if (holdsLane() && cancelTargets(lane.cancelRequested, runContext)) lane.cancelRequested = false;
|
|
2403
|
+
if (tracksMainRun && lane.activeRunContext === runContext) lane.activeRunContext = null;
|
|
2264
2404
|
stopTyping();
|
|
2265
2405
|
}
|
|
2266
2406
|
|
|
2267
2407
|
if (shouldRetryRefusedRun(result, runContext, { capture, refusalRetried: executionOptions.refusalRetried })
|
|
2268
2408
|
&& quarantineRefusedRun(result, runContext, state)) {
|
|
2269
2409
|
try { service.turnObserver?.onTurnSettled(false); } catch (e) { /* best-effort */ }
|
|
2270
|
-
//
|
|
2271
|
-
//
|
|
2272
|
-
if (
|
|
2410
|
+
// The lane stays held across the notice's async gap so a racing incoming
|
|
2411
|
+
// message queues behind the retry instead of interleaving.
|
|
2412
|
+
if (holdsLane()) lane.preparingRun = true;
|
|
2273
2413
|
await send("♻️ That refusal came from flagged context in the old session — quarantined it, retrying your message in a fresh one.").catch(() => {});
|
|
2274
|
-
return
|
|
2414
|
+
return runAdmitted(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
|
|
2275
2415
|
}
|
|
2276
2416
|
|
|
2277
2417
|
if (tracksMainRun) {
|
|
2278
2418
|
sideChatCoordinator.noteMainResult(runContext, result);
|
|
2279
|
-
for (const item of
|
|
2419
|
+
for (const item of lane.messageQueue) {
|
|
2280
2420
|
finalizeSideChatQueueItem(item, runContext, result);
|
|
2281
2421
|
}
|
|
2282
2422
|
}
|
|
@@ -2287,24 +2427,37 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
2287
2427
|
}
|
|
2288
2428
|
if (!internalCapture) await handoffMemoryReview(result, runContext, store, state);
|
|
2289
2429
|
if (!internalCapture) await considerGuardrail(result, runContext, store);
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
result.ok = false;
|
|
2295
|
-
result.status = "failed";
|
|
2296
|
-
result.error = result.error || stableRunError(error.message, "RUN_SETTINGS_PERSIST_FAILED");
|
|
2297
|
-
result.diagnostic = providerRunDiagnostic(result, runContext) || result.error.message;
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
if (!capture && !runContext.skipAutoCompact && result.ok && state.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state)) {
|
|
2301
|
-
try { await compactActiveSession(runContext.project.dir, { notify: false }); }
|
|
2430
|
+
// Auto-compaction is part of the lane's turn: only when nothing waits in
|
|
2431
|
+
// this conversation, and it compacts this conversation only.
|
|
2432
|
+
if (!capture && !runContext.skipAutoCompact && result.ok && lane.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state, { laneKey: lane.key })) {
|
|
2433
|
+
try { await compactActiveSession(runContext.project.dir, { notify: false, laneKey: lane.key, admissionHeld: ownsAdmissionLock }); }
|
|
2302
2434
|
catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
|
|
2303
2435
|
}
|
|
2304
|
-
if (!internalCapture) await drainQueuedMessages(state);
|
|
2305
2436
|
return result;
|
|
2306
2437
|
}
|
|
2307
2438
|
|
|
2439
|
+
// The one-shot budget (/budget) belongs to the room and is spent by exactly one
|
|
2440
|
+
// turn: the first of that room to START with it. It used to be cleared when a
|
|
2441
|
+
// turn finished, which let parallel conversations of one room all run with it
|
|
2442
|
+
// until the first of them ended. A turn that starts after it was spent runs
|
|
2443
|
+
// without it, even if it captured it while it waited in a queue.
|
|
2444
|
+
function consumeOneShotBudget(runContext, state, executionOptions = {}) {
|
|
2445
|
+
const captured = runContext.providerSettings?.budget ?? null;
|
|
2446
|
+
if (captured === null || executionOptions.refusalRetried) return runContext;
|
|
2447
|
+
let live = null;
|
|
2448
|
+
try { live = resolveProviderSettings(state, runContext.provider)?.budget ?? null; } catch (_) { live = null; }
|
|
2449
|
+
if (live !== null) {
|
|
2450
|
+
try {
|
|
2451
|
+
require("./state").getProviderSettings(state, runContext.provider).budget = null;
|
|
2452
|
+
saveState({ strict: true });
|
|
2453
|
+
} catch (error) {
|
|
2454
|
+
console.warn(`[budget] could not clear the one-shot budget: ${redactSensitive(error.message)}`);
|
|
2455
|
+
}
|
|
2456
|
+
return runContext;
|
|
2457
|
+
}
|
|
2458
|
+
return deepFreeze({ ...runContext, providerSettings: { ...runContext.providerSettings, budget: null } });
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2308
2461
|
function admissionOptions(state, opts = {}) {
|
|
2309
2462
|
const provider = opts.provider || state.settings?.backend || null;
|
|
2310
2463
|
return { ...opts, provider };
|
|
@@ -2339,7 +2492,13 @@ function queuedNotice(activeRunContext, at = Date.now()) {
|
|
|
2339
2492
|
const kind = header ? { "Watch triggered": "watch", "Watch expired": "watch", "Watch check timing out": "watch", "Scheduled cron": "scheduled job", "Scheduled wakeup": "wakeup" }[header[1]] : null;
|
|
2340
2493
|
const label = header && header[2].length > 60 ? `${header[2].slice(0, 59).trimEnd()}…` : header?.[2];
|
|
2341
2494
|
const what = kind ? `the ${kind} "${label}"` : "an earlier message";
|
|
2342
|
-
return `Queued behind ${what} (running ${minutes} min). /stop cancels it.`;
|
|
2495
|
+
return `Queued behind ${what} (running ${minutes} min). /stop cancels it (and drops this message).`;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// What a message waiting for a free slot is told.
|
|
2499
|
+
function waitingNotice(running) {
|
|
2500
|
+
const n = Math.max(1, Number(running) || 0);
|
|
2501
|
+
return `Waiting: ${n} conversation${n === 1 ? " is" : "s are"} already running; this starts when one finishes.`;
|
|
2343
2502
|
}
|
|
2344
2503
|
|
|
2345
2504
|
function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
@@ -2355,31 +2514,49 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
2355
2514
|
});
|
|
2356
2515
|
|
|
2357
2516
|
if (obsoleteCallTurn(store)) return Promise.resolve(cancelledCallRunResult(runContext));
|
|
2358
|
-
|
|
2359
|
-
|
|
2517
|
+
const admission = admitLane(state, runLaneKey(runContext));
|
|
2518
|
+
if (!admission.ok) {
|
|
2519
|
+
const lane = admission.lane;
|
|
2520
|
+
if (admission.reason === "busy" && shouldStartSideChat(state, runContext, opts, sideChatCoordinator)) {
|
|
2360
2521
|
return sideChatCoordinator.respond({
|
|
2361
2522
|
runContext,
|
|
2362
|
-
activeRunContext:
|
|
2523
|
+
activeRunContext: lane.activeRunContext,
|
|
2363
2524
|
store,
|
|
2364
2525
|
});
|
|
2365
2526
|
}
|
|
2366
2527
|
const deferred = deferredRunResult();
|
|
2367
|
-
|
|
2368
|
-
|
|
2528
|
+
// `text` is what the person typed (before reply/context blocks were added),
|
|
2529
|
+
// so /stop can say which queued messages it dropped.
|
|
2530
|
+
const item = queueInLane(lane, { runContext, store, deferred, queuedAt: Date.now(), text: admitted.userPrompt || prompt });
|
|
2531
|
+
cancelQueuedCallOnAbort(lane, item);
|
|
2532
|
+
// Waiting for a slot: admit whatever the free slots allow, oldest first.
|
|
2533
|
+
// This message may be among them, and then it needs no notice.
|
|
2534
|
+
if (admission.reason !== "busy") {
|
|
2535
|
+
drainQueuedMessages(state);
|
|
2536
|
+
if (!lane.messageQueue.includes(item)) return deferred.promise;
|
|
2537
|
+
// Still waiting: nothing runs in this conversation, so no chip (a
|
|
2538
|
+
// pre-turn heartbeat may have started before the cap applied).
|
|
2539
|
+
if (!laneBusy(lane)) stopLaneTyping(lane);
|
|
2540
|
+
}
|
|
2369
2541
|
// Transient queue/compaction acks are a chat-UX affordance (Telegram/Kazee).
|
|
2370
2542
|
// On a Spaces task thread they'd post a throwaway "Queued." comment on the
|
|
2371
2543
|
// task — noise. On a live call, send() is the TTS line: "Queued." would be
|
|
2372
2544
|
// spoken to the caller. Both stay quiet and just deliver the real reply.
|
|
2373
2545
|
if (currentAdapter()?.type !== "spaces" && !statusChatterMuted()) {
|
|
2374
|
-
|
|
2546
|
+
const notice = admission.reason === "busy"
|
|
2547
|
+
? (lane.isCompacting ? "Compacting context, will pick this up next…" : queuedNotice(lane.activeRunContext))
|
|
2548
|
+
: waitingNotice(admission.running);
|
|
2549
|
+
send(notice, { replyTo: replyToMsgId }).catch(() => {});
|
|
2375
2550
|
}
|
|
2376
2551
|
return deferred.promise;
|
|
2377
2552
|
}
|
|
2378
2553
|
return executeAdmittedRun(runContext, state, store, false, true);
|
|
2379
2554
|
}
|
|
2380
2555
|
|
|
2556
|
+
// Agent mode: a message sent while ITS OWN conversation is running gets a
|
|
2557
|
+
// read-only side answer (other conversations simply run in parallel).
|
|
2381
2558
|
function shouldStartSideChat(state, runContext, opts = {}, coordinator = sideChatCoordinator) {
|
|
2382
|
-
return !!state?.runningProcess
|
|
2559
|
+
return !!peekLane(state, runLaneKey(runContext))?.runningProcess
|
|
2383
2560
|
&& !runContext?.liveCall
|
|
2384
2561
|
&& opts.sideChat !== false
|
|
2385
2562
|
&& runContext?.purpose === "foreground"
|
|
@@ -2409,7 +2586,7 @@ function createSideChatQueueItem(request, store, lineage, state, deferred) {
|
|
|
2409
2586
|
: request.originalRunContext;
|
|
2410
2587
|
const awaitsActiveMain = !lineage.mainSessionId
|
|
2411
2588
|
&& lineage.mainRunId
|
|
2412
|
-
&& state.activeRunContext?.runId === lineage.mainRunId;
|
|
2589
|
+
&& peekLane(state, runLaneKey(request.originalRunContext))?.activeRunContext?.runId === lineage.mainRunId;
|
|
2413
2590
|
return {
|
|
2414
2591
|
runContext: awaitsActiveMain ? null : queuedContext,
|
|
2415
2592
|
store,
|
|
@@ -2440,11 +2617,13 @@ function enqueueSideChatRequest(request, store = null, lineage = {}) {
|
|
|
2440
2617
|
error.code = "SIDE_CHAT_FORBIDDEN";
|
|
2441
2618
|
throw error;
|
|
2442
2619
|
}
|
|
2620
|
+
// Queued in the lane of the conversation the side question came from.
|
|
2621
|
+
const lane = laneForRun(state, request.originalRunContext);
|
|
2443
2622
|
const deferred = deferredRunResult();
|
|
2444
|
-
|
|
2623
|
+
queueInLane(lane, createSideChatQueueItem(request, store, lineage, state, deferred));
|
|
2445
2624
|
deferred.promise.catch((error) => console.error("side-chat queued run failed:", redactSensitive(error.message)));
|
|
2446
|
-
if (!
|
|
2447
|
-
return { queued: true, position:
|
|
2625
|
+
if (!laneBusy(lane)) drainQueuedMessages(state);
|
|
2626
|
+
return { queued: true, position: lane.messageQueue.length || 1 };
|
|
2448
2627
|
}
|
|
2449
2628
|
|
|
2450
2629
|
function runAgentCapture(prompt, cwd, opts = {}) {
|
|
@@ -2467,6 +2646,9 @@ function runAgentSilent(prompt, cwd, label, opts = {}) {
|
|
|
2467
2646
|
|
|
2468
2647
|
module.exports = {
|
|
2469
2648
|
queuedNotice,
|
|
2649
|
+
waitingNotice,
|
|
2650
|
+
drainQueuedMessages,
|
|
2651
|
+
stopLaneTyping,
|
|
2470
2652
|
admitRunContext,
|
|
2471
2653
|
streamPreviewAllowed,
|
|
2472
2654
|
capturedTranscriptMetadata,
|