alvin-bot 5.1.8 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/dist/handlers/commands.js +54 -4
- package/dist/handlers/message.js +39 -1
- package/dist/i18n.js +12 -0
- package/dist/providers/claude-sdk-provider.js +14 -0
- package/dist/providers/registry.js +12 -0
- package/dist/services/alvin-dispatch.js +4 -0
- package/dist/services/async-agent-watcher.js +61 -0
- package/dist/services/session-persistence.js +6 -0
- package/dist/services/session.js +8 -0
- package/dist/services/stop-controller.js +52 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [5.2.0] — 2026-05-17
|
|
6
|
+
|
|
7
|
+
### Stop now actually means stop — instantly
|
|
8
|
+
|
|
9
|
+
You could always type `/cancel`, but it rarely felt like anything
|
|
10
|
+
happened: the bot kept "thinking" for a while and the answer often
|
|
11
|
+
still arrived. That's fixed. When you stop a task, the bot now bails
|
|
12
|
+
out immediately instead of quietly trying its backup brains one after
|
|
13
|
+
another in the background. Press stop — it stops.
|
|
14
|
+
|
|
15
|
+
### A one-tap ⛔ Stop button on every running task
|
|
16
|
+
|
|
17
|
+
No more remembering or typing a command mid-thought. While Alvin is
|
|
18
|
+
working you'll see a ⛔ Stop button right there on the status message.
|
|
19
|
+
One tap, the task ends, the button flips to "⛔ Gestoppt". Mistyped
|
|
20
|
+
your request or changed your mind? It's one thumb away — and it works
|
|
21
|
+
the same in Telegram, Slack, Discord and WhatsApp.
|
|
22
|
+
|
|
23
|
+
### New `/stopall` — pull the plug on everything
|
|
24
|
+
|
|
25
|
+
`/cancel` (and the button) stop the task you're watching but let
|
|
26
|
+
already-running background helpers finish and report back later. When
|
|
27
|
+
you want a hard reset — *"forget all of it"* — use the new `/stopall`:
|
|
28
|
+
it stops the current task, terminates the background sub-agents it
|
|
29
|
+
spawned, and clears anything queued behind it. Nothing comes back to
|
|
30
|
+
surprise you afterwards.
|
|
31
|
+
|
|
32
|
+
Under the hood this was a careful, well-tested hardening of the whole
|
|
33
|
+
stop path — verified end-to-end on a clean second machine before
|
|
34
|
+
shipping. No setup or config needed; it just works.
|
|
35
|
+
|
|
5
36
|
## [5.1.8] — 2026-05-17
|
|
6
37
|
|
|
7
38
|
### Interrupted jobs auto-resume after a controlled restart
|
|
@@ -2,7 +2,7 @@ import { InlineKeyboard, InputFile } from "grammy";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path, { resolve } from "path";
|
|
4
4
|
import os from "os";
|
|
5
|
-
import { getSession, resetSession, markSessionDirty, getTelegramWorkspace, setTelegramWorkspace } from "../services/session.js";
|
|
5
|
+
import { getSession, buildSessionKey, resetSession, markSessionDirty, getTelegramWorkspace, setTelegramWorkspace } from "../services/session.js";
|
|
6
6
|
import { listWorkspaces, getWorkspace } from "../services/workspaces.js";
|
|
7
7
|
import { getRegistry } from "../engine.js";
|
|
8
8
|
import { reloadSoul } from "../services/personality.js";
|
|
@@ -32,6 +32,8 @@ import { getReleaseHighlights } from "../services/release-highlights.js";
|
|
|
32
32
|
import { runCleanup, getCleanupPolicy } from "../services/disk-cleanup.js";
|
|
33
33
|
import { getHealthStatus, isFailedOver } from "../services/heartbeat.js";
|
|
34
34
|
import { t, LOCALE_NAMES, LOCALE_FLAGS } from "../i18n.js";
|
|
35
|
+
import { requestStop, interruptQuery } from "../services/stop-controller.js";
|
|
36
|
+
import { killSessionDetachedAgents, cancelPendingForSession } from "../services/async-agent-watcher.js";
|
|
35
37
|
// Kick off auto-update loop on module load if the persistent flag is set.
|
|
36
38
|
// Doing this as a module side-effect avoids touching the bot entry point.
|
|
37
39
|
if (getAutoUpdate()) {
|
|
@@ -1896,18 +1898,66 @@ export function registerCommands(bot) {
|
|
|
1896
1898
|
}
|
|
1897
1899
|
await ctx.answerCallbackQuery();
|
|
1898
1900
|
});
|
|
1901
|
+
/** Build StopDeps for a session — wires the three stop side-effects. */
|
|
1902
|
+
function buildStopDeps(session) {
|
|
1903
|
+
return {
|
|
1904
|
+
interruptQuery: () => interruptQuery(session),
|
|
1905
|
+
killDetachedAgents: () => killSessionDetachedAgents(session),
|
|
1906
|
+
clearPendingForSession: (k) => cancelPendingForSession(k),
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1899
1909
|
bot.command("cancel", async (ctx) => {
|
|
1900
1910
|
const userId = ctx.from.id;
|
|
1901
|
-
|
|
1911
|
+
// Use buildSessionKey so the resolved session is always the SAME object
|
|
1912
|
+
// that message.ts created (and that registered pending agents under this key).
|
|
1913
|
+
// In per-user mode this equals String(userId); in per-channel modes it
|
|
1914
|
+
// incorporates chatId — matching the key used for the ⛔ button payload.
|
|
1915
|
+
const sessionKey = buildSessionKey("telegram", ctx.chat.id, userId);
|
|
1916
|
+
const session = getSession(sessionKey);
|
|
1902
1917
|
const lang = session.language;
|
|
1903
|
-
if (session.isProcessing
|
|
1904
|
-
session
|
|
1918
|
+
if (session.isProcessing) {
|
|
1919
|
+
requestStop(session, "soft", buildStopDeps(session));
|
|
1905
1920
|
await ctx.reply(t("bot.cancel.cancelling", lang));
|
|
1906
1921
|
}
|
|
1907
1922
|
else {
|
|
1908
1923
|
await ctx.reply(t("bot.cancel.noRunning", lang));
|
|
1909
1924
|
}
|
|
1910
1925
|
});
|
|
1926
|
+
// /stopall — hard stop: interrupt query + kill detached agents + clear queue + cancel pending.
|
|
1927
|
+
bot.command("stopall", async (ctx) => {
|
|
1928
|
+
const userId = ctx.from.id;
|
|
1929
|
+
// Same canonical-key resolution as /cancel above — must match message.ts.
|
|
1930
|
+
const sessionKey = buildSessionKey("telegram", ctx.chat.id, userId);
|
|
1931
|
+
const session = getSession(sessionKey);
|
|
1932
|
+
const lang = session.language;
|
|
1933
|
+
if (session.isProcessing) {
|
|
1934
|
+
requestStop(session, "hard", buildStopDeps(session));
|
|
1935
|
+
await ctx.reply(t("bot.cancel.stoppedAll", lang));
|
|
1936
|
+
}
|
|
1937
|
+
else {
|
|
1938
|
+
await ctx.reply(t("bot.cancel.noRunning", lang));
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
// stop: inline keyboard callback — soft stop triggered from the ⛔ button
|
|
1942
|
+
// sent alongside the processing indicator in the message handler.
|
|
1943
|
+
// Payload format: "stop:<sessionKey>" where sessionKey = String(userId) in
|
|
1944
|
+
// default per-user mode (mirrors buildSessionKey used in message.ts).
|
|
1945
|
+
bot.callbackQuery(/^stop:(.+)$/, async (ctx) => {
|
|
1946
|
+
const sessionKey = ctx.match[1];
|
|
1947
|
+
const session = getSession(sessionKey);
|
|
1948
|
+
const lang = session.language;
|
|
1949
|
+
if (session.isProcessing) {
|
|
1950
|
+
requestStop(session, "soft", buildStopDeps(session));
|
|
1951
|
+
}
|
|
1952
|
+
try {
|
|
1953
|
+
await ctx.answerCallbackQuery({ text: t("bot.cancel.stoppedToast", lang) });
|
|
1954
|
+
}
|
|
1955
|
+
catch { /* harmless grammy race */ }
|
|
1956
|
+
try {
|
|
1957
|
+
await ctx.editMessageReplyMarkup({});
|
|
1958
|
+
}
|
|
1959
|
+
catch { /* harmless grammy race — message may already be gone */ }
|
|
1960
|
+
});
|
|
1911
1961
|
// /restart — trigger a PM2-managed restart by exiting the process.
|
|
1912
1962
|
// The PM2 supervisor picks up the exit and respawns with --update-env.
|
|
1913
1963
|
bot.command("restart", async (ctx) => {
|
package/dist/handlers/message.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InputFile } from "grammy";
|
|
1
|
+
import { InputFile, InlineKeyboard } from "grammy";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import { getSession, addToHistory, trackProviderUsage, buildSessionKey, getTelegramWorkspace, markSessionDirty } from "../services/session.js";
|
|
4
4
|
import { resolveWorkspaceOrDefault, getWorkspace } from "../services/workspaces.js";
|
|
@@ -229,6 +229,18 @@ export async function handleMessage(ctx) {
|
|
|
229
229
|
// for the rest of this function (it's mutated from the bypass path in
|
|
230
230
|
// another handler invocation, so the type stays `boolean | undefined`).
|
|
231
231
|
delete session._bypassAbortFired;
|
|
232
|
+
// v5.1 — Send a lightweight control message with an inline ⛔ Stop button.
|
|
233
|
+
// Payload "stop:<sessionKey>" matches the callbackQuery handler in commands.ts.
|
|
234
|
+
// Cleaned up (deleted) in the finally block regardless of how the turn ends.
|
|
235
|
+
// One message only — do NOT send if the chat can't receive replies.
|
|
236
|
+
let stopMsgId = null;
|
|
237
|
+
try {
|
|
238
|
+
const stopMsg = await ctx.reply("⏳", {
|
|
239
|
+
reply_markup: new InlineKeyboard().text("⛔ Stop", `stop:${sessionKey}`),
|
|
240
|
+
});
|
|
241
|
+
stopMsgId = stopMsg.message_id;
|
|
242
|
+
}
|
|
243
|
+
catch { /* harmless — typing indicator remains, button is best-effort */ }
|
|
232
244
|
const streamer = new TelegramStreamer(ctx.chat.id, ctx.api, ctx.message?.message_id);
|
|
233
245
|
let finalText = "";
|
|
234
246
|
let timedOut = false;
|
|
@@ -444,6 +456,8 @@ export async function handleMessage(ctx) {
|
|
|
444
456
|
userId,
|
|
445
457
|
sessionKey,
|
|
446
458
|
} : undefined,
|
|
459
|
+
// v5.1 — Store the SDK query handle so requestStop() can interrupt it.
|
|
460
|
+
onQueryHandle: (q) => { session._qHandle = q; },
|
|
447
461
|
};
|
|
448
462
|
// Stream response from provider (with fallback)
|
|
449
463
|
let lastBroadcastLen = 0;
|
|
@@ -459,6 +473,11 @@ export async function handleMessage(ctx) {
|
|
|
459
473
|
// This is the second half of the empty-stream-loop fix.
|
|
460
474
|
let sessionResetInStream = false;
|
|
461
475
|
for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
|
|
476
|
+
// v5.1 — Bail as soon as requestStop() marks the session. The registry's
|
|
477
|
+
// outer loop already guards against new provider attempts; this guard
|
|
478
|
+
// drains the current generator's remaining chunks immediately.
|
|
479
|
+
if (session._stopRequested)
|
|
480
|
+
break;
|
|
462
481
|
// v4.12.1 — Update pending-sync-task state FIRST so the timer's
|
|
463
482
|
// next reset picks up the new state. This ordering is load-bearing:
|
|
464
483
|
// reversing it means the timer rearms with stale state. A sync
|
|
@@ -625,6 +644,14 @@ export async function handleMessage(ctx) {
|
|
|
625
644
|
break;
|
|
626
645
|
}
|
|
627
646
|
}
|
|
647
|
+
// v5.1 stop: user stopped this query — do NOT finalize partial output
|
|
648
|
+
// as a successful answer, no 👍, no history commit. The stop trigger
|
|
649
|
+
// (/cancel | /stopall | ⛔ button) already acknowledged to the user.
|
|
650
|
+
// The `finally` still runs (clears isProcessing/_qHandle/_stopRequested
|
|
651
|
+
// + typing indicator).
|
|
652
|
+
if (session._stopRequested) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
628
655
|
if (bypassAborted) {
|
|
629
656
|
// v4.12.3 — Bypass path took over; don't finalize, don't react 👍.
|
|
630
657
|
// Just clean up and return. The finally block still fires.
|
|
@@ -699,6 +726,17 @@ export async function handleMessage(ctx) {
|
|
|
699
726
|
clearInterval(typingInterval);
|
|
700
727
|
session.isProcessing = false;
|
|
701
728
|
session.abortController = null;
|
|
729
|
+
// v5.1 — Clear stop-hardening state so the next turn starts clean.
|
|
730
|
+
session._qHandle = null;
|
|
731
|
+
session._stopRequested = null;
|
|
732
|
+
// v5.1 — Remove the ⛔ Stop control message (sent at processing start).
|
|
733
|
+
// Best-effort: if it was already deleted or the bot lacks permission, ignore.
|
|
734
|
+
if (stopMsgId !== null) {
|
|
735
|
+
try {
|
|
736
|
+
await ctx.api.deleteMessage(ctx.chat.id, stopMsgId);
|
|
737
|
+
}
|
|
738
|
+
catch { /* harmless grammy race */ }
|
|
739
|
+
}
|
|
702
740
|
// Check for queued messages — they'll be prepended to the next real message
|
|
703
741
|
// Queue stays in session and gets consumed on next handleMessage call
|
|
704
742
|
}
|
package/dist/i18n.js
CHANGED
|
@@ -344,6 +344,18 @@ const strings = {
|
|
|
344
344
|
es: "No hay ninguna solicitud en curso.",
|
|
345
345
|
fr: "Aucune requête en cours.",
|
|
346
346
|
},
|
|
347
|
+
"bot.cancel.stoppedAll": {
|
|
348
|
+
en: "⛔ Stopped everything — running task, sub-agents and queue cancelled.",
|
|
349
|
+
de: "⛔ Alles gestoppt — laufende Aufgabe, Sub-Agents und Warteschlange abgebrochen.",
|
|
350
|
+
es: "⛔ Todo detenido — tarea en curso, sub-agentes y cola cancelados.",
|
|
351
|
+
fr: "⛔ Tout arrêté — tâche en cours, sous-agents et file annulés.",
|
|
352
|
+
},
|
|
353
|
+
"bot.cancel.stoppedToast": {
|
|
354
|
+
en: "⛔ Stopped",
|
|
355
|
+
de: "⛔ Gestoppt",
|
|
356
|
+
es: "⛔ Detenido",
|
|
357
|
+
fr: "⛔ Arrêté",
|
|
358
|
+
},
|
|
347
359
|
// /model
|
|
348
360
|
"bot.model.chooseHeader": {
|
|
349
361
|
en: "🤖 *Choose model:*",
|
|
@@ -206,10 +206,24 @@ export class ClaudeSDKProvider {
|
|
|
206
206
|
...(fallbackModel ? { fallbackModel } : {}),
|
|
207
207
|
},
|
|
208
208
|
});
|
|
209
|
+
// v5.1 — Expose the SDK query handle to the caller so requestStop()
|
|
210
|
+
// can interrupt it via q.interrupt() + transport.process.kill().
|
|
211
|
+
try {
|
|
212
|
+
options.onQueryHandle?.(q);
|
|
213
|
+
}
|
|
214
|
+
catch { /* non-fatal */ }
|
|
209
215
|
let accumulatedText = "";
|
|
210
216
|
let capturedSessionId = options.sessionId || "";
|
|
211
217
|
let localToolUseCount = 0;
|
|
212
218
|
for await (const message of q) {
|
|
219
|
+
// v5.1 — Bail immediately if the caller set abortSignal while iterating.
|
|
220
|
+
if (options.abortSignal?.aborted) {
|
|
221
|
+
try {
|
|
222
|
+
q.return?.();
|
|
223
|
+
}
|
|
224
|
+
catch { /* ignore */ }
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
213
227
|
// System init — capture session ID
|
|
214
228
|
if (message.type === "system" && "subtype" in message && message.subtype === "init") {
|
|
215
229
|
const sysMsg = message;
|
|
@@ -123,6 +123,12 @@ export class ProviderRegistry {
|
|
|
123
123
|
}
|
|
124
124
|
const errors = [];
|
|
125
125
|
for (const key of chain) {
|
|
126
|
+
// v5.1 PRIMARY FIX — bail before attempting any provider if the caller
|
|
127
|
+
// has already aborted. Without this guard, the outer fallback loop kept
|
|
128
|
+
// trying successive providers (e.g. Ollama) even after abort(), leaving
|
|
129
|
+
// the typing indicator on and wasting resources.
|
|
130
|
+
if (options.abortSignal?.aborted)
|
|
131
|
+
break;
|
|
126
132
|
const provider = this.providers.get(key);
|
|
127
133
|
if (!provider)
|
|
128
134
|
continue;
|
|
@@ -234,6 +240,12 @@ export class ProviderRegistry {
|
|
|
234
240
|
// If we got here without done or error, something's off
|
|
235
241
|
return;
|
|
236
242
|
}
|
|
243
|
+
// v5.1 stop: clean user cancellation — the outer loop broke on an
|
|
244
|
+
// aborted signal with nothing attempted. Don't surface a misleading
|
|
245
|
+
// "no provider" error; end the generator quietly (the consumer's
|
|
246
|
+
// _stopRequested handling owns the user-facing acknowledgement).
|
|
247
|
+
if (options.abortSignal?.aborted)
|
|
248
|
+
return;
|
|
237
249
|
// All providers failed — show specific errors
|
|
238
250
|
const errorDetail = errors.map(e => ` ${e.key}: ${e.error}`).join("\n");
|
|
239
251
|
yield {
|
|
@@ -99,6 +99,9 @@ export function dispatchDetachedAgent(input) {
|
|
|
99
99
|
// Detach from parent Node's event loop so parent exit doesn't wait.
|
|
100
100
|
child.unref();
|
|
101
101
|
// Register with watcher so it polls the output file and delivers.
|
|
102
|
+
// child.pid is captured here (before unref) so killSessionDetachedAgents
|
|
103
|
+
// can send SIGTERM to the process. child.pid may be undefined if the OS
|
|
104
|
+
// failed to assign a PID (extremely rare); registerPendingAgent tolerates it.
|
|
102
105
|
registerPendingAgent({
|
|
103
106
|
agentId,
|
|
104
107
|
outputFile,
|
|
@@ -109,6 +112,7 @@ export function dispatchDetachedAgent(input) {
|
|
|
109
112
|
toolUseId: null,
|
|
110
113
|
sessionKey: input.sessionKey,
|
|
111
114
|
platform: input.platform,
|
|
115
|
+
pid: child.pid,
|
|
112
116
|
});
|
|
113
117
|
// Increment the session's pendingBackgroundCount so the main handler
|
|
114
118
|
// knows a background task is in flight (same signal path as SDK's
|
|
@@ -131,6 +131,7 @@ export function registerPendingAgent(input) {
|
|
|
131
131
|
toolUseId: input.toolUseId,
|
|
132
132
|
sessionKey: input.sessionKey,
|
|
133
133
|
platform: input.platform,
|
|
134
|
+
pid: input.pid,
|
|
134
135
|
};
|
|
135
136
|
enforcePendingCap();
|
|
136
137
|
pending.set(input.agentId, entry);
|
|
@@ -281,6 +282,66 @@ async function deliverAsFailure(entry, status, error) {
|
|
|
281
282
|
decrementPendingCount(entry.sessionKey);
|
|
282
283
|
}
|
|
283
284
|
// ── Test helpers ──────────────────────────────────────────────────
|
|
285
|
+
/**
|
|
286
|
+
* v5.1 — Kill detached sub-agent processes scoped to a session.
|
|
287
|
+
*
|
|
288
|
+
* Reads the canonical session key from `session.sessionKey` (stamped by
|
|
289
|
+
* getSession() / loadPersistedSessions() — always a real string, never null
|
|
290
|
+
* after the v5.1.x fix). Iterates watcher entries whose `sessionKey` matches
|
|
291
|
+
* and calls `killFn(entry.pid)` for entries that have a pid.
|
|
292
|
+
*
|
|
293
|
+
* The function accepts an injectable `killFn` for testability. The default
|
|
294
|
+
* implementation sends SIGTERM; callers in tests pass a spy.
|
|
295
|
+
*
|
|
296
|
+
* Never throws — all per-entry errors are swallowed.
|
|
297
|
+
*/
|
|
298
|
+
export function killSessionDetachedAgents(session, killFn = (p) => {
|
|
299
|
+
try {
|
|
300
|
+
process.kill(p, "SIGTERM");
|
|
301
|
+
}
|
|
302
|
+
catch { /* already gone */ }
|
|
303
|
+
}) {
|
|
304
|
+
// Use session.sessionKey — the real canonical key stamped by getSession().
|
|
305
|
+
// Before v5.1.x this field did not exist on UserSession, causing a silent
|
|
306
|
+
// no-op; the fix in session.ts guarantees it is always a non-empty string.
|
|
307
|
+
const key = session.sessionKey ?? null;
|
|
308
|
+
if (key === null)
|
|
309
|
+
return;
|
|
310
|
+
for (const entry of pending.values()) {
|
|
311
|
+
if (entry.sessionKey !== key)
|
|
312
|
+
continue;
|
|
313
|
+
if (typeof entry.pid === "number") {
|
|
314
|
+
try {
|
|
315
|
+
killFn(entry.pid);
|
|
316
|
+
}
|
|
317
|
+
catch { /* best-effort */ }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* v5.1 — Mark/remove all pending watcher entries for a session so the
|
|
323
|
+
* watcher does NOT deliver them after a hard stop.
|
|
324
|
+
*
|
|
325
|
+
* Mirrors the pattern used in pollOnce(): remove from the in-memory map
|
|
326
|
+
* and persist immediately. This is a remove-not-flag approach because
|
|
327
|
+
* there is no `cancelled` flag on PendingAsyncAgent and adding one would
|
|
328
|
+
* require touching all poll-cycle delivery paths.
|
|
329
|
+
*
|
|
330
|
+
* Scoped strictly to entries whose `sessionKey` matches — never global,
|
|
331
|
+
* never name-based. Never throws.
|
|
332
|
+
*/
|
|
333
|
+
export function cancelPendingForSession(sessionKey) {
|
|
334
|
+
let changed = false;
|
|
335
|
+
for (const [id, entry] of pending.entries()) {
|
|
336
|
+
if (entry.sessionKey === sessionKey) {
|
|
337
|
+
pending.delete(id);
|
|
338
|
+
changed = true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (changed) {
|
|
342
|
+
saveToDisk();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
284
345
|
/** Test-only: drop in-memory state. Doesn't touch disk. */
|
|
285
346
|
export function __resetForTest() {
|
|
286
347
|
pending.clear();
|
|
@@ -171,11 +171,17 @@ export function loadPersistedSessions() {
|
|
|
171
171
|
// Build a UserSession from the persisted shape, filling defaults for any
|
|
172
172
|
// fields added in newer schema versions.
|
|
173
173
|
const restored = {
|
|
174
|
+
// v5.1.x — Canonical registry key stamped so stop-controller and watcher
|
|
175
|
+
// helpers can read session.sessionKey regardless of how the session was
|
|
176
|
+
// created (direct access or rehydrated from disk).
|
|
177
|
+
sessionKey: key,
|
|
174
178
|
sessionId: persisted.sessionId ?? null,
|
|
175
179
|
workingDir: persisted.workingDir ?? process.cwd(),
|
|
176
180
|
workspaceName: persisted.workspaceName ?? null,
|
|
177
181
|
isProcessing: false,
|
|
178
182
|
abortController: null,
|
|
183
|
+
_stopRequested: null,
|
|
184
|
+
_qHandle: null,
|
|
179
185
|
lastActivity: persisted.lastActivity ?? Date.now(),
|
|
180
186
|
startedAt: persisted.startedAt ?? Date.now(),
|
|
181
187
|
totalCost: persisted.totalCost ?? 0,
|
package/dist/services/session.js
CHANGED
|
@@ -74,11 +74,14 @@ export function getSession(key) {
|
|
|
74
74
|
let session = sessions.get(k);
|
|
75
75
|
if (!session) {
|
|
76
76
|
session = {
|
|
77
|
+
sessionKey: k,
|
|
77
78
|
sessionId: null,
|
|
78
79
|
workingDir: config.defaultWorkingDir,
|
|
79
80
|
workspaceName: null,
|
|
80
81
|
isProcessing: false,
|
|
81
82
|
abortController: null,
|
|
83
|
+
_stopRequested: null,
|
|
84
|
+
_qHandle: null,
|
|
82
85
|
lastActivity: Date.now(),
|
|
83
86
|
startedAt: Date.now(),
|
|
84
87
|
totalCost: 0,
|
|
@@ -106,6 +109,11 @@ export function getSession(key) {
|
|
|
106
109
|
// Touch lastActivity on every access so the cleanup interval
|
|
107
110
|
// never kills a session that's still being interacted with.
|
|
108
111
|
session.lastActivity = Date.now();
|
|
112
|
+
// Idempotent: stamp sessionKey on existing sessions that predate this field
|
|
113
|
+
// (e.g. sessions loaded from persistence before the field existed).
|
|
114
|
+
if (!session.sessionKey) {
|
|
115
|
+
session.sessionKey = k;
|
|
116
|
+
}
|
|
109
117
|
}
|
|
110
118
|
return session;
|
|
111
119
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Teeth: interrupt the running SDK query. SDK hides the pid, so we use
|
|
2
|
+
* its own interrupt() control-message + transport process kill fallback.
|
|
3
|
+
* Both best-effort; never throws. */
|
|
4
|
+
export function interruptQuery(session) {
|
|
5
|
+
try {
|
|
6
|
+
session._qHandle?.interrupt?.();
|
|
7
|
+
}
|
|
8
|
+
catch { /* already finished */ }
|
|
9
|
+
try {
|
|
10
|
+
session._qHandle?.transport?.process?.kill?.();
|
|
11
|
+
}
|
|
12
|
+
catch { /* n/a */ }
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Single shared stop primitive. Synchronous, idempotent, NEVER throws into
|
|
16
|
+
* the caller. soft = abort + interrupt running query + reset JS state.
|
|
17
|
+
* hard = soft + kill detached sub-agents + clear queue + cancel pending.
|
|
18
|
+
*
|
|
19
|
+
* Post Task-1: the SDK hides the subprocess PID, so the teeth are
|
|
20
|
+
* q.interrupt() (via deps.interruptQuery), NOT a process-group SIGKILL.
|
|
21
|
+
*/
|
|
22
|
+
export function requestStop(session, tier, deps) {
|
|
23
|
+
// 1. Signal abort (best-effort; already fast per Task-1 evidence).
|
|
24
|
+
try {
|
|
25
|
+
session.abortController?.abort();
|
|
26
|
+
}
|
|
27
|
+
catch { /* idempotent */ }
|
|
28
|
+
// 2. Teeth — interrupt the running SDK query.
|
|
29
|
+
try {
|
|
30
|
+
deps.interruptQuery();
|
|
31
|
+
}
|
|
32
|
+
catch { /* already finished / never throw */ }
|
|
33
|
+
// 3. Mark stop so consumer loop + provider + registry fallback bail.
|
|
34
|
+
session._stopRequested = tier;
|
|
35
|
+
// 4. Hard tier extras.
|
|
36
|
+
if (tier === "hard") {
|
|
37
|
+
try {
|
|
38
|
+
deps.killDetachedAgents();
|
|
39
|
+
}
|
|
40
|
+
catch { /* best-effort */ }
|
|
41
|
+
if (Array.isArray(session.messageQueue))
|
|
42
|
+
session.messageQueue.length = 0;
|
|
43
|
+
try {
|
|
44
|
+
deps.clearPendingForSession(session.sessionKey);
|
|
45
|
+
}
|
|
46
|
+
catch { /* best-effort */ }
|
|
47
|
+
}
|
|
48
|
+
// 5. Deterministic JS-state teardown.
|
|
49
|
+
session.isProcessing = false;
|
|
50
|
+
session.abortController = null;
|
|
51
|
+
session._qHandle = null;
|
|
52
|
+
}
|