shadok-ai 0.17.4 → 0.17.5
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/dist/live-handover.d.ts +47 -0
- package/dist/live-handover.js +58 -0
- package/dist/live-handover.js.map +1 -0
- package/dist/server.js +30 -1
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { WebSocket } from "ws";
|
|
2
|
+
/**
|
|
3
|
+
* The subset of a `Live` that a handover reads and mutates. Kept structural so
|
|
4
|
+
* the server's full `Live` satisfies it with no cast, and a test can build a
|
|
5
|
+
* fake. It DELIBERATELY omits `pilot`, `cwd`, `id` and `transcriptHome`: the
|
|
6
|
+
* handover must never be able to kill the agent or clear the shared tail
|
|
7
|
+
* position, and leaving those fields out of the type makes that a compile-time
|
|
8
|
+
* guarantee rather than a comment someone can regress past.
|
|
9
|
+
*/
|
|
10
|
+
export interface HandoverLive {
|
|
11
|
+
clients: Set<WebSocket>;
|
|
12
|
+
rawClients?: Set<WebSocket>;
|
|
13
|
+
screenTimer: ReturnType<typeof setInterval> | null;
|
|
14
|
+
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
15
|
+
retryTimer: ReturnType<typeof setTimeout> | null;
|
|
16
|
+
stopTail: (() => void) | null;
|
|
17
|
+
rawOff?: (() => void) | null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Hand a superseded `Live`'s viewers onto the `Live` that replaced it, and quiet
|
|
21
|
+
* the old one WITHOUT touching the agent. Returns how many viewers moved.
|
|
22
|
+
*
|
|
23
|
+
* The bug (shared-ledger [5d8d]): when a session's pilot has exited but the
|
|
24
|
+
* channel still has web/Telegram viewers attached, a resume takes the
|
|
25
|
+
* `createSession` path and `sessions.set(id, newLive)` REPLACES the registry
|
|
26
|
+
* entry — leaving the old `Live`, with its clients, orphaned. Its tail keeps
|
|
27
|
+
* running on the same transcript, so those clients still see the agent's
|
|
28
|
+
* ANSWERS, but every `broadcast` now targets the new `Live`: they miss the other
|
|
29
|
+
* interfaces' prompt echoes (the reported symptom: Telegram messages invisible
|
|
30
|
+
* on the web until reload), dialogs, and working/turn-done. Two `Live` for one
|
|
31
|
+
* agent; "server code correct in isolation", the split is the cause.
|
|
32
|
+
*
|
|
33
|
+
* The cure is one `Live` per agent: hand the viewers over and tear the old one
|
|
34
|
+
* down. Teardown here is DELIBERATELY not `destroySession`. The new `Live` may
|
|
35
|
+
* have adopted the very same tmux pane — a `hasExited` that latched on a single
|
|
36
|
+
* transient probe (invariants 23/25) leaves the pane alive — so killing the old
|
|
37
|
+
* pilot would kill the live agent, and clearing the shared tail position would
|
|
38
|
+
* make the new tail replay the whole transcript from byte 0. So we stop the old
|
|
39
|
+
* `Live`'s own tail and timers and detach its raw pipe, and nothing else. (The
|
|
40
|
+
* type can't express `pilot.kill()` or `clearTailPos` anyway — see `HandoverLive`.)
|
|
41
|
+
*
|
|
42
|
+
* Raw terminal viewers are NOT re-wired onto the new pilot's pipe: the raw
|
|
43
|
+
* subscription is per-pilot and is rebuilt when the engine room is next opened.
|
|
44
|
+
* Moving the chat `clients` set is what the reported bug needs; the old raw pipe
|
|
45
|
+
* is simply turned off so the retired `Live` goes fully quiet.
|
|
46
|
+
*/
|
|
47
|
+
export declare function supersedeSession(old: HandoverLive, next: HandoverLive): number;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand a superseded `Live`'s viewers onto the `Live` that replaced it, and quiet
|
|
3
|
+
* the old one WITHOUT touching the agent. Returns how many viewers moved.
|
|
4
|
+
*
|
|
5
|
+
* The bug (shared-ledger [5d8d]): when a session's pilot has exited but the
|
|
6
|
+
* channel still has web/Telegram viewers attached, a resume takes the
|
|
7
|
+
* `createSession` path and `sessions.set(id, newLive)` REPLACES the registry
|
|
8
|
+
* entry — leaving the old `Live`, with its clients, orphaned. Its tail keeps
|
|
9
|
+
* running on the same transcript, so those clients still see the agent's
|
|
10
|
+
* ANSWERS, but every `broadcast` now targets the new `Live`: they miss the other
|
|
11
|
+
* interfaces' prompt echoes (the reported symptom: Telegram messages invisible
|
|
12
|
+
* on the web until reload), dialogs, and working/turn-done. Two `Live` for one
|
|
13
|
+
* agent; "server code correct in isolation", the split is the cause.
|
|
14
|
+
*
|
|
15
|
+
* The cure is one `Live` per agent: hand the viewers over and tear the old one
|
|
16
|
+
* down. Teardown here is DELIBERATELY not `destroySession`. The new `Live` may
|
|
17
|
+
* have adopted the very same tmux pane — a `hasExited` that latched on a single
|
|
18
|
+
* transient probe (invariants 23/25) leaves the pane alive — so killing the old
|
|
19
|
+
* pilot would kill the live agent, and clearing the shared tail position would
|
|
20
|
+
* make the new tail replay the whole transcript from byte 0. So we stop the old
|
|
21
|
+
* `Live`'s own tail and timers and detach its raw pipe, and nothing else. (The
|
|
22
|
+
* type can't express `pilot.kill()` or `clearTailPos` anyway — see `HandoverLive`.)
|
|
23
|
+
*
|
|
24
|
+
* Raw terminal viewers are NOT re-wired onto the new pilot's pipe: the raw
|
|
25
|
+
* subscription is per-pilot and is rebuilt when the engine room is next opened.
|
|
26
|
+
* Moving the chat `clients` set is what the reported bug needs; the old raw pipe
|
|
27
|
+
* is simply turned off so the retired `Live` goes fully quiet.
|
|
28
|
+
*/
|
|
29
|
+
export function supersedeSession(old, next) {
|
|
30
|
+
let moved = 0;
|
|
31
|
+
for (const c of old.clients) {
|
|
32
|
+
next.clients.add(c);
|
|
33
|
+
moved++;
|
|
34
|
+
}
|
|
35
|
+
old.clients.clear();
|
|
36
|
+
// Quiet the retired Live: its own tail (so one transcript is not tailed
|
|
37
|
+
// twice — the "two tails share one pos file" side effect noted in [5d8d]),
|
|
38
|
+
// its timers, and its raw pipe. Never the pilot, never the tail position.
|
|
39
|
+
if (old.screenTimer) {
|
|
40
|
+
clearInterval(old.screenTimer);
|
|
41
|
+
old.screenTimer = null;
|
|
42
|
+
}
|
|
43
|
+
if (old.idleTimer) {
|
|
44
|
+
clearTimeout(old.idleTimer);
|
|
45
|
+
old.idleTimer = null;
|
|
46
|
+
}
|
|
47
|
+
if (old.retryTimer) {
|
|
48
|
+
clearTimeout(old.retryTimer);
|
|
49
|
+
old.retryTimer = null;
|
|
50
|
+
}
|
|
51
|
+
old.stopTail?.();
|
|
52
|
+
old.stopTail = null;
|
|
53
|
+
old.rawOff?.();
|
|
54
|
+
old.rawOff = null;
|
|
55
|
+
old.rawClients?.clear();
|
|
56
|
+
return moved;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=live-handover.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"live-handover.js","sourceRoot":"","sources":["../src/live-handover.ts"],"names":[],"mappings":"AAoBA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAiB,EAAE,IAAkB;IACpE,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,MAAM,CAAC,IAAI,GAAG,CAAC,OAAO,EAAE,CAAC;QAC5B,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QACpB,KAAK,EAAE,CAAC;IACV,CAAC;IACD,GAAG,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;IAEpB,wEAAwE;IACxE,2EAA2E;IAC3E,0EAA0E;IAC1E,IAAI,GAAG,CAAC,WAAW,EAAE,CAAC;QACpB,aAAa,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QAC/B,GAAG,CAAC,WAAW,GAAG,IAAI,CAAC;IACzB,CAAC;IACD,IAAI,GAAG,CAAC,SAAS,EAAE,CAAC;QAClB,YAAY,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QAC5B,GAAG,CAAC,SAAS,GAAG,IAAI,CAAC;IACvB,CAAC;IACD,IAAI,GAAG,CAAC,UAAU,EAAE,CAAC;QACnB,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;QAC7B,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC;IACxB,CAAC;IACD,GAAG,CAAC,QAAQ,EAAE,EAAE,CAAC;IACjB,GAAG,CAAC,QAAQ,GAAG,IAAI,CAAC;IACpB,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC;IACf,GAAG,CAAC,MAAM,GAAG,IAAI,CAAC;IAClB,GAAG,CAAC,UAAU,EAAE,KAAK,EAAE,CAAC;IAExB,OAAO,KAAK,CAAC;AACf,CAAC"}
|
package/dist/server.js
CHANGED
|
@@ -65,6 +65,7 @@ import { getProfile, profileArgs, permissionModeArgs, isPermissionMode, isEffort
|
|
|
65
65
|
import { BOOTSTRAP_ADMIN, loadAccounts, verifyPassword, sessionSecret, signSession, readSessionFull, sessionAlive, saveAccounts, userWriteVerdict, newInvite, inviteVerdict, hashPassword, promptAuthor, signSessionKey, readSessionKey, signTalkHandle, readTalkHandle, } from "./accounts.js";
|
|
66
66
|
import { admins2fa, authenticationOptions, enrollCodeVerdict, isInternalClient, keepsTwoAdminsVerdict, loginNext, newEnrollCode, publicPasskey, putChallenge, readPending, registrationOptions, resetVerdict, signPending, takeChallenge, twoFactorEnableVerdict, verifyAuthentication, verifyRegistration, PENDING_TTL_MS, } from "./twofactor.js";
|
|
67
67
|
import { ensureSelfRepo } from "./selfrepo.js";
|
|
68
|
+
import { supersedeSession } from "./live-handover.js";
|
|
68
69
|
import { createWorktree, pruneWorktree, ensureWorktreeCheckout, gitDiff, isGitRepo, listPastSessions, } from "./worktree.js";
|
|
69
70
|
import { latestVersion, update } from "./updater.js";
|
|
70
71
|
import { resolveChannel } from "./update-channel.js";
|
|
@@ -6092,8 +6093,11 @@ wss.on("connection", (ws, req) => {
|
|
|
6092
6093
|
// so a server updated underneath it refreshes the page automatically.
|
|
6093
6094
|
send(versionMessage());
|
|
6094
6095
|
ws.on("close", () => {
|
|
6096
|
+
// Detach from the CURRENT Live for this id, not the closure's possibly
|
|
6097
|
+
// retired one: a handover ([5d8d]) moved this ws onto the new Live, so
|
|
6098
|
+
// detaching the old object would leak the ws in the live one's client set.
|
|
6095
6099
|
if (session)
|
|
6096
|
-
detach(ws, session);
|
|
6100
|
+
detach(ws, sessions.get(session.id) ?? session);
|
|
6097
6101
|
session = null;
|
|
6098
6102
|
});
|
|
6099
6103
|
ws.on("message", async (raw) => {
|
|
@@ -6105,6 +6109,16 @@ wss.on("connection", (ws, req) => {
|
|
|
6105
6109
|
return fail("unreadable message");
|
|
6106
6110
|
}
|
|
6107
6111
|
try {
|
|
6112
|
+
// This connection can outlive the Live it attached to: a resume that
|
|
6113
|
+
// supersedes an exited-but-watched session (see `supersedeSession`,
|
|
6114
|
+
// [5d8d]) moved this ws onto the NEW Live, but this closure still points
|
|
6115
|
+
// at the retired one. Re-point it so every handler below — and the
|
|
6116
|
+
// takeover/retry logic — acts on the single real Live for this id.
|
|
6117
|
+
if (session) {
|
|
6118
|
+
const current = sessions.get(session.id);
|
|
6119
|
+
if (current && current !== session)
|
|
6120
|
+
session = current;
|
|
6121
|
+
}
|
|
6108
6122
|
// Any user takeover cancels a pending auto-retry and ends the streak.
|
|
6109
6123
|
// "prompt" is settled inside its own case instead: a prompt refused on
|
|
6110
6124
|
// pace grounds sends nothing, so it must not count as a takeover — it
|
|
@@ -6457,6 +6471,21 @@ wss.on("connection", (ws, req) => {
|
|
|
6457
6471
|
if (mtMsg)
|
|
6458
6472
|
return fail(mtMsg);
|
|
6459
6473
|
session = await createSession(id, effectiveCwd, args, worktree, profile, model, effort, spawnActor({ account: me?.name, origin: msg.origin, peer: callerPeer, parent: parentAtStart, agent: callerAgent?.name }), dropUser, agentGroupId);
|
|
6474
|
+
// One Live per agent (shared-ledger [5d8d]). We only reach here when
|
|
6475
|
+
// there was no live session to attach to — but `existing` may be a
|
|
6476
|
+
// RETIRED Live whose pilot has exited while viewers are still
|
|
6477
|
+
// attached (a web tab, a Telegram bridge). `createSession` has just
|
|
6478
|
+
// replaced it in the registry; without a handover those viewers are
|
|
6479
|
+
// orphaned on the old object — still fed the agent's answers by its
|
|
6480
|
+
// lingering tail, but cut off from every broadcast on the new Live
|
|
6481
|
+
// (the other interfaces' prompt echoes, dialogs, working/turn-done).
|
|
6482
|
+
// Move them onto the new Live and quiet the old one — without killing
|
|
6483
|
+
// the agent, which the new Live may now be driving on the same pane.
|
|
6484
|
+
if (existing && existing !== session) {
|
|
6485
|
+
const moved = supersedeSession(existing, session);
|
|
6486
|
+
if (moved)
|
|
6487
|
+
console.log(`session ${id.slice(0, 8)}: superseded a retired Live, moved ${moved} viewer(s)`);
|
|
6488
|
+
}
|
|
6460
6489
|
session.clients.add(ws);
|
|
6461
6490
|
if (resumed) {
|
|
6462
6491
|
const turns = historyWithOffers(loadHistory(effectiveCwd, id, session.transcriptHome), id);
|