@threadbase-sh/streamer 1.40.0 → 1.41.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/README.md +1 -1
- package/dist/cli.cjs +308 -56
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +306 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +306 -54
- package/dist/index.js.map +1 -1
- package/dist/{migrations/010_create_managed_sessions.sql → runtime-migrations/001_create_managed_sessions.sql} +6 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -448,6 +448,12 @@ var FEATURE_FLAGS = [
|
|
|
448
448
|
description: "Send the built system prompt to fresh Codex sessions. Off by default: Codex has no --system-prompt flag, so the prompt goes in the positional [PROMPT] argument, which Codex treats as the user's opening turn rather than a system-level instruction.",
|
|
449
449
|
default: false,
|
|
450
450
|
env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
id: "sessionRehydration",
|
|
454
|
+
description: "Seed the session list at boot with sessions a previous streamer run left behind, so a restart leaves them one tap from resuming instead of silently gone. On by default, with a kill switch: it changes what GET /api/sessions contains.",
|
|
455
|
+
default: true,
|
|
456
|
+
env: "THREADBASE_FEATURE_SESSION_REHYDRATION"
|
|
451
457
|
}
|
|
452
458
|
];
|
|
453
459
|
function findFeatureFlag(id) {
|
|
@@ -1883,6 +1889,12 @@ function detectShellPrompt(lines) {
|
|
|
1883
1889
|
return null;
|
|
1884
1890
|
}
|
|
1885
1891
|
|
|
1892
|
+
// src/utils/deriveSessionName.ts
|
|
1893
|
+
function deriveSessionName(firstMessageText) {
|
|
1894
|
+
const firstLine = firstMessageText.split("\n", 1)[0]?.trim() ?? "";
|
|
1895
|
+
return firstLine.slice(0, 80);
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1886
1898
|
// src/pty-manager.ts
|
|
1887
1899
|
var OUTPUT_BUFFER_MAX2 = 65536;
|
|
1888
1900
|
var INPUT_HISTORY_MAX2 = 50;
|
|
@@ -2356,6 +2368,10 @@ var PTYManager = class {
|
|
|
2356
2368
|
if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
|
|
2357
2369
|
session.inputHistory.shift();
|
|
2358
2370
|
}
|
|
2371
|
+
if (session.firstMessageText === void 0) {
|
|
2372
|
+
session.firstMessageText = text;
|
|
2373
|
+
session.sessionName = deriveSessionName(text);
|
|
2374
|
+
}
|
|
2359
2375
|
this.onUserMessage?.(session.id, text, ts);
|
|
2360
2376
|
}
|
|
2361
2377
|
getSession(sessionId) {
|
|
@@ -2621,7 +2637,9 @@ function toPublicSession2(s) {
|
|
|
2621
2637
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
2622
2638
|
...s.statusSource != null && { statusSource: s.statusSource },
|
|
2623
2639
|
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
2624
|
-
...s.filePath != null && { filePath: s.filePath }
|
|
2640
|
+
...s.filePath != null && { filePath: s.filePath },
|
|
2641
|
+
...s.sessionName != null && { sessionName: s.sessionName },
|
|
2642
|
+
...s.firstMessageText != null && { firstMessageText: s.firstMessageText }
|
|
2625
2643
|
};
|
|
2626
2644
|
}
|
|
2627
2645
|
function stripAnsi2(str) {
|
|
@@ -5141,6 +5159,9 @@ function getMigrationsDir2() {
|
|
|
5141
5159
|
}
|
|
5142
5160
|
return __dirname;
|
|
5143
5161
|
}
|
|
5162
|
+
function resolveMigrationsDir(name = "migrations") {
|
|
5163
|
+
return join12(getMigrationsDir2(), name);
|
|
5164
|
+
}
|
|
5144
5165
|
var SCHEMA_MIGRATIONS_SQL = `
|
|
5145
5166
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
5146
5167
|
id TEXT PRIMARY KEY,
|
|
@@ -5149,7 +5170,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
|
5149
5170
|
`;
|
|
5150
5171
|
function runSqliteMigrations(db, migrationsDir) {
|
|
5151
5172
|
db.exec(SCHEMA_MIGRATIONS_SQL);
|
|
5152
|
-
const dir = migrationsDir ??
|
|
5173
|
+
const dir = migrationsDir ?? resolveMigrationsDir();
|
|
5153
5174
|
const files = readdirSync2(dir).filter((f) => f.endsWith(".sql")).sort();
|
|
5154
5175
|
const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
|
|
5155
5176
|
const appliedSet = new Set(appliedRows.map((r) => r.id));
|
|
@@ -6560,6 +6581,7 @@ var ManagedSessionsRepository = class {
|
|
|
6560
6581
|
updateStatusStmt;
|
|
6561
6582
|
getStmt;
|
|
6562
6583
|
listNonTerminalStmt;
|
|
6584
|
+
listRecoverableStmt;
|
|
6563
6585
|
deleteStmt;
|
|
6564
6586
|
constructor(db) {
|
|
6565
6587
|
this.upsertStmt = db.prepare(`
|
|
@@ -6612,6 +6634,13 @@ var ManagedSessionsRepository = class {
|
|
|
6612
6634
|
WHERE completed_at IS NULL
|
|
6613
6635
|
ORDER BY started_at ASC
|
|
6614
6636
|
`);
|
|
6637
|
+
this.listRecoverableStmt = db.prepare(`
|
|
6638
|
+
SELECT * FROM managed_sessions
|
|
6639
|
+
WHERE (completed_at IS NULL OR status_source = 'shutdown')
|
|
6640
|
+
AND status_updated_at >= @since
|
|
6641
|
+
ORDER BY status_updated_at DESC
|
|
6642
|
+
LIMIT @limit
|
|
6643
|
+
`);
|
|
6615
6644
|
this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
|
|
6616
6645
|
}
|
|
6617
6646
|
/** Record a session at spawn, or refresh every field of an existing row. */
|
|
@@ -6663,6 +6692,14 @@ var ManagedSessionsRepository = class {
|
|
|
6663
6692
|
listNonTerminal() {
|
|
6664
6693
|
return this.listNonTerminalStmt.all();
|
|
6665
6694
|
}
|
|
6695
|
+
/**
|
|
6696
|
+
* Rows a restart could bring back: still open, or closed by our own shutdown,
|
|
6697
|
+
* and touched no longer ago than `sinceMs`. Newest first, capped — the caller
|
|
6698
|
+
* decides which of these actually deserve rehydrating (`shouldRehydrate`).
|
|
6699
|
+
*/
|
|
6700
|
+
listRecoverable({ sinceMs, limit }) {
|
|
6701
|
+
return this.listRecoverableStmt.all({ since: sinceMs, limit });
|
|
6702
|
+
}
|
|
6666
6703
|
delete(sessionId) {
|
|
6667
6704
|
this.deleteStmt.run(sessionId);
|
|
6668
6705
|
}
|
|
@@ -6798,6 +6835,54 @@ var SessionsRepository = class {
|
|
|
6798
6835
|
}
|
|
6799
6836
|
};
|
|
6800
6837
|
|
|
6838
|
+
// src/db/runtime-store.ts
|
|
6839
|
+
import Database2 from "better-sqlite3";
|
|
6840
|
+
var RuntimeStore = class _RuntimeStore {
|
|
6841
|
+
constructor(db) {
|
|
6842
|
+
this.db = db;
|
|
6843
|
+
}
|
|
6844
|
+
db;
|
|
6845
|
+
static open(dbPath, migrationsDir) {
|
|
6846
|
+
const db = new Database2(dbPath);
|
|
6847
|
+
db.pragma("journal_mode = WAL");
|
|
6848
|
+
runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
|
|
6849
|
+
return new _RuntimeStore(db);
|
|
6850
|
+
}
|
|
6851
|
+
getDatabase() {
|
|
6852
|
+
return this.db;
|
|
6853
|
+
}
|
|
6854
|
+
/**
|
|
6855
|
+
* One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
|
|
6856
|
+
*
|
|
6857
|
+
* Non-destructive by design: the source table is left in place so an older
|
|
6858
|
+
* streamer rolled back onto the same machine still finds its registry. Runs
|
|
6859
|
+
* only when this file's table is empty, so a second boot is a no-op rather
|
|
6860
|
+
* than a re-copy that would resurrect rows deleted since.
|
|
6861
|
+
*
|
|
6862
|
+
* Returns the number of rows copied.
|
|
6863
|
+
*/
|
|
6864
|
+
importLegacyManagedSessions(source) {
|
|
6865
|
+
const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
|
|
6866
|
+
if (existing.n > 0) return 0;
|
|
6867
|
+
const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
|
|
6868
|
+
if (!hasTable) return 0;
|
|
6869
|
+
const rows = source.prepare("SELECT * FROM managed_sessions").all();
|
|
6870
|
+
if (rows.length === 0) return 0;
|
|
6871
|
+
const columns = Object.keys(rows[0]);
|
|
6872
|
+
const insert = this.db.prepare(
|
|
6873
|
+
`INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
|
|
6874
|
+
VALUES (${columns.map((c) => `@${c}`).join(", ")})`
|
|
6875
|
+
);
|
|
6876
|
+
this.db.transaction((batch) => {
|
|
6877
|
+
for (const row of batch) insert.run(row);
|
|
6878
|
+
})(rows);
|
|
6879
|
+
return rows.length;
|
|
6880
|
+
}
|
|
6881
|
+
close() {
|
|
6882
|
+
this.db.close();
|
|
6883
|
+
}
|
|
6884
|
+
};
|
|
6885
|
+
|
|
6801
6886
|
// src/db/upload-records.ts
|
|
6802
6887
|
async function recordUpload(pool2, instanceId, row) {
|
|
6803
6888
|
if (!pool2) return;
|
|
@@ -7815,7 +7900,8 @@ function contentStateForSession(args) {
|
|
|
7815
7900
|
status,
|
|
7816
7901
|
startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
|
|
7817
7902
|
lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
|
|
7818
|
-
...args.serverLabel != null && { serverLabel: args.serverLabel }
|
|
7903
|
+
...args.serverLabel != null && { serverLabel: args.serverLabel },
|
|
7904
|
+
...args.session.sessionName != null && { sessionName: args.session.sessionName }
|
|
7819
7905
|
};
|
|
7820
7906
|
}
|
|
7821
7907
|
var LiveActivityNotifier = class {
|
|
@@ -7828,14 +7914,17 @@ var LiveActivityNotifier = class {
|
|
|
7828
7914
|
serverId;
|
|
7829
7915
|
serverLabel;
|
|
7830
7916
|
/**
|
|
7831
|
-
*
|
|
7917
|
+
* Sessions with a currently open (pushed) activity.
|
|
7832
7918
|
*
|
|
7833
|
-
*
|
|
7834
|
-
*
|
|
7835
|
-
*
|
|
7836
|
-
*
|
|
7919
|
+
* An activity opens on a `waiting_input → running` edge (the user sent a
|
|
7920
|
+
* prompt) and closes on the matching `running → waiting_input` edge (the
|
|
7921
|
+
* response, including any sub-agents, finished) — so this set is what makes
|
|
7922
|
+
* the notifier per-turn rather than per-session. A session's very first
|
|
7923
|
+
* `running` (right after spawn, before any user prompt) has no prior
|
|
7924
|
+
* `waiting_input` and therefore no edge, so it never opens an activity —
|
|
7925
|
+
* this is what keeps a fresh/idle session from pushing anything.
|
|
7837
7926
|
*/
|
|
7838
|
-
|
|
7927
|
+
openActivity = /* @__PURE__ */ new Map();
|
|
7839
7928
|
/**
|
|
7840
7929
|
* React to a session status change.
|
|
7841
7930
|
*
|
|
@@ -7843,34 +7932,22 @@ var LiveActivityNotifier = class {
|
|
|
7843
7932
|
* transition, so this returns a promise the caller may ignore and every error
|
|
7844
7933
|
* is logged rather than propagated.
|
|
7845
7934
|
*/
|
|
7846
|
-
async onStatusChange(session) {
|
|
7935
|
+
async onStatusChange(session, previousStatus) {
|
|
7847
7936
|
const status = toLiveActivityStatus(session.status);
|
|
7848
7937
|
try {
|
|
7849
7938
|
if (!status) {
|
|
7850
|
-
await this.endFor(session);
|
|
7939
|
+
if (this.openActivity.has(session.id)) await this.endFor(session);
|
|
7851
7940
|
return;
|
|
7852
7941
|
}
|
|
7853
|
-
if (
|
|
7854
|
-
|
|
7855
|
-
|
|
7856
|
-
serverId: this.serverId,
|
|
7857
|
-
serverLabel: this.serverLabel
|
|
7858
|
-
});
|
|
7859
|
-
if (!contentState) return;
|
|
7860
|
-
const outcome = await this.sender.send({
|
|
7861
|
-
sessionId: session.id,
|
|
7862
|
-
event: "update",
|
|
7863
|
-
contentState
|
|
7864
|
-
});
|
|
7865
|
-
this.lastPushed.set(session.id, status);
|
|
7866
|
-
if (outcome.attempted > 0) {
|
|
7867
|
-
log4.info("live_activity.updated", {
|
|
7868
|
-
event: "live_activity.updated",
|
|
7869
|
-
sessionId: session.id,
|
|
7870
|
-
status,
|
|
7871
|
-
...outcome
|
|
7872
|
-
});
|
|
7942
|
+
if (status === "running" && previousStatus === "waiting_input") {
|
|
7943
|
+
await this.startTurn(session);
|
|
7944
|
+
return;
|
|
7873
7945
|
}
|
|
7946
|
+
if (status === "waiting_input" && previousStatus === "running") {
|
|
7947
|
+
if (this.openActivity.has(session.id)) await this.endFor(session);
|
|
7948
|
+
return;
|
|
7949
|
+
}
|
|
7950
|
+
await this.maybeSendName(session);
|
|
7874
7951
|
} catch (err) {
|
|
7875
7952
|
log4.error("live_activity.notify_failed", {
|
|
7876
7953
|
event: "live_activity.notify_failed",
|
|
@@ -7880,14 +7957,57 @@ var LiveActivityNotifier = class {
|
|
|
7880
7957
|
});
|
|
7881
7958
|
}
|
|
7882
7959
|
}
|
|
7960
|
+
async startTurn(session) {
|
|
7961
|
+
const contentState = contentStateForSession({
|
|
7962
|
+
session,
|
|
7963
|
+
serverId: this.serverId,
|
|
7964
|
+
serverLabel: this.serverLabel
|
|
7965
|
+
});
|
|
7966
|
+
if (!contentState) return;
|
|
7967
|
+
const outcome = await this.sender.send({
|
|
7968
|
+
sessionId: session.id,
|
|
7969
|
+
event: "update",
|
|
7970
|
+
contentState
|
|
7971
|
+
});
|
|
7972
|
+
this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
|
|
7973
|
+
if (outcome.attempted > 0) {
|
|
7974
|
+
log4.info("live_activity.updated", {
|
|
7975
|
+
event: "live_activity.updated",
|
|
7976
|
+
sessionId: session.id,
|
|
7977
|
+
status: contentState.status,
|
|
7978
|
+
...outcome
|
|
7979
|
+
});
|
|
7980
|
+
}
|
|
7981
|
+
}
|
|
7982
|
+
async maybeSendName(session) {
|
|
7983
|
+
const open2 = this.openActivity.get(session.id);
|
|
7984
|
+
if (!open2 || open2.sessionNameSent || session.sessionName == null) return;
|
|
7985
|
+
const contentState = contentStateForSession({
|
|
7986
|
+
session,
|
|
7987
|
+
serverId: this.serverId,
|
|
7988
|
+
serverLabel: this.serverLabel
|
|
7989
|
+
});
|
|
7990
|
+
if (!contentState) return;
|
|
7991
|
+
const outcome = await this.sender.send({
|
|
7992
|
+
sessionId: session.id,
|
|
7993
|
+
event: "update",
|
|
7994
|
+
contentState
|
|
7995
|
+
});
|
|
7996
|
+
open2.sessionNameSent = true;
|
|
7997
|
+
if (outcome.attempted > 0) {
|
|
7998
|
+
log4.info("live_activity.updated", {
|
|
7999
|
+
event: "live_activity.updated",
|
|
8000
|
+
sessionId: session.id,
|
|
8001
|
+
status: contentState.status,
|
|
8002
|
+
...outcome
|
|
8003
|
+
});
|
|
8004
|
+
}
|
|
8005
|
+
}
|
|
7883
8006
|
async endFor(session) {
|
|
7884
|
-
|
|
7885
|
-
|
|
8007
|
+
this.openActivity.delete(session.id);
|
|
8008
|
+
const status = toLiveActivityStatus(session.status);
|
|
7886
8009
|
const contentState = contentStateForSession({
|
|
7887
|
-
session: {
|
|
7888
|
-
...session,
|
|
7889
|
-
status: lastStatus === "waiting_input" ? "waiting_input" : "running"
|
|
7890
|
-
},
|
|
8010
|
+
session: { ...session, status: status ?? "waiting_input" },
|
|
7891
8011
|
serverId: this.serverId,
|
|
7892
8012
|
serverLabel: this.serverLabel
|
|
7893
8013
|
});
|
|
@@ -7901,9 +8021,9 @@ var LiveActivityNotifier = class {
|
|
|
7901
8021
|
});
|
|
7902
8022
|
}
|
|
7903
8023
|
}
|
|
7904
|
-
/** Drop cached state for a session, so a resume re-
|
|
8024
|
+
/** Drop cached state for a session, so a resume re-opens on its next turn. */
|
|
7905
8025
|
forget(sessionId) {
|
|
7906
|
-
this.
|
|
8026
|
+
this.openActivity.delete(sessionId);
|
|
7907
8027
|
}
|
|
7908
8028
|
};
|
|
7909
8029
|
|
|
@@ -8134,7 +8254,8 @@ var LiveActivityRenewalScheduler = class {
|
|
|
8134
8254
|
status,
|
|
8135
8255
|
startedAt,
|
|
8136
8256
|
lastOutput: session.lastOutput ?? "",
|
|
8137
|
-
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
8257
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
|
|
8258
|
+
...session.sessionName != null && { sessionName: session.sessionName }
|
|
8138
8259
|
};
|
|
8139
8260
|
try {
|
|
8140
8261
|
await this.deps.sender.send({
|
|
@@ -8194,7 +8315,8 @@ var LiveActivityRenewalScheduler = class {
|
|
|
8194
8315
|
// Carried through unchanged — the whole point of the renewal.
|
|
8195
8316
|
startedAt: args.startedAt,
|
|
8196
8317
|
lastOutput: truncateLastOutput(session.lastOutput ?? ""),
|
|
8197
|
-
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
8318
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
|
|
8319
|
+
...session.sessionName != null && { sessionName: session.sessionName }
|
|
8198
8320
|
},
|
|
8199
8321
|
now: args.now,
|
|
8200
8322
|
staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
|
|
@@ -8581,6 +8703,50 @@ async function reconcileSessions(rows, probe, currentInstanceId) {
|
|
|
8581
8703
|
return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
|
|
8582
8704
|
}
|
|
8583
8705
|
|
|
8706
|
+
// src/services/sessions/rehydrateSessions.ts
|
|
8707
|
+
var REHYDRATE_MAX = 25;
|
|
8708
|
+
var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
8709
|
+
var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
|
|
8710
|
+
function shouldRehydrate(row, opts) {
|
|
8711
|
+
if (!opts.projectExists(row.project_path)) return false;
|
|
8712
|
+
if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
|
|
8713
|
+
if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
|
|
8714
|
+
return true;
|
|
8715
|
+
}
|
|
8716
|
+
function rowToStubSession(row) {
|
|
8717
|
+
return {
|
|
8718
|
+
id: row.session_id,
|
|
8719
|
+
provider: row.provider,
|
|
8720
|
+
projectPath: row.project_path,
|
|
8721
|
+
projectName: row.project_name,
|
|
8722
|
+
branch: row.branch,
|
|
8723
|
+
// No PTY exists for a stub, so this is the only truthful status.
|
|
8724
|
+
status: "idle",
|
|
8725
|
+
startedAt: new Date(row.started_at),
|
|
8726
|
+
completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
|
|
8727
|
+
promptCount: row.prompt_count,
|
|
8728
|
+
lastOutput: "",
|
|
8729
|
+
rehydrated: true,
|
|
8730
|
+
...row.session_name != null && { sessionName: row.session_name },
|
|
8731
|
+
...row.project_id != null && { projectId: row.project_id },
|
|
8732
|
+
...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
|
|
8733
|
+
...row.resumed_from_conversation_id != null && {
|
|
8734
|
+
resumedFromConversationId: row.resumed_from_conversation_id
|
|
8735
|
+
},
|
|
8736
|
+
...row.failure_reason != null && { failureReason: row.failure_reason },
|
|
8737
|
+
...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
|
|
8738
|
+
// Only `shutdown` crosses over. It is the one registry source that is also a
|
|
8739
|
+
// wire StatusSource *and* that genuinely describes the `idle` above — the
|
|
8740
|
+
// streamer stopped this session. A crashed row still says `transition` over
|
|
8741
|
+
// a `running` status, and copying that here would attach observed-confidence
|
|
8742
|
+
// provenance to a status we derived at boot, so leave it unset instead.
|
|
8743
|
+
...row.status_source === "shutdown" && {
|
|
8744
|
+
statusSource: "shutdown",
|
|
8745
|
+
statusUpdatedAt: new Date(row.status_updated_at)
|
|
8746
|
+
}
|
|
8747
|
+
};
|
|
8748
|
+
}
|
|
8749
|
+
|
|
8584
8750
|
// src/types.ts
|
|
8585
8751
|
function confidenceForSource(source) {
|
|
8586
8752
|
return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
|
|
@@ -8728,14 +8894,15 @@ function managedToResponse(s, ptyAttached) {
|
|
|
8728
8894
|
// Lifecycle for a session this run knows about. `attached` while we hold
|
|
8729
8895
|
// its PTY; once the PTY is gone the session is terminal from this run's
|
|
8730
8896
|
// perspective — `failed` when it recorded a reason, else `completed`.
|
|
8731
|
-
//
|
|
8732
|
-
//
|
|
8733
|
-
//
|
|
8734
|
-
|
|
8735
|
-
|
|
8897
|
+
// A `rehydrated` stub is the exception: the boot rehydrator seeded it from
|
|
8898
|
+
// the durable registry, so it is a previous run's session with no process
|
|
8899
|
+
// behind it — `resumable`, and `historical` rather than `managed`
|
|
8900
|
+
// (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
|
|
8901
|
+
lifecycle: ptyAttached ? "attached" : s.rehydrated ? "resumable" : s.failureReason != null ? "failed" : "completed",
|
|
8902
|
+
lifecycleSource: ptyAttached ? "spawn" : s.rehydrated ? "reconcile" : "exit",
|
|
8736
8903
|
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
8737
8904
|
// `activity` is attached for managed sessions.
|
|
8738
|
-
ownership: "managed",
|
|
8905
|
+
ownership: s.rehydrated ? "historical" : "managed",
|
|
8739
8906
|
projectPath: s.projectPath,
|
|
8740
8907
|
projectName: s.projectName,
|
|
8741
8908
|
branch: s.branch,
|
|
@@ -9325,10 +9492,13 @@ var StreamerServer = class {
|
|
|
9325
9492
|
projectsRepo = null;
|
|
9326
9493
|
conversationsRepo = null;
|
|
9327
9494
|
sessionsRepo = null;
|
|
9328
|
-
// Durable session registry (C1 Phase 2). Null when
|
|
9329
|
-
//
|
|
9330
|
-
// taking the server down with it, so every write goes through `?.`.
|
|
9495
|
+
// Durable session registry (C1 Phase 2). Null when runtime.db failed to open
|
|
9496
|
+
// — persistence degrades to today's in-memory-only behaviour rather than
|
|
9497
|
+
// taking the server down with it, so every write goes through `?.`. Note the
|
|
9498
|
+
// handle is runtime.db, NOT the conversation cache: a cache failure used to
|
|
9499
|
+
// null this repo and silently disable all session persistence.
|
|
9331
9500
|
managedSessionsRepo = null;
|
|
9501
|
+
runtimeStore = null;
|
|
9332
9502
|
// Identifies this streamer run. A registry row carrying a different id is a
|
|
9333
9503
|
// session that outlived the process that started it.
|
|
9334
9504
|
streamerInstanceId = randomUUID5();
|
|
@@ -9347,6 +9517,7 @@ var StreamerServer = class {
|
|
|
9347
9517
|
liveActivityRenewal = null;
|
|
9348
9518
|
discoveryCache = null;
|
|
9349
9519
|
cacheDir;
|
|
9520
|
+
runtimeDbPath;
|
|
9350
9521
|
tailSize;
|
|
9351
9522
|
directoryDebounceMs;
|
|
9352
9523
|
// Trailing-debounced trigger that flags the scanner stale after a quiet
|
|
@@ -9393,6 +9564,7 @@ var StreamerServer = class {
|
|
|
9393
9564
|
this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
|
|
9394
9565
|
this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
|
|
9395
9566
|
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join18(homedir9(), ".threadbase", "cache");
|
|
9567
|
+
this.runtimeDbPath = config.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? join18(process.env.THREADBASE_CONFIG_DIR ?? join18(homedir9(), ".threadbase"), "runtime.db");
|
|
9396
9568
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
9397
9569
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
9398
9570
|
this.markScannerStaleDebounced = debounce(() => {
|
|
@@ -9563,6 +9735,7 @@ var StreamerServer = class {
|
|
|
9563
9735
|
if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
|
|
9564
9736
|
},
|
|
9565
9737
|
onStatusChange: (session) => {
|
|
9738
|
+
const previousStatus = this.sessionStore.getManaged(session.id)?.status;
|
|
9566
9739
|
this.sessionStore.updateManaged(session.id, {
|
|
9567
9740
|
status: session.status,
|
|
9568
9741
|
completedAt: session.completedAt,
|
|
@@ -9617,7 +9790,7 @@ var StreamerServer = class {
|
|
|
9617
9790
|
if (resp) {
|
|
9618
9791
|
this.wsHub.broadcast({ type: "session_update", session: resp });
|
|
9619
9792
|
}
|
|
9620
|
-
void this.liveActivityNotifier?.onStatusChange(session);
|
|
9793
|
+
void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
|
|
9621
9794
|
this.sessionStatusBus.emit(`status:${session.id}`, session.status);
|
|
9622
9795
|
}
|
|
9623
9796
|
});
|
|
@@ -9668,6 +9841,7 @@ var StreamerServer = class {
|
|
|
9668
9841
|
conversationsRepo: () => this.conversationsRepo,
|
|
9669
9842
|
sessionsRepo: () => this.sessionsRepo,
|
|
9670
9843
|
cacheMetadataRepo: () => this.cacheMetadataRepo,
|
|
9844
|
+
runtimeStore: () => this.runtimeStore,
|
|
9671
9845
|
ptyAttachedIds: () => this.ptyAttachedIds(),
|
|
9672
9846
|
handleListSessions: (url, res) => this.handleListSessions(url, res),
|
|
9673
9847
|
handleSessionsCount: (res) => this.handleSessionsCount(res),
|
|
@@ -9967,6 +10141,58 @@ var StreamerServer = class {
|
|
|
9967
10141
|
}
|
|
9968
10142
|
return verdicts;
|
|
9969
10143
|
}
|
|
10144
|
+
/**
|
|
10145
|
+
* Seed the session list with what previous runs left behind (persistence plan
|
|
10146
|
+
* Phase 1, gaps G1/G2/G8).
|
|
10147
|
+
*
|
|
10148
|
+
* Reconciliation classifies rows and stops there; a verdict is overlaid onto a
|
|
10149
|
+
* SessionResponse that already exists, and after a clean restart none does —
|
|
10150
|
+
* `SessionStore` starts empty. So the user's session did not become
|
|
10151
|
+
* `resumable`, it became *absent*. This is the half that puts it back.
|
|
10152
|
+
*
|
|
10153
|
+
* The seeded stubs hold no PTY and are never handed to `LiveSessionManager`,
|
|
10154
|
+
* so `reapIdleSessions` and `startGraceTimer` — both of which iterate
|
|
10155
|
+
* `ptyManager.listSessions()` — cannot observe them. A later resume calls
|
|
10156
|
+
* `sessionStore.addManaged` with the real session, which overwrites the stub
|
|
10157
|
+
* by id rather than duplicating it.
|
|
10158
|
+
*/
|
|
10159
|
+
rehydratePreviousSessions(verdicts) {
|
|
10160
|
+
if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
|
|
10161
|
+
try {
|
|
10162
|
+
const now = Date.now();
|
|
10163
|
+
const rows = this.managedSessionsRepo.listRecoverable({
|
|
10164
|
+
sinceMs: now - REHYDRATE_WINDOW_MS,
|
|
10165
|
+
limit: REHYDRATE_MAX + 1
|
|
10166
|
+
});
|
|
10167
|
+
const truncated = rows.length > REHYDRATE_MAX;
|
|
10168
|
+
const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
|
|
10169
|
+
if (candidates.length === 0) return;
|
|
10170
|
+
const lifecycleByVerdict = new Map(verdicts.map((v) => [v.sessionId, v.lifecycle]));
|
|
10171
|
+
let rehydrated = 0;
|
|
10172
|
+
for (const row of candidates) {
|
|
10173
|
+
if (this.sessionStore.getManaged(row.session_id)) continue;
|
|
10174
|
+
if (!shouldRehydrate(row, { now, projectExists: existsSync11 })) continue;
|
|
10175
|
+
this.sessionStore.addManaged(rowToStubSession(row));
|
|
10176
|
+
this.sessionLifecycles.set(
|
|
10177
|
+
row.session_id,
|
|
10178
|
+
lifecycleByVerdict.get(row.session_id) ?? "resumable"
|
|
10179
|
+
);
|
|
10180
|
+
if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
|
|
10181
|
+
rehydrated++;
|
|
10182
|
+
}
|
|
10183
|
+
this.log.info(`[rehydrate] recovered ${rehydrated} session(s) from the registry`, {
|
|
10184
|
+
event: "sessions.rehydrated",
|
|
10185
|
+
rehydrated,
|
|
10186
|
+
skipped: candidates.length - rehydrated,
|
|
10187
|
+
truncated
|
|
10188
|
+
});
|
|
10189
|
+
} catch (err) {
|
|
10190
|
+
this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
|
|
10191
|
+
event: "sessions.rehydrate_failed",
|
|
10192
|
+
err
|
|
10193
|
+
});
|
|
10194
|
+
}
|
|
10195
|
+
}
|
|
9970
10196
|
/**
|
|
9971
10197
|
* Pick a token guaranteed to appear in the spawned process's argv, for the
|
|
9972
10198
|
* reconciler's pid-reuse guard.
|
|
@@ -10200,6 +10426,17 @@ var StreamerServer = class {
|
|
|
10200
10426
|
port,
|
|
10201
10427
|
event: "server.listening"
|
|
10202
10428
|
});
|
|
10429
|
+
try {
|
|
10430
|
+
this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
|
|
10431
|
+
this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
|
|
10432
|
+
} catch (err) {
|
|
10433
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
10434
|
+
const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
|
|
10435
|
+
this.log.error(
|
|
10436
|
+
`Runtime store failed to open \u2014 session persistence DISABLED; sessions will not survive a restart.` + (abiMismatch ? ` Fix: npm rebuild better-sqlite3` : "") + ` (${message})`,
|
|
10437
|
+
{ error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
|
|
10438
|
+
);
|
|
10439
|
+
}
|
|
10203
10440
|
try {
|
|
10204
10441
|
this.cache = ConversationCache.open(
|
|
10205
10442
|
join18(this.cacheDir, "cache.db"),
|
|
@@ -10227,8 +10464,20 @@ var StreamerServer = class {
|
|
|
10227
10464
|
this.projectsRepo = new ProjectsRepository(db);
|
|
10228
10465
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
10229
10466
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
10230
|
-
|
|
10231
|
-
|
|
10467
|
+
try {
|
|
10468
|
+
const copied = this.runtimeStore?.importLegacyManagedSessions(db) ?? 0;
|
|
10469
|
+
if (copied > 0) {
|
|
10470
|
+
this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
|
|
10471
|
+
copied,
|
|
10472
|
+
event: "runtime.legacy_import"
|
|
10473
|
+
});
|
|
10474
|
+
}
|
|
10475
|
+
} catch (err) {
|
|
10476
|
+
this.log.warn("[registry] legacy managed_sessions copy failed", {
|
|
10477
|
+
event: "runtime.legacy_import_failed",
|
|
10478
|
+
err
|
|
10479
|
+
});
|
|
10480
|
+
}
|
|
10232
10481
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
10233
10482
|
this.pushRepo = new PushRepository(db);
|
|
10234
10483
|
this.devicesRepo = new DevicesRepository(db);
|
|
@@ -10264,6 +10513,7 @@ var StreamerServer = class {
|
|
|
10264
10513
|
);
|
|
10265
10514
|
this.scannerPersistenceDisabled = true;
|
|
10266
10515
|
}
|
|
10516
|
+
void this.reconcilePreviousSessions().then((v) => this.rehydratePreviousSessions(v));
|
|
10267
10517
|
if (this.skipStartupWarmup) {
|
|
10268
10518
|
this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
|
|
10269
10519
|
event: "cache.warmup_skipped"
|
|
@@ -10471,6 +10721,7 @@ var StreamerServer = class {
|
|
|
10471
10721
|
this.allScanners.clear();
|
|
10472
10722
|
this.scanner = null;
|
|
10473
10723
|
this.cache?.close();
|
|
10724
|
+
this.runtimeStore?.close();
|
|
10474
10725
|
this.ptyManager.dispose();
|
|
10475
10726
|
this.fileWatcher.dispose();
|
|
10476
10727
|
this.externalTails.clear();
|
|
@@ -10918,7 +11169,8 @@ var StreamerServer = class {
|
|
|
10918
11169
|
}
|
|
10919
11170
|
handleSessionsCount(res) {
|
|
10920
11171
|
if (this.rejectIfWarmingUp(res)) return;
|
|
10921
|
-
|
|
11172
|
+
const total = this.sessionStore.list(this.ptyAttachedIds()).filter((s) => s.ownership !== "historical").length;
|
|
11173
|
+
json(res, 200, { total });
|
|
10922
11174
|
}
|
|
10923
11175
|
handleGetRecentSessions(url, res) {
|
|
10924
11176
|
if (this.rejectIfWarmingUp(res)) return;
|