@inetafrica/open-claudia 3.5.1 → 3.5.7
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 +13 -0
- package/CHANGELOG.md +36 -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 +519 -64
- package/channels/kazee/state.js +88 -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/providers/utility-policy.js +3 -1
- package/core/router.js +25 -13
- package/core/run-context.js +49 -8
- package/core/runner.js +318 -122
- package/core/sandbox-probe.js +78 -0
- 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/thread-title.js +237 -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 +7 -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);
|
|
@@ -1831,9 +1848,11 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
|
|
|
1831
1848
|
env.OC_RUN_ATTEST = require("./speaker-attest").registerRun(runContext.runId, principal);
|
|
1832
1849
|
// A non-owner's turn never sees the AI service credentials: Claude Code
|
|
1833
1850
|
// strips its own auth variables from every subprocess it starts (shell,
|
|
1834
|
-
// hooks) when this is set. The agent process itself keeps them.
|
|
1851
|
+
// hooks) when this is set. The agent process itself keeps them. Only where
|
|
1852
|
+
// the sandbox it needs can run (sandbox-probe.js): in the fleet's
|
|
1853
|
+
// containers it cannot, and the scrub made every staff turn crash.
|
|
1835
1854
|
if (principal.owner !== true) {
|
|
1836
|
-
env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB = "1";
|
|
1855
|
+
if (require("./sandbox-probe").staffEnvScrub().ok) env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB = "1";
|
|
1837
1856
|
// Read by the permission hook (which inherits the agent's env, not the
|
|
1838
1857
|
// agent's shell's) so a guard that cannot load fails closed on this run.
|
|
1839
1858
|
env.OC_GUARDED_RUN = "1";
|
|
@@ -1850,35 +1869,57 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
|
|
|
1850
1869
|
return { ...invocation, env };
|
|
1851
1870
|
}
|
|
1852
1871
|
|
|
1853
|
-
//
|
|
1854
|
-
// (pre-turn "thinking" started at message receipt hands off to the
|
|
1855
|
-
// heartbeat here)
|
|
1856
|
-
//
|
|
1857
|
-
|
|
1872
|
+
// One heartbeat per lane: a new start in the same conversation supersedes the
|
|
1873
|
+
// running one (pre-turn "thinking" started at message receipt hands off to the
|
|
1874
|
+
// run's own heartbeat here), while other conversations keep their own. A
|
|
1875
|
+
// stopper only acts while its own interval is current, so a stale stopper from
|
|
1876
|
+
// a superseded start can never kill a newer heartbeat. Inside a chat thread
|
|
1877
|
+
// the indicator goes into the thread and is re-sent every 2.5 s (Kazee
|
|
1878
|
+
// expires a thread's typing indicator after 5 s).
|
|
1879
|
+
const TYPING_INTERVAL_MS = 4000;
|
|
1880
|
+
const THREAD_TYPING_INTERVAL_MS = 2500;
|
|
1881
|
+
function startTypingHeartbeat(state, store, laneKey = null) {
|
|
1858
1882
|
const adapter = store?.adapter || currentAdapter();
|
|
1859
1883
|
const channelId = store?.channelId || currentChannelId();
|
|
1860
1884
|
if (!adapter || !channelId) return () => {};
|
|
1861
|
-
|
|
1862
|
-
const
|
|
1863
|
-
|
|
1885
|
+
const lane = laneFor(state, laneKey || store?.conversationKey || null);
|
|
1886
|
+
const threadId = store ? (store.threadId || null) : currentThreadId();
|
|
1887
|
+
const opts = threadId ? { threadId } : null;
|
|
1888
|
+
if (lane.typingHeartbeat) clearInterval(lane.typingHeartbeat);
|
|
1889
|
+
const phase = () => (lane.thinkingPhase ? "thinking" : undefined);
|
|
1890
|
+
const tick = () => (opts ? adapter.typing(channelId, phase(), opts) : adapter.typing(channelId, phase())).catch(() => {});
|
|
1891
|
+
lane.thinkingPhase = true;
|
|
1864
1892
|
// 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
|
-
|
|
1893
|
+
// samples every few seconds, so a final answer that starts streaming and
|
|
1894
|
+
// finishes inside one interval would never emit a single "typing" frame —
|
|
1895
|
+
// the chip would sit on "thinking" for the whole turn and then stop.
|
|
1896
|
+
lane.typingTick = tick;
|
|
1897
|
+
lane.typingTarget = { adapter, channelId, threadId };
|
|
1869
1898
|
tick();
|
|
1870
|
-
const interval = setInterval(tick,
|
|
1871
|
-
|
|
1899
|
+
const interval = setInterval(tick, threadId ? THREAD_TYPING_INTERVAL_MS : TYPING_INTERVAL_MS);
|
|
1900
|
+
lane.typingHeartbeat = interval;
|
|
1872
1901
|
return () => {
|
|
1873
|
-
if (
|
|
1874
|
-
|
|
1875
|
-
state.typingTick = null;
|
|
1876
|
-
clearInterval(interval);
|
|
1877
|
-
state.typingHeartbeat = null;
|
|
1878
|
-
try { adapter.typingStop?.(channelId); } catch (_) {}
|
|
1902
|
+
if (lane.typingHeartbeat !== interval) return;
|
|
1903
|
+
stopLaneTyping(lane);
|
|
1879
1904
|
};
|
|
1880
1905
|
}
|
|
1881
1906
|
|
|
1907
|
+
// Stop a lane's typing heartbeat now and tell the chat (into the lane's thread).
|
|
1908
|
+
function stopLaneTyping(lane) {
|
|
1909
|
+
if (!lane) return;
|
|
1910
|
+
const target = lane.typingTarget;
|
|
1911
|
+
if (lane.typingHeartbeat) clearInterval(lane.typingHeartbeat);
|
|
1912
|
+
lane.typingHeartbeat = null;
|
|
1913
|
+
lane.thinkingPhase = false;
|
|
1914
|
+
lane.typingTick = null;
|
|
1915
|
+
lane.typingTarget = null;
|
|
1916
|
+
if (!target) return;
|
|
1917
|
+
try {
|
|
1918
|
+
if (target.threadId) target.adapter.typingStop?.(target.channelId, { threadId: target.threadId });
|
|
1919
|
+
else target.adapter.typingStop?.(target.channelId);
|
|
1920
|
+
} catch (_) {}
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1882
1923
|
function settleImmediateEffects(result, runContext, { state, store, capture }) {
|
|
1883
1924
|
const stages = [
|
|
1884
1925
|
["transcript", persistForegroundTranscript],
|
|
@@ -1934,7 +1975,8 @@ function streamPreviewAllowed(runContext) {
|
|
|
1934
1975
|
// emits mid-run is appended to one throttled status message, so the user sees
|
|
1935
1976
|
// the narration as it happens instead of silence. finalizeStreamPreview
|
|
1936
1977
|
// settles the message when the run ends.
|
|
1937
|
-
function createStreamPreview({ state, store }) {
|
|
1978
|
+
function createStreamPreview({ state, store, lane: givenLane = null }) {
|
|
1979
|
+
let lane = givenLane;
|
|
1938
1980
|
let enabled = !!store
|
|
1939
1981
|
&& (store?.adapter?.type || "") !== "voice"
|
|
1940
1982
|
&& store?.adapter?.suppressStreamPreview !== true;
|
|
@@ -1946,34 +1988,60 @@ function createStreamPreview({ state, store }) {
|
|
|
1946
1988
|
catch (_) { enabled = false; }
|
|
1947
1989
|
}
|
|
1948
1990
|
const channelId = store?.channelId || null;
|
|
1991
|
+
let owner = null; // the run whose preview this is
|
|
1949
1992
|
|
|
1993
|
+
// The lane's preview belongs to one run at a time (previewRunId): a run
|
|
1994
|
+
// never writes into or flushes a message another run owns, and only a /stop
|
|
1995
|
+
// naming THIS run mutes it.
|
|
1996
|
+
const ownsPreview = () => !!owner && lane.previewRunId === owner.runId;
|
|
1950
1997
|
const flush = () => {
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1998
|
+
if (!ownsPreview()) return;
|
|
1999
|
+
lane.streamInterval = null;
|
|
2000
|
+
if (!enabled || cancelTargets(lane.cancelRequested, owner)) return;
|
|
2001
|
+
const body = String(lane.streamBuffer || "").trim();
|
|
1954
2002
|
if (!body) return;
|
|
1955
2003
|
const display = body.slice(-3800) + STREAM_PREVIEW_FOOTER;
|
|
2004
|
+
const runId = owner.runId;
|
|
1956
2005
|
// Event callbacks arrive off the provider's stdout stream, outside the
|
|
1957
2006
|
// chat AsyncLocalStorage context — rebind before any send/edit.
|
|
1958
2007
|
chatContext.run(store, async () => {
|
|
1959
2008
|
try {
|
|
1960
|
-
if (typeof
|
|
1961
|
-
await editMessage(
|
|
2009
|
+
if (typeof lane.statusMessageId === "string" && String(lane.statusMessageChannel) === String(channelId)) {
|
|
2010
|
+
await editMessage(lane.statusMessageId, display, telegramHtmlOpts({ streaming: true }));
|
|
1962
2011
|
return;
|
|
1963
2012
|
}
|
|
1964
|
-
if (
|
|
2013
|
+
if (lane.statusMessageId === true) return; // sent but not editable — leave the single notice alone
|
|
1965
2014
|
const sent = await send(display, telegramHtmlOpts());
|
|
1966
|
-
|
|
1967
|
-
|
|
2015
|
+
// Settled while the send was in flight: an id minted for a finished
|
|
2016
|
+
// run must never be adopted (and later edited) by the next one.
|
|
2017
|
+
if (lane.previewRunId !== runId) {
|
|
2018
|
+
if (sent.editable) deleteMessage(sent.messageId).catch(() => {});
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
lane.statusMessageId = sent.editable ? sent.messageId : (sent.ok ? true : null);
|
|
2022
|
+
lane.statusMessageChannel = sent.ok ? channelId : null;
|
|
1968
2023
|
} catch (_) { /* preview is best-effort */ }
|
|
1969
2024
|
}).catch(() => {});
|
|
1970
2025
|
};
|
|
1971
2026
|
|
|
1972
2027
|
const appendChunk = (chunk, runContext) => {
|
|
1973
|
-
if (!enabled || !streamPreviewAllowed(runContext)
|
|
1974
|
-
|
|
1975
|
-
if (
|
|
1976
|
-
if (
|
|
2028
|
+
if (!enabled || !streamPreviewAllowed(runContext)) return;
|
|
2029
|
+
if (!lane) lane = laneForRun(state, runContext);
|
|
2030
|
+
if (cancelTargets(lane.cancelRequested, runContext)) return;
|
|
2031
|
+
if (owner?.runId !== runContext.runId) {
|
|
2032
|
+
owner = runContext;
|
|
2033
|
+
if (lane.previewRunId !== runContext.runId) {
|
|
2034
|
+
if (lane.streamInterval) clearTimeout(lane.streamInterval);
|
|
2035
|
+
lane.previewRunId = runContext.runId;
|
|
2036
|
+
lane.statusMessageId = null;
|
|
2037
|
+
lane.statusMessageChannel = null;
|
|
2038
|
+
lane.streamBuffer = "";
|
|
2039
|
+
lane.streamInterval = null;
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
lane.streamBuffer = lane.streamBuffer ? `${lane.streamBuffer}\n\n${chunk}` : chunk;
|
|
2043
|
+
if (lane.streamBuffer.length > 20000) lane.streamBuffer = lane.streamBuffer.slice(-16000);
|
|
2044
|
+
if (!lane.streamInterval) lane.streamInterval = setTimeout(flush, STREAM_PREVIEW_THROTTLE_MS);
|
|
1977
2045
|
};
|
|
1978
2046
|
|
|
1979
2047
|
return {
|
|
@@ -1993,14 +2061,19 @@ function createStreamPreview({ state, store }) {
|
|
|
1993
2061
|
};
|
|
1994
2062
|
}
|
|
1995
2063
|
|
|
1996
|
-
function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
|
|
2064
|
+
function createForegroundService({ state, store, lane, stopTyping, capture, ownsAdmissionLock, persistCapture = true, holdsLane = null, onOwnSpawn = null }) {
|
|
2065
|
+
// Still the lane's holder? A run whose lane /stop's backstop freed (and
|
|
2066
|
+
// another run then claimed) must not touch that lane's run slot.
|
|
2067
|
+
const holds = () => (holdsLane ? holdsLane() : ownsAdmissionLock);
|
|
1997
2068
|
const callSpeechLeases = new Map();
|
|
1998
2069
|
const callStage = (stage, runContext) => {
|
|
1999
2070
|
if (capture || runContext?.liveCall !== true) return;
|
|
2000
2071
|
console.log(`${new Date().toISOString()} Kazee call provider: stage=${stage} run=${runContext.runId} elapsed=${Math.max(0, Date.now() - runContext.admittedAt)}ms`);
|
|
2001
2072
|
};
|
|
2002
2073
|
const persistEffects = !capture || persistCapture;
|
|
2003
|
-
|
|
2074
|
+
// The run's lane: given by executeAdmittedRun, else the captured run's own.
|
|
2075
|
+
const laneOf = (runContext) => lane || laneForRun(state, runContext);
|
|
2076
|
+
const preview = capture ? null : createStreamPreview({ state, store, lane });
|
|
2004
2077
|
// Chat-visibility + learning-signal layer (skill/pack/tool announcements,
|
|
2005
2078
|
// /recall + /tooltrace banners, recall/tool graph feeding). Foreground only.
|
|
2006
2079
|
const observer = capture ? null : require("./turn-observer").createTurnObserver({ state, store });
|
|
@@ -2037,13 +2110,18 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
2037
2110
|
onSpawn(proc, runContext) {
|
|
2038
2111
|
callStage("started", runContext);
|
|
2039
2112
|
callSpeechLeases.get(runContext.runId)?.activate();
|
|
2040
|
-
|
|
2041
|
-
|
|
2113
|
+
// A capture (compaction stage, sub-agent) only records its process on a
|
|
2114
|
+
// lane that has none, so it never displaces the lane's own turn.
|
|
2115
|
+
const runLane = laneOf(runContext);
|
|
2116
|
+
if (ownsAdmissionLock) onOwnSpawn?.(proc);
|
|
2117
|
+
if (ownsAdmissionLock || !runLane.runningProcess) runLane.runningProcess = proc;
|
|
2118
|
+
if (ownsAdmissionLock) runLane.preparingRun = false;
|
|
2042
2119
|
},
|
|
2043
2120
|
shouldAbort(runContext, { capture: isCapture } = {}) {
|
|
2044
2121
|
if (isCapture || !ownsAdmissionLock) return false;
|
|
2045
|
-
|
|
2046
|
-
|
|
2122
|
+
const runLane = laneOf(runContext);
|
|
2123
|
+
if (!cancelTargets(runLane.cancelRequested, runContext)) return false;
|
|
2124
|
+
runLane.cancelRequested = false;
|
|
2047
2125
|
return true;
|
|
2048
2126
|
},
|
|
2049
2127
|
onPromptBundle(bundle, runContext) {
|
|
@@ -2056,13 +2134,14 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
2056
2134
|
if (event.type === "result" || event.type === "error") callSpeechLeases.get(runContext.runId)?.close();
|
|
2057
2135
|
// Streaming text reads as "typing"; tool work re-enters "thinking" so
|
|
2058
2136
|
// the chip doesn't decay to a stale "typing" through long tool stretches.
|
|
2059
|
-
const
|
|
2060
|
-
|
|
2061
|
-
|
|
2137
|
+
const lane = laneOf(runContext);
|
|
2138
|
+
const phaseBefore = lane.thinkingPhase;
|
|
2139
|
+
if (["text_delta", "text_final", "result", "error"].includes(event.type)) lane.thinkingPhase = false;
|
|
2140
|
+
else if (event.type === "tool_start") lane.thinkingPhase = true;
|
|
2062
2141
|
// Emit on the TRANSITION rather than waiting for the next sample. Guarded
|
|
2063
2142
|
// on an actual change, so a long text stream still sends one frame, not
|
|
2064
2143
|
// one per delta.
|
|
2065
|
-
if (
|
|
2144
|
+
if (lane.thinkingPhase !== phaseBefore) { try { lane.typingTick?.(); } catch (_) {} }
|
|
2066
2145
|
if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
|
|
2067
2146
|
const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
|
|
2068
2147
|
if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
|
|
@@ -2087,10 +2166,11 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
2087
2166
|
try { require("./speaker-attest").endRun(runContext.runId); } catch (_) {}
|
|
2088
2167
|
callSpeechLeases.get(runContext.runId)?.close();
|
|
2089
2168
|
callSpeechLeases.delete(runContext.runId);
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
//
|
|
2093
|
-
|
|
2169
|
+
const runLane = laneOf(runContext);
|
|
2170
|
+
if (runLane.runningProcess === proc) runLane.runningProcess = null;
|
|
2171
|
+
// Keep the lane's lock armed while transcript/usage/session persistence
|
|
2172
|
+
// and required delivery settle after child close.
|
|
2173
|
+
if (holds()) runLane.preparingRun = true;
|
|
2094
2174
|
stopTyping();
|
|
2095
2175
|
},
|
|
2096
2176
|
idleTimeoutMs: Math.max(60000, parseInt(config.OC_TURN_IDLE_MS || process.env.OC_TURN_IDLE_MS || "", 10) || 10 * 60 * 1000),
|
|
@@ -2123,6 +2203,15 @@ async function handoffMemoryReview(result, runContext, store, state) {
|
|
|
2123
2203
|
});
|
|
2124
2204
|
}
|
|
2125
2205
|
|
|
2206
|
+
// Thread titles: after a delivered thread reply the bot may name the thread
|
|
2207
|
+
// (core/thread-title.js decides). Never awaited, never throws.
|
|
2208
|
+
function nameThreadAfterTurn(result, runContext, store) {
|
|
2209
|
+
try {
|
|
2210
|
+
const pending = require("./thread-title").nameThreadAfterTurn(result, runContext, store);
|
|
2211
|
+
if (pending) pending.catch((error) => console.warn(`[thread-title] ${redactSensitive(error.message)}`));
|
|
2212
|
+
} catch (error) { /* best-effort: naming a thread must never break a turn */ }
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2126
2215
|
// Track C — owner-guardrail capture. After a foreground turn, a deterministic
|
|
2127
2216
|
// detector spots durable-rule statements ("from now on / never / stop") in the
|
|
2128
2217
|
// owner's message and captures them into lessons.md (or asks first). Owner-only
|
|
@@ -2155,15 +2244,16 @@ function cancelledCallRunResult(runContext) {
|
|
|
2155
2244
|
return cancelledRunResult(runContext, { id: runContext.provider, label: runContext.provider }, "Call turn superseded or ended before execution");
|
|
2156
2245
|
}
|
|
2157
2246
|
|
|
2158
|
-
function cancelQueuedCallOnAbort(
|
|
2247
|
+
function cancelQueuedCallOnAbort(lane, item) {
|
|
2159
2248
|
if (item.store?.adapter?.liveCall !== true) return;
|
|
2160
2249
|
const signal = item.store.adapter.callTurn?.signal;
|
|
2161
2250
|
if (!signal) return;
|
|
2162
2251
|
const cancel = () => {
|
|
2163
|
-
const index =
|
|
2252
|
+
const index = lane.messageQueue.indexOf(item);
|
|
2164
2253
|
if (index < 0) return; // Already admitted: pre-spawn checks and speech fences take over.
|
|
2165
|
-
|
|
2254
|
+
lane.messageQueue.splice(index, 1);
|
|
2166
2255
|
item.disposeCallListener?.();
|
|
2256
|
+
if (!laneBusy(lane) && !lane.messageQueue.length) stopLaneTyping(lane);
|
|
2167
2257
|
item.deferred.resolve(cancelledCallRunResult(item.runContext));
|
|
2168
2258
|
};
|
|
2169
2259
|
item.disposeCallListener = () => signal.removeEventListener("abort", cancel);
|
|
@@ -2171,19 +2261,37 @@ function cancelQueuedCallOnAbort(state, item) {
|
|
|
2171
2261
|
if (signal.aborted) cancel();
|
|
2172
2262
|
}
|
|
2173
2263
|
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2264
|
+
// Admit waiting lanes into free slots: every lane with queued work and no run,
|
|
2265
|
+
// oldest waiting message first, as far as the caps allow. An admitted lane runs
|
|
2266
|
+
// its next queued message (one at a time within a lane) in that message's own
|
|
2267
|
+
// captured context. Called whenever a run, a compaction or a /stop frees a
|
|
2268
|
+
// lane, and synchronous up to the admission, so no newcomer can take a freed
|
|
2269
|
+
// slot ahead of a lane that was already waiting. Every state's lanes are
|
|
2270
|
+
// considered (the global cap is shared); the argument is kept for callers.
|
|
2271
|
+
function drainQueuedMessages(_state) {
|
|
2272
|
+
for (const { state, lane } of waitingLanes()) {
|
|
2273
|
+
if (laneBusy(lane) || !lane.messageQueue.length) continue;
|
|
2274
|
+
if (slotBlocker(state)) { lane.waitingForSlot = true; continue; }
|
|
2275
|
+
const item = lane.messageQueue.shift();
|
|
2276
|
+
item.disposeCallListener?.();
|
|
2277
|
+
lane.preparingRun = true;
|
|
2278
|
+
lane.waitingForSlot = false;
|
|
2279
|
+
setImmediate(() => {
|
|
2280
|
+
const execute = () => executeAdmittedRun(item.runContext, state, item.store, false, true);
|
|
2281
|
+
const promise = item.store ? chatContext.run(item.store, execute) : execute();
|
|
2282
|
+
promise.then(item.deferred.resolve, item.deferred.reject);
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2184
2285
|
return Promise.resolve();
|
|
2185
2286
|
}
|
|
2186
2287
|
|
|
2288
|
+
let queueSequence = 0;
|
|
2289
|
+
function queueInLane(lane, item) {
|
|
2290
|
+
item.seq = ++queueSequence;
|
|
2291
|
+
lane.messageQueue.push(item);
|
|
2292
|
+
return item;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2187
2295
|
// A refusal on a RESUMED session is almost always the safety classifier
|
|
2188
2296
|
// rejecting flagged material in the old context — which every resume re-sends
|
|
2189
2297
|
// — not the user's new message. Recovery: quarantine the poisoned session so
|
|
@@ -2235,13 +2343,52 @@ function refusalRetryContext(runContext, state) {
|
|
|
2235
2343
|
});
|
|
2236
2344
|
}
|
|
2237
2345
|
|
|
2346
|
+
// A run that owns its lane's admission holds the lane from admission until its
|
|
2347
|
+
// whole tail is done (delivery, memory review, auto-compaction), then frees it
|
|
2348
|
+
// and admits the next waiting lane. Captures (compaction stages, sub-agents)
|
|
2349
|
+
// run inside a lane without owning it.
|
|
2238
2350
|
async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissionLock, executionOptions = {}) {
|
|
2351
|
+
if (!ownsAdmissionLock) {
|
|
2352
|
+
const result = await runAdmitted(runContext, state, store, capture, false, executionOptions);
|
|
2353
|
+
if (!(capture && executionOptions.persistCapture === false)) await drainQueuedMessages(state);
|
|
2354
|
+
return result;
|
|
2355
|
+
}
|
|
2356
|
+
const lane = laneForRun(state, runContext);
|
|
2357
|
+
const ownerToken = {};
|
|
2358
|
+
lane.ownerToken = ownerToken;
|
|
2359
|
+
try {
|
|
2360
|
+
return await runAdmitted(runContext, state, store, capture, true, { ...executionOptions, ownerToken });
|
|
2361
|
+
} finally {
|
|
2362
|
+
// Free the lane only while this run still holds it: after /stop's
|
|
2363
|
+
// backstop released a stuck run, another run may own the lane now.
|
|
2364
|
+
if (lane.ownerToken === ownerToken) {
|
|
2365
|
+
lane.ownerToken = null;
|
|
2366
|
+
lane.settling = false;
|
|
2367
|
+
lane.preparingRun = false;
|
|
2368
|
+
// A bare cancel flag (a /stop that named no run) must not outlive the
|
|
2369
|
+
// turn that held the lane, or the next message would be cancelled.
|
|
2370
|
+
if (lane.cancelRequested === true) lane.cancelRequested = false;
|
|
2371
|
+
}
|
|
2372
|
+
await drainQueuedMessages(state);
|
|
2373
|
+
pruneIdleLanes(state);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
async function runAdmitted(runContext, state, store, capture, ownsAdmissionLock, executionOptions = {}) {
|
|
2378
|
+
const lane = laneForRun(state, runContext);
|
|
2239
2379
|
const tracksMainRun = ownsAdmissionLock && !capture;
|
|
2240
|
-
if (tracksMainRun) state.activeRunContext = runContext;
|
|
2241
|
-
const stopTyping = startTypingHeartbeat(state, store);
|
|
2242
2380
|
const persistCapture = executionOptions.persistCapture !== false;
|
|
2243
2381
|
const internalCapture = capture && !persistCapture;
|
|
2244
|
-
|
|
2382
|
+
if (tracksMainRun) lane.activeRunContext = runContext;
|
|
2383
|
+
if (!internalCapture) runContext = consumeOneShotBudget(runContext, state, executionOptions);
|
|
2384
|
+
const stopTyping = startTypingHeartbeat(state, store, runLaneKey(runContext));
|
|
2385
|
+
const holdsLane = () => ownsAdmissionLock
|
|
2386
|
+
&& (!executionOptions.ownerToken || lane.ownerToken === executionOptions.ownerToken);
|
|
2387
|
+
const ownProcs = new Set();
|
|
2388
|
+
const service = createForegroundService({
|
|
2389
|
+
state, store, lane, stopTyping, capture, ownsAdmissionLock, persistCapture,
|
|
2390
|
+
holdsLane, onOwnSpawn: (proc) => ownProcs.add(proc),
|
|
2391
|
+
});
|
|
2245
2392
|
let result;
|
|
2246
2393
|
try {
|
|
2247
2394
|
result = await service.executeCaptured(runContext.userPrompt, runContext, { capture });
|
|
@@ -2254,29 +2401,33 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
2254
2401
|
result = await settleImmediateEffects(result, runContext, { state, store, capture });
|
|
2255
2402
|
}
|
|
2256
2403
|
} finally {
|
|
2257
|
-
|
|
2258
|
-
if (
|
|
2404
|
+
// Only this run's own process: never one a later run put there.
|
|
2405
|
+
if (tracksMainRun && lane.runningProcess && ownProcs.has(lane.runningProcess)) lane.runningProcess = null;
|
|
2406
|
+
// The provider is done but the lane stays held until the turn's tail has
|
|
2407
|
+
// finished: `settling`, not `preparingRun`, so a /stop now has nothing to
|
|
2408
|
+
// cancel and cannot leave a stray flag for the next turn.
|
|
2409
|
+
if (holdsLane()) { lane.preparingRun = false; lane.settling = true; }
|
|
2259
2410
|
// A cancel flag that survived this run (e.g. /stop raced a run that
|
|
2260
2411
|
// finished successfully anyway) must not leak into the next turn, where
|
|
2261
2412
|
// it would falsely abort a queued run or mute a genuine failure.
|
|
2262
|
-
if (
|
|
2263
|
-
if (tracksMainRun &&
|
|
2413
|
+
if (holdsLane() && cancelTargets(lane.cancelRequested, runContext)) lane.cancelRequested = false;
|
|
2414
|
+
if (tracksMainRun && lane.activeRunContext === runContext) lane.activeRunContext = null;
|
|
2264
2415
|
stopTyping();
|
|
2265
2416
|
}
|
|
2266
2417
|
|
|
2267
2418
|
if (shouldRetryRefusedRun(result, runContext, { capture, refusalRetried: executionOptions.refusalRetried })
|
|
2268
2419
|
&& quarantineRefusedRun(result, runContext, state)) {
|
|
2269
2420
|
try { service.turnObserver?.onTurnSettled(false); } catch (e) { /* best-effort */ }
|
|
2270
|
-
//
|
|
2271
|
-
//
|
|
2272
|
-
if (
|
|
2421
|
+
// The lane stays held across the notice's async gap so a racing incoming
|
|
2422
|
+
// message queues behind the retry instead of interleaving.
|
|
2423
|
+
if (holdsLane()) lane.preparingRun = true;
|
|
2273
2424
|
await send("♻️ That refusal came from flagged context in the old session — quarantined it, retrying your message in a fresh one.").catch(() => {});
|
|
2274
|
-
return
|
|
2425
|
+
return runAdmitted(refusalRetryContext(runContext, state), state, store, capture, ownsAdmissionLock, { ...executionOptions, refusalRetried: true });
|
|
2275
2426
|
}
|
|
2276
2427
|
|
|
2277
2428
|
if (tracksMainRun) {
|
|
2278
2429
|
sideChatCoordinator.noteMainResult(runContext, result);
|
|
2279
|
-
for (const item of
|
|
2430
|
+
for (const item of lane.messageQueue) {
|
|
2280
2431
|
finalizeSideChatQueueItem(item, runContext, result);
|
|
2281
2432
|
}
|
|
2282
2433
|
}
|
|
@@ -2287,24 +2438,40 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
|
|
|
2287
2438
|
}
|
|
2288
2439
|
if (!internalCapture) await handoffMemoryReview(result, runContext, store, state);
|
|
2289
2440
|
if (!internalCapture) await considerGuardrail(result, runContext, store);
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
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 }); }
|
|
2441
|
+
// A thread's first delivered reply may name the thread. Detached: it never
|
|
2442
|
+
// delays this turn, the lane or the queue behind it.
|
|
2443
|
+
if (!capture) nameThreadAfterTurn(result, runContext, store);
|
|
2444
|
+
// Auto-compaction is part of the lane's turn: only when nothing waits in
|
|
2445
|
+
// this conversation, and it compacts this conversation only.
|
|
2446
|
+
if (!capture && !runContext.skipAutoCompact && result.ok && lane.messageQueue.length === 0 && sameCapturedProject(state, runContext) && shouldAutoCompact(state, { laneKey: lane.key })) {
|
|
2447
|
+
try { await compactActiveSession(runContext.project.dir, { notify: false, laneKey: lane.key, admissionHeld: ownsAdmissionLock }); }
|
|
2302
2448
|
catch (error) { console.warn(`[proactive-compact] failed: ${redactSensitive(error.message)}`); }
|
|
2303
2449
|
}
|
|
2304
|
-
if (!internalCapture) await drainQueuedMessages(state);
|
|
2305
2450
|
return result;
|
|
2306
2451
|
}
|
|
2307
2452
|
|
|
2453
|
+
// The one-shot budget (/budget) belongs to the room and is spent by exactly one
|
|
2454
|
+
// turn: the first of that room to START with it. It used to be cleared when a
|
|
2455
|
+
// turn finished, which let parallel conversations of one room all run with it
|
|
2456
|
+
// until the first of them ended. A turn that starts after it was spent runs
|
|
2457
|
+
// without it, even if it captured it while it waited in a queue.
|
|
2458
|
+
function consumeOneShotBudget(runContext, state, executionOptions = {}) {
|
|
2459
|
+
const captured = runContext.providerSettings?.budget ?? null;
|
|
2460
|
+
if (captured === null || executionOptions.refusalRetried) return runContext;
|
|
2461
|
+
let live = null;
|
|
2462
|
+
try { live = resolveProviderSettings(state, runContext.provider)?.budget ?? null; } catch (_) { live = null; }
|
|
2463
|
+
if (live !== null) {
|
|
2464
|
+
try {
|
|
2465
|
+
require("./state").getProviderSettings(state, runContext.provider).budget = null;
|
|
2466
|
+
saveState({ strict: true });
|
|
2467
|
+
} catch (error) {
|
|
2468
|
+
console.warn(`[budget] could not clear the one-shot budget: ${redactSensitive(error.message)}`);
|
|
2469
|
+
}
|
|
2470
|
+
return runContext;
|
|
2471
|
+
}
|
|
2472
|
+
return deepFreeze({ ...runContext, providerSettings: { ...runContext.providerSettings, budget: null } });
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2308
2475
|
function admissionOptions(state, opts = {}) {
|
|
2309
2476
|
const provider = opts.provider || state.settings?.backend || null;
|
|
2310
2477
|
return { ...opts, provider };
|
|
@@ -2339,7 +2506,13 @@ function queuedNotice(activeRunContext, at = Date.now()) {
|
|
|
2339
2506
|
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
2507
|
const label = header && header[2].length > 60 ? `${header[2].slice(0, 59).trimEnd()}…` : header?.[2];
|
|
2341
2508
|
const what = kind ? `the ${kind} "${label}"` : "an earlier message";
|
|
2342
|
-
return `Queued behind ${what} (running ${minutes} min). /stop cancels it.`;
|
|
2509
|
+
return `Queued behind ${what} (running ${minutes} min). /stop cancels it (and drops this message).`;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
// What a message waiting for a free slot is told.
|
|
2513
|
+
function waitingNotice(running) {
|
|
2514
|
+
const n = Math.max(1, Number(running) || 0);
|
|
2515
|
+
return `Waiting: ${n} conversation${n === 1 ? " is" : "s are"} already running; this starts when one finishes.`;
|
|
2343
2516
|
}
|
|
2344
2517
|
|
|
2345
2518
|
function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
@@ -2355,31 +2528,49 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
2355
2528
|
});
|
|
2356
2529
|
|
|
2357
2530
|
if (obsoleteCallTurn(store)) return Promise.resolve(cancelledCallRunResult(runContext));
|
|
2358
|
-
|
|
2359
|
-
|
|
2531
|
+
const admission = admitLane(state, runLaneKey(runContext));
|
|
2532
|
+
if (!admission.ok) {
|
|
2533
|
+
const lane = admission.lane;
|
|
2534
|
+
if (admission.reason === "busy" && shouldStartSideChat(state, runContext, opts, sideChatCoordinator)) {
|
|
2360
2535
|
return sideChatCoordinator.respond({
|
|
2361
2536
|
runContext,
|
|
2362
|
-
activeRunContext:
|
|
2537
|
+
activeRunContext: lane.activeRunContext,
|
|
2363
2538
|
store,
|
|
2364
2539
|
});
|
|
2365
2540
|
}
|
|
2366
2541
|
const deferred = deferredRunResult();
|
|
2367
|
-
|
|
2368
|
-
|
|
2542
|
+
// `text` is what the person typed (before reply/context blocks were added),
|
|
2543
|
+
// so /stop can say which queued messages it dropped.
|
|
2544
|
+
const item = queueInLane(lane, { runContext, store, deferred, queuedAt: Date.now(), text: admitted.userPrompt || prompt });
|
|
2545
|
+
cancelQueuedCallOnAbort(lane, item);
|
|
2546
|
+
// Waiting for a slot: admit whatever the free slots allow, oldest first.
|
|
2547
|
+
// This message may be among them, and then it needs no notice.
|
|
2548
|
+
if (admission.reason !== "busy") {
|
|
2549
|
+
drainQueuedMessages(state);
|
|
2550
|
+
if (!lane.messageQueue.includes(item)) return deferred.promise;
|
|
2551
|
+
// Still waiting: nothing runs in this conversation, so no chip (a
|
|
2552
|
+
// pre-turn heartbeat may have started before the cap applied).
|
|
2553
|
+
if (!laneBusy(lane)) stopLaneTyping(lane);
|
|
2554
|
+
}
|
|
2369
2555
|
// Transient queue/compaction acks are a chat-UX affordance (Telegram/Kazee).
|
|
2370
2556
|
// On a Spaces task thread they'd post a throwaway "Queued." comment on the
|
|
2371
2557
|
// task — noise. On a live call, send() is the TTS line: "Queued." would be
|
|
2372
2558
|
// spoken to the caller. Both stay quiet and just deliver the real reply.
|
|
2373
2559
|
if (currentAdapter()?.type !== "spaces" && !statusChatterMuted()) {
|
|
2374
|
-
|
|
2560
|
+
const notice = admission.reason === "busy"
|
|
2561
|
+
? (lane.isCompacting ? "Compacting context, will pick this up next…" : queuedNotice(lane.activeRunContext))
|
|
2562
|
+
: waitingNotice(admission.running);
|
|
2563
|
+
send(notice, { replyTo: replyToMsgId }).catch(() => {});
|
|
2375
2564
|
}
|
|
2376
2565
|
return deferred.promise;
|
|
2377
2566
|
}
|
|
2378
2567
|
return executeAdmittedRun(runContext, state, store, false, true);
|
|
2379
2568
|
}
|
|
2380
2569
|
|
|
2570
|
+
// Agent mode: a message sent while ITS OWN conversation is running gets a
|
|
2571
|
+
// read-only side answer (other conversations simply run in parallel).
|
|
2381
2572
|
function shouldStartSideChat(state, runContext, opts = {}, coordinator = sideChatCoordinator) {
|
|
2382
|
-
return !!state?.runningProcess
|
|
2573
|
+
return !!peekLane(state, runLaneKey(runContext))?.runningProcess
|
|
2383
2574
|
&& !runContext?.liveCall
|
|
2384
2575
|
&& opts.sideChat !== false
|
|
2385
2576
|
&& runContext?.purpose === "foreground"
|
|
@@ -2409,7 +2600,7 @@ function createSideChatQueueItem(request, store, lineage, state, deferred) {
|
|
|
2409
2600
|
: request.originalRunContext;
|
|
2410
2601
|
const awaitsActiveMain = !lineage.mainSessionId
|
|
2411
2602
|
&& lineage.mainRunId
|
|
2412
|
-
&& state.activeRunContext?.runId === lineage.mainRunId;
|
|
2603
|
+
&& peekLane(state, runLaneKey(request.originalRunContext))?.activeRunContext?.runId === lineage.mainRunId;
|
|
2413
2604
|
return {
|
|
2414
2605
|
runContext: awaitsActiveMain ? null : queuedContext,
|
|
2415
2606
|
store,
|
|
@@ -2440,11 +2631,13 @@ function enqueueSideChatRequest(request, store = null, lineage = {}) {
|
|
|
2440
2631
|
error.code = "SIDE_CHAT_FORBIDDEN";
|
|
2441
2632
|
throw error;
|
|
2442
2633
|
}
|
|
2634
|
+
// Queued in the lane of the conversation the side question came from.
|
|
2635
|
+
const lane = laneForRun(state, request.originalRunContext);
|
|
2443
2636
|
const deferred = deferredRunResult();
|
|
2444
|
-
|
|
2637
|
+
queueInLane(lane, createSideChatQueueItem(request, store, lineage, state, deferred));
|
|
2445
2638
|
deferred.promise.catch((error) => console.error("side-chat queued run failed:", redactSensitive(error.message)));
|
|
2446
|
-
if (!
|
|
2447
|
-
return { queued: true, position:
|
|
2639
|
+
if (!laneBusy(lane)) drainQueuedMessages(state);
|
|
2640
|
+
return { queued: true, position: lane.messageQueue.length || 1 };
|
|
2448
2641
|
}
|
|
2449
2642
|
|
|
2450
2643
|
function runAgentCapture(prompt, cwd, opts = {}) {
|
|
@@ -2467,6 +2660,9 @@ function runAgentSilent(prompt, cwd, label, opts = {}) {
|
|
|
2467
2660
|
|
|
2468
2661
|
module.exports = {
|
|
2469
2662
|
queuedNotice,
|
|
2663
|
+
waitingNotice,
|
|
2664
|
+
drainQueuedMessages,
|
|
2665
|
+
stopLaneTyping,
|
|
2470
2666
|
admitRunContext,
|
|
2471
2667
|
streamPreviewAllowed,
|
|
2472
2668
|
capturedTranscriptMetadata,
|