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.
@@ -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);