@inetafrica/open-claudia 3.5.0 → 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 +32 -2
- package/bin/cli.js +13 -5
- package/bin/loopback-client.js +23 -5
- package/bin/owner-gate.js +2 -1
- package/bin/schedule.js +7 -2
- package/bin/tool.js +2 -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/bot-endpoint.js +194 -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 +313 -126
- package/core/scheduler.js +64 -32
- 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/tool-guard.js +15 -0
- 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 +15 -1
- package/test-provider-parity-e2e.js +8 -2
- 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);
|
|
@@ -1771,11 +1784,13 @@ async function deliverForegroundResult(result, runContext, state = currentState(
|
|
|
1771
1784
|
return { ok: true, chunks: chunks.length };
|
|
1772
1785
|
}
|
|
1773
1786
|
|
|
1774
|
-
// The principal a run acts for. With no chat origin (bot-internal work)
|
|
1775
|
-
// is the owner. With a chat origin it is the speaker the relationship
|
|
1776
|
-
// resolves from the inbound envelope — also for a compaction stage, which
|
|
1777
|
-
// summarises that speaker's conversation; if the speaker cannot be resolved
|
|
1778
|
-
// the
|
|
1787
|
+
// The principal a run acts for. With no chat origin at all (bot-internal work)
|
|
1788
|
+
// that is the owner. With a chat origin it is the speaker the relationship
|
|
1789
|
+
// layer resolves from the inbound envelope — also for a compaction stage, which
|
|
1790
|
+
// summarises that speaker's conversation; if the speaker cannot be resolved,
|
|
1791
|
+
// or the origin names someone but not the transport and room it came from, the
|
|
1792
|
+
// run is treated as a non-owner (fail closed).
|
|
1793
|
+
const ORIGIN_FIELDS = Object.freeze(["adapterId", "transport", "channelId", "userId", "canonicalUserId"]);
|
|
1779
1794
|
function runPrincipal(runContext) {
|
|
1780
1795
|
const origin = runContext.origin || {};
|
|
1781
1796
|
const channelId = runContext.channelId != null ? String(runContext.channelId) : origin.channelId;
|
|
@@ -1786,9 +1801,15 @@ function runPrincipal(runContext) {
|
|
|
1786
1801
|
adapterId: origin.adapterId || null,
|
|
1787
1802
|
adapterType: origin.transport || null,
|
|
1788
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,
|
|
1789
1807
|
userId: origin.userId || null,
|
|
1790
1808
|
};
|
|
1791
|
-
if (!base.adapterType || !base.channelId)
|
|
1809
|
+
if (!base.adapterType || !base.channelId) {
|
|
1810
|
+
const partial = ORIGIN_FIELDS.some((key) => origin[key] != null && origin[key] !== "");
|
|
1811
|
+
return { ...base, owner: !partial };
|
|
1812
|
+
}
|
|
1792
1813
|
let speaker = null;
|
|
1793
1814
|
try { speaker = require("./relationship").speakerFor(base.adapterType, base.channelId, base.userId); }
|
|
1794
1815
|
catch (error) { speaker = null; }
|
|
@@ -1804,6 +1825,7 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
|
|
|
1804
1825
|
if (!compactionStage) {
|
|
1805
1826
|
if (runContext.origin?.adapterId) env.OC_CHANNEL_ADAPTER = runContext.origin.adapterId;
|
|
1806
1827
|
if (runContext.channelId) env.OC_CHANNEL_ID = String(runContext.channelId);
|
|
1828
|
+
if (runContext.threadId) env.OC_THREAD_ID = String(runContext.threadId);
|
|
1807
1829
|
if (runContext.origin?.userId) env.OC_CHANNEL_USER_ID = String(runContext.origin.userId);
|
|
1808
1830
|
if (runContext.canonicalUserId) {
|
|
1809
1831
|
env.OC_CANONICAL_USER_ID = String(runContext.canonicalUserId);
|
|
@@ -1845,35 +1867,57 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
|
|
|
1845
1867
|
return { ...invocation, env };
|
|
1846
1868
|
}
|
|
1847
1869
|
|
|
1848
|
-
//
|
|
1849
|
-
// (pre-turn "thinking" started at message receipt hands off to the
|
|
1850
|
-
// heartbeat here)
|
|
1851
|
-
//
|
|
1852
|
-
|
|
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) {
|
|
1853
1880
|
const adapter = store?.adapter || currentAdapter();
|
|
1854
1881
|
const channelId = store?.channelId || currentChannelId();
|
|
1855
1882
|
if (!adapter || !channelId) return () => {};
|
|
1856
|
-
|
|
1857
|
-
const
|
|
1858
|
-
|
|
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;
|
|
1859
1890
|
// Phase changes are PUSHED through this, not waited for. The interval only
|
|
1860
|
-
// samples every
|
|
1861
|
-
// inside one interval would never emit a single "typing" frame —
|
|
1862
|
-
// would sit on "thinking" for the whole turn and then stop.
|
|
1863
|
-
|
|
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 };
|
|
1864
1896
|
tick();
|
|
1865
|
-
const interval = setInterval(tick,
|
|
1866
|
-
|
|
1897
|
+
const interval = setInterval(tick, threadId ? THREAD_TYPING_INTERVAL_MS : TYPING_INTERVAL_MS);
|
|
1898
|
+
lane.typingHeartbeat = interval;
|
|
1867
1899
|
return () => {
|
|
1868
|
-
if (
|
|
1869
|
-
|
|
1870
|
-
state.typingTick = null;
|
|
1871
|
-
clearInterval(interval);
|
|
1872
|
-
state.typingHeartbeat = null;
|
|
1873
|
-
try { adapter.typingStop?.(channelId); } catch (_) {}
|
|
1900
|
+
if (lane.typingHeartbeat !== interval) return;
|
|
1901
|
+
stopLaneTyping(lane);
|
|
1874
1902
|
};
|
|
1875
1903
|
}
|
|
1876
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
|
+
|
|
1877
1921
|
function settleImmediateEffects(result, runContext, { state, store, capture }) {
|
|
1878
1922
|
const stages = [
|
|
1879
1923
|
["transcript", persistForegroundTranscript],
|
|
@@ -1929,7 +1973,8 @@ function streamPreviewAllowed(runContext) {
|
|
|
1929
1973
|
// emits mid-run is appended to one throttled status message, so the user sees
|
|
1930
1974
|
// the narration as it happens instead of silence. finalizeStreamPreview
|
|
1931
1975
|
// settles the message when the run ends.
|
|
1932
|
-
function createStreamPreview({ state, store }) {
|
|
1976
|
+
function createStreamPreview({ state, store, lane: givenLane = null }) {
|
|
1977
|
+
let lane = givenLane;
|
|
1933
1978
|
let enabled = !!store
|
|
1934
1979
|
&& (store?.adapter?.type || "") !== "voice"
|
|
1935
1980
|
&& store?.adapter?.suppressStreamPreview !== true;
|
|
@@ -1941,34 +1986,60 @@ function createStreamPreview({ state, store }) {
|
|
|
1941
1986
|
catch (_) { enabled = false; }
|
|
1942
1987
|
}
|
|
1943
1988
|
const channelId = store?.channelId || null;
|
|
1989
|
+
let owner = null; // the run whose preview this is
|
|
1944
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;
|
|
1945
1995
|
const flush = () => {
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1996
|
+
if (!ownsPreview()) return;
|
|
1997
|
+
lane.streamInterval = null;
|
|
1998
|
+
if (!enabled || cancelTargets(lane.cancelRequested, owner)) return;
|
|
1999
|
+
const body = String(lane.streamBuffer || "").trim();
|
|
1949
2000
|
if (!body) return;
|
|
1950
2001
|
const display = body.slice(-3800) + STREAM_PREVIEW_FOOTER;
|
|
2002
|
+
const runId = owner.runId;
|
|
1951
2003
|
// Event callbacks arrive off the provider's stdout stream, outside the
|
|
1952
2004
|
// chat AsyncLocalStorage context — rebind before any send/edit.
|
|
1953
2005
|
chatContext.run(store, async () => {
|
|
1954
2006
|
try {
|
|
1955
|
-
if (typeof
|
|
1956
|
-
await editMessage(
|
|
2007
|
+
if (typeof lane.statusMessageId === "string" && String(lane.statusMessageChannel) === String(channelId)) {
|
|
2008
|
+
await editMessage(lane.statusMessageId, display, telegramHtmlOpts({ streaming: true }));
|
|
1957
2009
|
return;
|
|
1958
2010
|
}
|
|
1959
|
-
if (
|
|
2011
|
+
if (lane.statusMessageId === true) return; // sent but not editable — leave the single notice alone
|
|
1960
2012
|
const sent = await send(display, telegramHtmlOpts());
|
|
1961
|
-
|
|
1962
|
-
|
|
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;
|
|
1963
2021
|
} catch (_) { /* preview is best-effort */ }
|
|
1964
2022
|
}).catch(() => {});
|
|
1965
2023
|
};
|
|
1966
2024
|
|
|
1967
2025
|
const appendChunk = (chunk, runContext) => {
|
|
1968
|
-
if (!enabled || !streamPreviewAllowed(runContext)
|
|
1969
|
-
|
|
1970
|
-
if (
|
|
1971
|
-
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);
|
|
1972
2043
|
};
|
|
1973
2044
|
|
|
1974
2045
|
return {
|
|
@@ -1988,14 +2059,19 @@ function createStreamPreview({ state, store }) {
|
|
|
1988
2059
|
};
|
|
1989
2060
|
}
|
|
1990
2061
|
|
|
1991
|
-
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);
|
|
1992
2066
|
const callSpeechLeases = new Map();
|
|
1993
2067
|
const callStage = (stage, runContext) => {
|
|
1994
2068
|
if (capture || runContext?.liveCall !== true) return;
|
|
1995
2069
|
console.log(`${new Date().toISOString()} Kazee call provider: stage=${stage} run=${runContext.runId} elapsed=${Math.max(0, Date.now() - runContext.admittedAt)}ms`);
|
|
1996
2070
|
};
|
|
1997
2071
|
const persistEffects = !capture || persistCapture;
|
|
1998
|
-
|
|
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 });
|
|
1999
2075
|
// Chat-visibility + learning-signal layer (skill/pack/tool announcements,
|
|
2000
2076
|
// /recall + /tooltrace banners, recall/tool graph feeding). Foreground only.
|
|
2001
2077
|
const observer = capture ? null : require("./turn-observer").createTurnObserver({ state, store });
|
|
@@ -2032,13 +2108,18 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
2032
2108
|
onSpawn(proc, runContext) {
|
|
2033
2109
|
callStage("started", runContext);
|
|
2034
2110
|
callSpeechLeases.get(runContext.runId)?.activate();
|
|
2035
|
-
|
|
2036
|
-
|
|
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;
|
|
2037
2117
|
},
|
|
2038
2118
|
shouldAbort(runContext, { capture: isCapture } = {}) {
|
|
2039
2119
|
if (isCapture || !ownsAdmissionLock) return false;
|
|
2040
|
-
|
|
2041
|
-
|
|
2120
|
+
const runLane = laneOf(runContext);
|
|
2121
|
+
if (!cancelTargets(runLane.cancelRequested, runContext)) return false;
|
|
2122
|
+
runLane.cancelRequested = false;
|
|
2042
2123
|
return true;
|
|
2043
2124
|
},
|
|
2044
2125
|
onPromptBundle(bundle, runContext) {
|
|
@@ -2051,13 +2132,14 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
2051
2132
|
if (event.type === "result" || event.type === "error") callSpeechLeases.get(runContext.runId)?.close();
|
|
2052
2133
|
// Streaming text reads as "typing"; tool work re-enters "thinking" so
|
|
2053
2134
|
// the chip doesn't decay to a stale "typing" through long tool stretches.
|
|
2054
|
-
const
|
|
2055
|
-
|
|
2056
|
-
|
|
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;
|
|
2057
2139
|
// Emit on the TRANSITION rather than waiting for the next sample. Guarded
|
|
2058
2140
|
// on an actual change, so a long text stream still sends one frame, not
|
|
2059
2141
|
// one per delta.
|
|
2060
|
-
if (
|
|
2142
|
+
if (lane.thinkingPhase !== phaseBefore) { try { lane.typingTick?.(); } catch (_) {} }
|
|
2061
2143
|
if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
|
|
2062
2144
|
const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
|
|
2063
2145
|
if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
|
|
@@ -2082,10 +2164,11 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
2082
2164
|
try { require("./speaker-attest").endRun(runContext.runId); } catch (_) {}
|
|
2083
2165
|
callSpeechLeases.get(runContext.runId)?.close();
|
|
2084
2166
|
callSpeechLeases.delete(runContext.runId);
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
//
|
|
2088
|
-
|
|
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;
|
|
2089
2172
|
stopTyping();
|
|
2090
2173
|
},
|
|
2091
2174
|
idleTimeoutMs: Math.max(60000, parseInt(config.OC_TURN_IDLE_MS || process.env.OC_TURN_IDLE_MS || "", 10) || 10 * 60 * 1000),
|
|
@@ -2150,15 +2233,16 @@ function cancelledCallRunResult(runContext) {
|
|
|
2150
2233
|
return cancelledRunResult(runContext, { id: runContext.provider, label: runContext.provider }, "Call turn superseded or ended before execution");
|
|
2151
2234
|
}
|
|
2152
2235
|
|
|
2153
|
-
function cancelQueuedCallOnAbort(
|
|
2236
|
+
function cancelQueuedCallOnAbort(lane, item) {
|
|
2154
2237
|
if (item.store?.adapter?.liveCall !== true) return;
|
|
2155
2238
|
const signal = item.store.adapter.callTurn?.signal;
|
|
2156
2239
|
if (!signal) return;
|
|
2157
2240
|
const cancel = () => {
|
|
2158
|
-
const index =
|
|
2241
|
+
const index = lane.messageQueue.indexOf(item);
|
|
2159
2242
|
if (index < 0) return; // Already admitted: pre-spawn checks and speech fences take over.
|
|
2160
|
-
|
|
2243
|
+
lane.messageQueue.splice(index, 1);
|
|
2161
2244
|
item.disposeCallListener?.();
|
|
2245
|
+
if (!laneBusy(lane) && !lane.messageQueue.length) stopLaneTyping(lane);
|
|
2162
2246
|
item.deferred.resolve(cancelledCallRunResult(item.runContext));
|
|
2163
2247
|
};
|
|
2164
2248
|
item.disposeCallListener = () => signal.removeEventListener("abort", cancel);
|
|
@@ -2166,19 +2250,37 @@ function cancelQueuedCallOnAbort(state, item) {
|
|
|
2166
2250
|
if (signal.aborted) cancel();
|
|
2167
2251
|
}
|
|
2168
2252
|
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
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
|
+
}
|
|
2179
2274
|
return Promise.resolve();
|
|
2180
2275
|
}
|
|
2181
2276
|
|
|
2277
|
+
let queueSequence = 0;
|
|
2278
|
+
function queueInLane(lane, item) {
|
|
2279
|
+
item.seq = ++queueSequence;
|
|
2280
|
+
lane.messageQueue.push(item);
|
|
2281
|
+
return item;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2182
2284
|
// A refusal on a RESUMED session is almost always the safety classifier
|
|
2183
2285
|
// rejecting flagged material in the old context — which every resume re-sends
|
|
2184
2286
|
// — not the user's new message. Recovery: quarantine the poisoned session so
|
|
@@ -2230,13 +2332,52 @@ function refusalRetryContext(runContext, state) {
|
|
|
2230
2332
|
});
|
|
2231
2333
|
}
|
|
2232
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.
|
|
2233
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);
|
|
2234
2368
|
const tracksMainRun = ownsAdmissionLock && !capture;
|
|
2235
|
-
if (tracksMainRun) state.activeRunContext = runContext;
|
|
2236
|
-
const stopTyping = startTypingHeartbeat(state, store);
|
|
2237
2369
|
const persistCapture = executionOptions.persistCapture !== false;
|
|
2238
2370
|
const internalCapture = capture && !persistCapture;
|
|
2239
|
-
|
|
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
|
+
});
|
|
2240
2381
|
let result;
|
|
2241
2382
|
try {
|
|
2242
2383
|
result = await service.executeCaptured(runContext.userPrompt, runContext, { capture });
|
|
@@ -2249,29 +2390,33 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
2249
2390
|
result = await settleImmediateEffects(result, runContext, { state, store, capture });
|
|
2250
2391
|
}
|
|
2251
2392
|
} finally {
|
|
2252
|
-
|
|
2253
|
-
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; }
|
|
2254
2399
|
// A cancel flag that survived this run (e.g. /stop raced a run that
|
|
2255
2400
|
// finished successfully anyway) must not leak into the next turn, where
|
|
2256
2401
|
// it would falsely abort a queued run or mute a genuine failure.
|
|
2257
|
-
if (
|
|
2258
|
-
if (tracksMainRun &&
|
|
2402
|
+
if (holdsLane() && cancelTargets(lane.cancelRequested, runContext)) lane.cancelRequested = false;
|
|
2403
|
+
if (tracksMainRun && lane.activeRunContext === runContext) lane.activeRunContext = null;
|
|
2259
2404
|
stopTyping();
|
|
2260
2405
|
}
|
|
2261
2406
|
|
|
2262
2407
|
if (shouldRetryRefusedRun(result, runContext, { capture, refusalRetried: executionOptions.refusalRetried })
|
|
2263
2408
|
&& quarantineRefusedRun(result, runContext, state)) {
|
|
2264
2409
|
try { service.turnObserver?.onTurnSettled(false); } catch (e) { /* best-effort */ }
|
|
2265
|
-
//
|
|
2266
|
-
//
|
|
2267
|
-
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;
|
|
2268
2413
|
await send("♻️ That refusal came from flagged context in the old session — quarantined it, retrying your message in a fresh one.").catch(() => {});
|
|
2269
|
-
return
|
|
2414
|
+
return runAdmitted(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
|
|
2270
2415
|
}
|
|
2271
2416
|
|
|
2272
2417
|
if (tracksMainRun) {
|
|
2273
2418
|
sideChatCoordinator.noteMainResult(runContext, result);
|
|
2274
|
-
for (const item of
|
|
2419
|
+
for (const item of lane.messageQueue) {
|
|
2275
2420
|
finalizeSideChatQueueItem(item, runContext, result);
|
|
2276
2421
|
}
|
|
2277
2422
|
}
|
|
@@ -2282,24 +2427,37 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
2282
2427
|
}
|
|
2283
2428
|
if (!internalCapture) await handoffMemoryReview(result, runContext, store, state);
|
|
2284
2429
|
if (!internalCapture) await considerGuardrail(result, runContext, store);
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
result.ok = false;
|
|
2290
|
-
result.status = "failed";
|
|
2291
|
-
result.error = result.error || stableRunError(error.message, "RUN_SETTINGS_PERSIST_FAILED");
|
|
2292
|
-
result.diagnostic = providerRunDiagnostic(result, runContext) || result.error.message;
|
|
2293
|
-
}
|
|
2294
|
-
}
|
|
2295
|
-
if (!capture && !runContext.skipAutoCompact && result.ok && state.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state)) {
|
|
2296
|
-
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 }); }
|
|
2297
2434
|
catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
|
|
2298
2435
|
}
|
|
2299
|
-
if (!internalCapture) await drainQueuedMessages(state);
|
|
2300
2436
|
return result;
|
|
2301
2437
|
}
|
|
2302
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
|
+
|
|
2303
2461
|
function admissionOptions(state, opts = {}) {
|
|
2304
2462
|
const provider = opts.provider || state.settings?.backend || null;
|
|
2305
2463
|
return { ...opts, provider };
|
|
@@ -2334,7 +2492,13 @@ function queuedNotice(activeRunContext, at = Date.now()) {
|
|
|
2334
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;
|
|
2335
2493
|
const label = header && header[2].length > 60 ? `${header[2].slice(0, 59).trimEnd()}…` : header?.[2];
|
|
2336
2494
|
const what = kind ? `the ${kind} "${label}"` : "an earlier message";
|
|
2337
|
-
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.`;
|
|
2338
2502
|
}
|
|
2339
2503
|
|
|
2340
2504
|
function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
@@ -2350,31 +2514,49 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
2350
2514
|
});
|
|
2351
2515
|
|
|
2352
2516
|
if (obsoleteCallTurn(store)) return Promise.resolve(cancelledCallRunResult(runContext));
|
|
2353
|
-
|
|
2354
|
-
|
|
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)) {
|
|
2355
2521
|
return sideChatCoordinator.respond({
|
|
2356
2522
|
runContext,
|
|
2357
|
-
activeRunContext:
|
|
2523
|
+
activeRunContext: lane.activeRunContext,
|
|
2358
2524
|
store,
|
|
2359
2525
|
});
|
|
2360
2526
|
}
|
|
2361
2527
|
const deferred = deferredRunResult();
|
|
2362
|
-
|
|
2363
|
-
|
|
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
|
+
}
|
|
2364
2541
|
// Transient queue/compaction acks are a chat-UX affordance (Telegram/Kazee).
|
|
2365
2542
|
// On a Spaces task thread they'd post a throwaway "Queued." comment on the
|
|
2366
2543
|
// task — noise. On a live call, send() is the TTS line: "Queued." would be
|
|
2367
2544
|
// spoken to the caller. Both stay quiet and just deliver the real reply.
|
|
2368
2545
|
if (currentAdapter()?.type !== "spaces" && !statusChatterMuted()) {
|
|
2369
|
-
|
|
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(() => {});
|
|
2370
2550
|
}
|
|
2371
2551
|
return deferred.promise;
|
|
2372
2552
|
}
|
|
2373
2553
|
return executeAdmittedRun(runContext, state, store, false, true);
|
|
2374
2554
|
}
|
|
2375
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).
|
|
2376
2558
|
function shouldStartSideChat(state, runContext, opts = {}, coordinator = sideChatCoordinator) {
|
|
2377
|
-
return !!state?.runningProcess
|
|
2559
|
+
return !!peekLane(state, runLaneKey(runContext))?.runningProcess
|
|
2378
2560
|
&& !runContext?.liveCall
|
|
2379
2561
|
&& opts.sideChat !== false
|
|
2380
2562
|
&& runContext?.purpose === "foreground"
|
|
@@ -2404,7 +2586,7 @@ function createSideChatQueueItem(request, store, lineage, state, deferred) {
|
|
|
2404
2586
|
: request.originalRunContext;
|
|
2405
2587
|
const awaitsActiveMain = !lineage.mainSessionId
|
|
2406
2588
|
&& lineage.mainRunId
|
|
2407
|
-
&& state.activeRunContext?.runId === lineage.mainRunId;
|
|
2589
|
+
&& peekLane(state, runLaneKey(request.originalRunContext))?.activeRunContext?.runId === lineage.mainRunId;
|
|
2408
2590
|
return {
|
|
2409
2591
|
runContext: awaitsActiveMain ? null : queuedContext,
|
|
2410
2592
|
store,
|
|
@@ -2435,11 +2617,13 @@ function enqueueSideChatRequest(request, store = null, lineage = {}) {
|
|
|
2435
2617
|
error.code = "SIDE_CHAT_FORBIDDEN";
|
|
2436
2618
|
throw error;
|
|
2437
2619
|
}
|
|
2620
|
+
// Queued in the lane of the conversation the side question came from.
|
|
2621
|
+
const lane = laneForRun(state, request.originalRunContext);
|
|
2438
2622
|
const deferred = deferredRunResult();
|
|
2439
|
-
|
|
2623
|
+
queueInLane(lane, createSideChatQueueItem(request, store, lineage, state, deferred));
|
|
2440
2624
|
deferred.promise.catch((error) => console.error("side-chat queued run failed:", redactSensitive(error.message)));
|
|
2441
|
-
if (!
|
|
2442
|
-
return { queued: true, position:
|
|
2625
|
+
if (!laneBusy(lane)) drainQueuedMessages(state);
|
|
2626
|
+
return { queued: true, position: lane.messageQueue.length || 1 };
|
|
2443
2627
|
}
|
|
2444
2628
|
|
|
2445
2629
|
function runAgentCapture(prompt, cwd, opts = {}) {
|
|
@@ -2462,6 +2646,9 @@ function runAgentSilent(prompt, cwd, label, opts = {}) {
|
|
|
2462
2646
|
|
|
2463
2647
|
module.exports = {
|
|
2464
2648
|
queuedNotice,
|
|
2649
|
+
waitingNotice,
|
|
2650
|
+
drainQueuedMessages,
|
|
2651
|
+
stopLaneTyping,
|
|
2465
2652
|
admitRunContext,
|
|
2466
2653
|
streamPreviewAllowed,
|
|
2467
2654
|
capturedTranscriptMetadata,
|