@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.cjs
CHANGED
|
@@ -500,6 +500,12 @@ var FEATURE_FLAGS = [
|
|
|
500
500
|
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.",
|
|
501
501
|
default: false,
|
|
502
502
|
env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
id: "sessionRehydration",
|
|
506
|
+
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.",
|
|
507
|
+
default: true,
|
|
508
|
+
env: "THREADBASE_FEATURE_SESSION_REHYDRATION"
|
|
503
509
|
}
|
|
504
510
|
];
|
|
505
511
|
function findFeatureFlag(id) {
|
|
@@ -1936,6 +1942,12 @@ function detectShellPrompt(lines) {
|
|
|
1936
1942
|
return null;
|
|
1937
1943
|
}
|
|
1938
1944
|
|
|
1945
|
+
// src/utils/deriveSessionName.ts
|
|
1946
|
+
function deriveSessionName(firstMessageText) {
|
|
1947
|
+
const firstLine = firstMessageText.split("\n", 1)[0]?.trim() ?? "";
|
|
1948
|
+
return firstLine.slice(0, 80);
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1939
1951
|
// src/pty-manager.ts
|
|
1940
1952
|
var OUTPUT_BUFFER_MAX2 = 65536;
|
|
1941
1953
|
var INPUT_HISTORY_MAX2 = 50;
|
|
@@ -2409,6 +2421,10 @@ var PTYManager = class {
|
|
|
2409
2421
|
if (session.inputHistory.length > INPUT_HISTORY_MAX2) {
|
|
2410
2422
|
session.inputHistory.shift();
|
|
2411
2423
|
}
|
|
2424
|
+
if (session.firstMessageText === void 0) {
|
|
2425
|
+
session.firstMessageText = text;
|
|
2426
|
+
session.sessionName = deriveSessionName(text);
|
|
2427
|
+
}
|
|
2412
2428
|
this.onUserMessage?.(session.id, text, ts);
|
|
2413
2429
|
}
|
|
2414
2430
|
getSession(sessionId) {
|
|
@@ -2674,7 +2690,9 @@ function toPublicSession2(s) {
|
|
|
2674
2690
|
...s.lastActivityAt != null && { lastActivityAt: s.lastActivityAt },
|
|
2675
2691
|
...s.statusSource != null && { statusSource: s.statusSource },
|
|
2676
2692
|
...s.statusUpdatedAt != null && { statusUpdatedAt: s.statusUpdatedAt },
|
|
2677
|
-
...s.filePath != null && { filePath: s.filePath }
|
|
2693
|
+
...s.filePath != null && { filePath: s.filePath },
|
|
2694
|
+
...s.sessionName != null && { sessionName: s.sessionName },
|
|
2695
|
+
...s.firstMessageText != null && { firstMessageText: s.firstMessageText }
|
|
2678
2696
|
};
|
|
2679
2697
|
}
|
|
2680
2698
|
function stripAnsi2(str) {
|
|
@@ -5178,6 +5196,9 @@ function getMigrationsDir2() {
|
|
|
5178
5196
|
}
|
|
5179
5197
|
return __dirname;
|
|
5180
5198
|
}
|
|
5199
|
+
function resolveMigrationsDir(name = "migrations") {
|
|
5200
|
+
return (0, import_path10.join)(getMigrationsDir2(), name);
|
|
5201
|
+
}
|
|
5181
5202
|
var SCHEMA_MIGRATIONS_SQL = `
|
|
5182
5203
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
5183
5204
|
id TEXT PRIMARY KEY,
|
|
@@ -5186,7 +5207,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
|
5186
5207
|
`;
|
|
5187
5208
|
function runSqliteMigrations(db, migrationsDir) {
|
|
5188
5209
|
db.exec(SCHEMA_MIGRATIONS_SQL);
|
|
5189
|
-
const dir = migrationsDir ?? (
|
|
5210
|
+
const dir = migrationsDir ?? resolveMigrationsDir();
|
|
5190
5211
|
const files = (0, import_fs8.readdirSync)(dir).filter((f) => f.endsWith(".sql")).sort();
|
|
5191
5212
|
const appliedRows = db.prepare("SELECT id FROM schema_migrations").all();
|
|
5192
5213
|
const appliedSet = new Set(appliedRows.map((r) => r.id));
|
|
@@ -6597,6 +6618,7 @@ var ManagedSessionsRepository = class {
|
|
|
6597
6618
|
updateStatusStmt;
|
|
6598
6619
|
getStmt;
|
|
6599
6620
|
listNonTerminalStmt;
|
|
6621
|
+
listRecoverableStmt;
|
|
6600
6622
|
deleteStmt;
|
|
6601
6623
|
constructor(db) {
|
|
6602
6624
|
this.upsertStmt = db.prepare(`
|
|
@@ -6649,6 +6671,13 @@ var ManagedSessionsRepository = class {
|
|
|
6649
6671
|
WHERE completed_at IS NULL
|
|
6650
6672
|
ORDER BY started_at ASC
|
|
6651
6673
|
`);
|
|
6674
|
+
this.listRecoverableStmt = db.prepare(`
|
|
6675
|
+
SELECT * FROM managed_sessions
|
|
6676
|
+
WHERE (completed_at IS NULL OR status_source = 'shutdown')
|
|
6677
|
+
AND status_updated_at >= @since
|
|
6678
|
+
ORDER BY status_updated_at DESC
|
|
6679
|
+
LIMIT @limit
|
|
6680
|
+
`);
|
|
6652
6681
|
this.deleteStmt = db.prepare("DELETE FROM managed_sessions WHERE session_id = ?");
|
|
6653
6682
|
}
|
|
6654
6683
|
/** Record a session at spawn, or refresh every field of an existing row. */
|
|
@@ -6700,6 +6729,14 @@ var ManagedSessionsRepository = class {
|
|
|
6700
6729
|
listNonTerminal() {
|
|
6701
6730
|
return this.listNonTerminalStmt.all();
|
|
6702
6731
|
}
|
|
6732
|
+
/**
|
|
6733
|
+
* Rows a restart could bring back: still open, or closed by our own shutdown,
|
|
6734
|
+
* and touched no longer ago than `sinceMs`. Newest first, capped — the caller
|
|
6735
|
+
* decides which of these actually deserve rehydrating (`shouldRehydrate`).
|
|
6736
|
+
*/
|
|
6737
|
+
listRecoverable({ sinceMs, limit }) {
|
|
6738
|
+
return this.listRecoverableStmt.all({ since: sinceMs, limit });
|
|
6739
|
+
}
|
|
6703
6740
|
delete(sessionId) {
|
|
6704
6741
|
this.deleteStmt.run(sessionId);
|
|
6705
6742
|
}
|
|
@@ -6835,6 +6872,54 @@ var SessionsRepository = class {
|
|
|
6835
6872
|
}
|
|
6836
6873
|
};
|
|
6837
6874
|
|
|
6875
|
+
// src/db/runtime-store.ts
|
|
6876
|
+
var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
|
|
6877
|
+
var RuntimeStore = class _RuntimeStore {
|
|
6878
|
+
constructor(db) {
|
|
6879
|
+
this.db = db;
|
|
6880
|
+
}
|
|
6881
|
+
db;
|
|
6882
|
+
static open(dbPath, migrationsDir) {
|
|
6883
|
+
const db = new import_better_sqlite32.default(dbPath);
|
|
6884
|
+
db.pragma("journal_mode = WAL");
|
|
6885
|
+
runSqliteMigrations(db, migrationsDir ?? resolveMigrationsDir("runtime-migrations"));
|
|
6886
|
+
return new _RuntimeStore(db);
|
|
6887
|
+
}
|
|
6888
|
+
getDatabase() {
|
|
6889
|
+
return this.db;
|
|
6890
|
+
}
|
|
6891
|
+
/**
|
|
6892
|
+
* One-time move of `managed_sessions` rows out of a pre-split `cache.db`.
|
|
6893
|
+
*
|
|
6894
|
+
* Non-destructive by design: the source table is left in place so an older
|
|
6895
|
+
* streamer rolled back onto the same machine still finds its registry. Runs
|
|
6896
|
+
* only when this file's table is empty, so a second boot is a no-op rather
|
|
6897
|
+
* than a re-copy that would resurrect rows deleted since.
|
|
6898
|
+
*
|
|
6899
|
+
* Returns the number of rows copied.
|
|
6900
|
+
*/
|
|
6901
|
+
importLegacyManagedSessions(source) {
|
|
6902
|
+
const existing = this.db.prepare("SELECT COUNT(*) AS n FROM managed_sessions").get();
|
|
6903
|
+
if (existing.n > 0) return 0;
|
|
6904
|
+
const hasTable = source.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'managed_sessions'").get();
|
|
6905
|
+
if (!hasTable) return 0;
|
|
6906
|
+
const rows = source.prepare("SELECT * FROM managed_sessions").all();
|
|
6907
|
+
if (rows.length === 0) return 0;
|
|
6908
|
+
const columns = Object.keys(rows[0]);
|
|
6909
|
+
const insert = this.db.prepare(
|
|
6910
|
+
`INSERT OR IGNORE INTO managed_sessions (${columns.join(", ")})
|
|
6911
|
+
VALUES (${columns.map((c) => `@${c}`).join(", ")})`
|
|
6912
|
+
);
|
|
6913
|
+
this.db.transaction((batch) => {
|
|
6914
|
+
for (const row of batch) insert.run(row);
|
|
6915
|
+
})(rows);
|
|
6916
|
+
return rows.length;
|
|
6917
|
+
}
|
|
6918
|
+
close() {
|
|
6919
|
+
this.db.close();
|
|
6920
|
+
}
|
|
6921
|
+
};
|
|
6922
|
+
|
|
6838
6923
|
// src/db/upload-records.ts
|
|
6839
6924
|
async function recordUpload(pool2, instanceId, row) {
|
|
6840
6925
|
if (!pool2) return;
|
|
@@ -7852,7 +7937,8 @@ function contentStateForSession(args) {
|
|
|
7852
7937
|
status,
|
|
7853
7938
|
startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
|
|
7854
7939
|
lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
|
|
7855
|
-
...args.serverLabel != null && { serverLabel: args.serverLabel }
|
|
7940
|
+
...args.serverLabel != null && { serverLabel: args.serverLabel },
|
|
7941
|
+
...args.session.sessionName != null && { sessionName: args.session.sessionName }
|
|
7856
7942
|
};
|
|
7857
7943
|
}
|
|
7858
7944
|
var LiveActivityNotifier = class {
|
|
@@ -7865,14 +7951,17 @@ var LiveActivityNotifier = class {
|
|
|
7865
7951
|
serverId;
|
|
7866
7952
|
serverLabel;
|
|
7867
7953
|
/**
|
|
7868
|
-
*
|
|
7954
|
+
* Sessions with a currently open (pushed) activity.
|
|
7869
7955
|
*
|
|
7870
|
-
*
|
|
7871
|
-
*
|
|
7872
|
-
*
|
|
7873
|
-
*
|
|
7956
|
+
* An activity opens on a `waiting_input → running` edge (the user sent a
|
|
7957
|
+
* prompt) and closes on the matching `running → waiting_input` edge (the
|
|
7958
|
+
* response, including any sub-agents, finished) — so this set is what makes
|
|
7959
|
+
* the notifier per-turn rather than per-session. A session's very first
|
|
7960
|
+
* `running` (right after spawn, before any user prompt) has no prior
|
|
7961
|
+
* `waiting_input` and therefore no edge, so it never opens an activity —
|
|
7962
|
+
* this is what keeps a fresh/idle session from pushing anything.
|
|
7874
7963
|
*/
|
|
7875
|
-
|
|
7964
|
+
openActivity = /* @__PURE__ */ new Map();
|
|
7876
7965
|
/**
|
|
7877
7966
|
* React to a session status change.
|
|
7878
7967
|
*
|
|
@@ -7880,34 +7969,22 @@ var LiveActivityNotifier = class {
|
|
|
7880
7969
|
* transition, so this returns a promise the caller may ignore and every error
|
|
7881
7970
|
* is logged rather than propagated.
|
|
7882
7971
|
*/
|
|
7883
|
-
async onStatusChange(session) {
|
|
7972
|
+
async onStatusChange(session, previousStatus) {
|
|
7884
7973
|
const status = toLiveActivityStatus(session.status);
|
|
7885
7974
|
try {
|
|
7886
7975
|
if (!status) {
|
|
7887
|
-
await this.endFor(session);
|
|
7976
|
+
if (this.openActivity.has(session.id)) await this.endFor(session);
|
|
7888
7977
|
return;
|
|
7889
7978
|
}
|
|
7890
|
-
if (
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
serverId: this.serverId,
|
|
7894
|
-
serverLabel: this.serverLabel
|
|
7895
|
-
});
|
|
7896
|
-
if (!contentState) return;
|
|
7897
|
-
const outcome = await this.sender.send({
|
|
7898
|
-
sessionId: session.id,
|
|
7899
|
-
event: "update",
|
|
7900
|
-
contentState
|
|
7901
|
-
});
|
|
7902
|
-
this.lastPushed.set(session.id, status);
|
|
7903
|
-
if (outcome.attempted > 0) {
|
|
7904
|
-
log4.info("live_activity.updated", {
|
|
7905
|
-
event: "live_activity.updated",
|
|
7906
|
-
sessionId: session.id,
|
|
7907
|
-
status,
|
|
7908
|
-
...outcome
|
|
7909
|
-
});
|
|
7979
|
+
if (status === "running" && previousStatus === "waiting_input") {
|
|
7980
|
+
await this.startTurn(session);
|
|
7981
|
+
return;
|
|
7910
7982
|
}
|
|
7983
|
+
if (status === "waiting_input" && previousStatus === "running") {
|
|
7984
|
+
if (this.openActivity.has(session.id)) await this.endFor(session);
|
|
7985
|
+
return;
|
|
7986
|
+
}
|
|
7987
|
+
await this.maybeSendName(session);
|
|
7911
7988
|
} catch (err) {
|
|
7912
7989
|
log4.error("live_activity.notify_failed", {
|
|
7913
7990
|
event: "live_activity.notify_failed",
|
|
@@ -7917,14 +7994,57 @@ var LiveActivityNotifier = class {
|
|
|
7917
7994
|
});
|
|
7918
7995
|
}
|
|
7919
7996
|
}
|
|
7997
|
+
async startTurn(session) {
|
|
7998
|
+
const contentState = contentStateForSession({
|
|
7999
|
+
session,
|
|
8000
|
+
serverId: this.serverId,
|
|
8001
|
+
serverLabel: this.serverLabel
|
|
8002
|
+
});
|
|
8003
|
+
if (!contentState) return;
|
|
8004
|
+
const outcome = await this.sender.send({
|
|
8005
|
+
sessionId: session.id,
|
|
8006
|
+
event: "update",
|
|
8007
|
+
contentState
|
|
8008
|
+
});
|
|
8009
|
+
this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
|
|
8010
|
+
if (outcome.attempted > 0) {
|
|
8011
|
+
log4.info("live_activity.updated", {
|
|
8012
|
+
event: "live_activity.updated",
|
|
8013
|
+
sessionId: session.id,
|
|
8014
|
+
status: contentState.status,
|
|
8015
|
+
...outcome
|
|
8016
|
+
});
|
|
8017
|
+
}
|
|
8018
|
+
}
|
|
8019
|
+
async maybeSendName(session) {
|
|
8020
|
+
const open2 = this.openActivity.get(session.id);
|
|
8021
|
+
if (!open2 || open2.sessionNameSent || session.sessionName == null) return;
|
|
8022
|
+
const contentState = contentStateForSession({
|
|
8023
|
+
session,
|
|
8024
|
+
serverId: this.serverId,
|
|
8025
|
+
serverLabel: this.serverLabel
|
|
8026
|
+
});
|
|
8027
|
+
if (!contentState) return;
|
|
8028
|
+
const outcome = await this.sender.send({
|
|
8029
|
+
sessionId: session.id,
|
|
8030
|
+
event: "update",
|
|
8031
|
+
contentState
|
|
8032
|
+
});
|
|
8033
|
+
open2.sessionNameSent = true;
|
|
8034
|
+
if (outcome.attempted > 0) {
|
|
8035
|
+
log4.info("live_activity.updated", {
|
|
8036
|
+
event: "live_activity.updated",
|
|
8037
|
+
sessionId: session.id,
|
|
8038
|
+
status: contentState.status,
|
|
8039
|
+
...outcome
|
|
8040
|
+
});
|
|
8041
|
+
}
|
|
8042
|
+
}
|
|
7920
8043
|
async endFor(session) {
|
|
7921
|
-
|
|
7922
|
-
|
|
8044
|
+
this.openActivity.delete(session.id);
|
|
8045
|
+
const status = toLiveActivityStatus(session.status);
|
|
7923
8046
|
const contentState = contentStateForSession({
|
|
7924
|
-
session: {
|
|
7925
|
-
...session,
|
|
7926
|
-
status: lastStatus === "waiting_input" ? "waiting_input" : "running"
|
|
7927
|
-
},
|
|
8047
|
+
session: { ...session, status: status ?? "waiting_input" },
|
|
7928
8048
|
serverId: this.serverId,
|
|
7929
8049
|
serverLabel: this.serverLabel
|
|
7930
8050
|
});
|
|
@@ -7938,9 +8058,9 @@ var LiveActivityNotifier = class {
|
|
|
7938
8058
|
});
|
|
7939
8059
|
}
|
|
7940
8060
|
}
|
|
7941
|
-
/** Drop cached state for a session, so a resume re-
|
|
8061
|
+
/** Drop cached state for a session, so a resume re-opens on its next turn. */
|
|
7942
8062
|
forget(sessionId) {
|
|
7943
|
-
this.
|
|
8063
|
+
this.openActivity.delete(sessionId);
|
|
7944
8064
|
}
|
|
7945
8065
|
};
|
|
7946
8066
|
|
|
@@ -8171,7 +8291,8 @@ var LiveActivityRenewalScheduler = class {
|
|
|
8171
8291
|
status,
|
|
8172
8292
|
startedAt,
|
|
8173
8293
|
lastOutput: session.lastOutput ?? "",
|
|
8174
|
-
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
8294
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
|
|
8295
|
+
...session.sessionName != null && { sessionName: session.sessionName }
|
|
8175
8296
|
};
|
|
8176
8297
|
try {
|
|
8177
8298
|
await this.deps.sender.send({
|
|
@@ -8231,7 +8352,8 @@ var LiveActivityRenewalScheduler = class {
|
|
|
8231
8352
|
// Carried through unchanged — the whole point of the renewal.
|
|
8232
8353
|
startedAt: args.startedAt,
|
|
8233
8354
|
lastOutput: truncateLastOutput(session.lastOutput ?? ""),
|
|
8234
|
-
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
8355
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel },
|
|
8356
|
+
...session.sessionName != null && { sessionName: session.sessionName }
|
|
8235
8357
|
},
|
|
8236
8358
|
now: args.now,
|
|
8237
8359
|
staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
|
|
@@ -8618,6 +8740,50 @@ async function reconcileSessions(rows, probe, currentInstanceId) {
|
|
|
8618
8740
|
return Promise.all(rows.map((row) => classifySession(row, probe, currentInstanceId)));
|
|
8619
8741
|
}
|
|
8620
8742
|
|
|
8743
|
+
// src/services/sessions/rehydrateSessions.ts
|
|
8744
|
+
var REHYDRATE_MAX = 25;
|
|
8745
|
+
var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
8746
|
+
var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
|
|
8747
|
+
function shouldRehydrate(row, opts) {
|
|
8748
|
+
if (!opts.projectExists(row.project_path)) return false;
|
|
8749
|
+
if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
|
|
8750
|
+
if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
|
|
8751
|
+
return true;
|
|
8752
|
+
}
|
|
8753
|
+
function rowToStubSession(row) {
|
|
8754
|
+
return {
|
|
8755
|
+
id: row.session_id,
|
|
8756
|
+
provider: row.provider,
|
|
8757
|
+
projectPath: row.project_path,
|
|
8758
|
+
projectName: row.project_name,
|
|
8759
|
+
branch: row.branch,
|
|
8760
|
+
// No PTY exists for a stub, so this is the only truthful status.
|
|
8761
|
+
status: "idle",
|
|
8762
|
+
startedAt: new Date(row.started_at),
|
|
8763
|
+
completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
|
|
8764
|
+
promptCount: row.prompt_count,
|
|
8765
|
+
lastOutput: "",
|
|
8766
|
+
rehydrated: true,
|
|
8767
|
+
...row.session_name != null && { sessionName: row.session_name },
|
|
8768
|
+
...row.project_id != null && { projectId: row.project_id },
|
|
8769
|
+
...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
|
|
8770
|
+
...row.resumed_from_conversation_id != null && {
|
|
8771
|
+
resumedFromConversationId: row.resumed_from_conversation_id
|
|
8772
|
+
},
|
|
8773
|
+
...row.failure_reason != null && { failureReason: row.failure_reason },
|
|
8774
|
+
...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
|
|
8775
|
+
// Only `shutdown` crosses over. It is the one registry source that is also a
|
|
8776
|
+
// wire StatusSource *and* that genuinely describes the `idle` above — the
|
|
8777
|
+
// streamer stopped this session. A crashed row still says `transition` over
|
|
8778
|
+
// a `running` status, and copying that here would attach observed-confidence
|
|
8779
|
+
// provenance to a status we derived at boot, so leave it unset instead.
|
|
8780
|
+
...row.status_source === "shutdown" && {
|
|
8781
|
+
statusSource: "shutdown",
|
|
8782
|
+
statusUpdatedAt: new Date(row.status_updated_at)
|
|
8783
|
+
}
|
|
8784
|
+
};
|
|
8785
|
+
}
|
|
8786
|
+
|
|
8621
8787
|
// src/types.ts
|
|
8622
8788
|
function confidenceForSource(source) {
|
|
8623
8789
|
return source === "timeout-fallback" || source === "quiet-fallback" ? "inferred" : "observed";
|
|
@@ -8765,14 +8931,15 @@ function managedToResponse(s, ptyAttached) {
|
|
|
8765
8931
|
// Lifecycle for a session this run knows about. `attached` while we hold
|
|
8766
8932
|
// its PTY; once the PTY is gone the session is terminal from this run's
|
|
8767
8933
|
// perspective — `failed` when it recorded a reason, else `completed`.
|
|
8768
|
-
//
|
|
8769
|
-
//
|
|
8770
|
-
//
|
|
8771
|
-
|
|
8772
|
-
|
|
8934
|
+
// A `rehydrated` stub is the exception: the boot rehydrator seeded it from
|
|
8935
|
+
// the durable registry, so it is a previous run's session with no process
|
|
8936
|
+
// behind it — `resumable`, and `historical` rather than `managed`
|
|
8937
|
+
// (docs/plans/live-sessions-persistence-plan.md §4, Phase 1).
|
|
8938
|
+
lifecycle: ptyAttached ? "attached" : s.rehydrated ? "resumable" : s.failureReason != null ? "failed" : "completed",
|
|
8939
|
+
lifecycleSource: ptyAttached ? "spawn" : s.rehydrated ? "reconcile" : "exit",
|
|
8773
8940
|
// We spawned it, so `status` is the authoritative signal — no inferred
|
|
8774
8941
|
// `activity` is attached for managed sessions.
|
|
8775
|
-
ownership: "managed",
|
|
8942
|
+
ownership: s.rehydrated ? "historical" : "managed",
|
|
8776
8943
|
projectPath: s.projectPath,
|
|
8777
8944
|
projectName: s.projectName,
|
|
8778
8945
|
branch: s.branch,
|
|
@@ -9362,10 +9529,13 @@ var StreamerServer = class {
|
|
|
9362
9529
|
projectsRepo = null;
|
|
9363
9530
|
conversationsRepo = null;
|
|
9364
9531
|
sessionsRepo = null;
|
|
9365
|
-
// Durable session registry (C1 Phase 2). Null when
|
|
9366
|
-
//
|
|
9367
|
-
// taking the server down with it, so every write goes through `?.`.
|
|
9532
|
+
// Durable session registry (C1 Phase 2). Null when runtime.db failed to open
|
|
9533
|
+
// — persistence degrades to today's in-memory-only behaviour rather than
|
|
9534
|
+
// taking the server down with it, so every write goes through `?.`. Note the
|
|
9535
|
+
// handle is runtime.db, NOT the conversation cache: a cache failure used to
|
|
9536
|
+
// null this repo and silently disable all session persistence.
|
|
9368
9537
|
managedSessionsRepo = null;
|
|
9538
|
+
runtimeStore = null;
|
|
9369
9539
|
// Identifies this streamer run. A registry row carrying a different id is a
|
|
9370
9540
|
// session that outlived the process that started it.
|
|
9371
9541
|
streamerInstanceId = (0, import_crypto11.randomUUID)();
|
|
@@ -9384,6 +9554,7 @@ var StreamerServer = class {
|
|
|
9384
9554
|
liveActivityRenewal = null;
|
|
9385
9555
|
discoveryCache = null;
|
|
9386
9556
|
cacheDir;
|
|
9557
|
+
runtimeDbPath;
|
|
9387
9558
|
tailSize;
|
|
9388
9559
|
directoryDebounceMs;
|
|
9389
9560
|
// Trailing-debounced trigger that flags the scanner stale after a quiet
|
|
@@ -9430,6 +9601,7 @@ var StreamerServer = class {
|
|
|
9430
9601
|
this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
|
|
9431
9602
|
this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
|
|
9432
9603
|
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path18.join)((0, import_os10.homedir)(), ".threadbase", "cache");
|
|
9604
|
+
this.runtimeDbPath = config.runtimeDbPath ?? process.env.THREADBASE_RUNTIME_DB ?? (0, import_path18.join)(process.env.THREADBASE_CONFIG_DIR ?? (0, import_path18.join)((0, import_os10.homedir)(), ".threadbase"), "runtime.db");
|
|
9433
9605
|
this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
|
|
9434
9606
|
this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
|
|
9435
9607
|
this.markScannerStaleDebounced = debounce(() => {
|
|
@@ -9600,6 +9772,7 @@ var StreamerServer = class {
|
|
|
9600
9772
|
if (resp) this.wsHub.broadcast({ type: "session_ready", session: resp });
|
|
9601
9773
|
},
|
|
9602
9774
|
onStatusChange: (session) => {
|
|
9775
|
+
const previousStatus = this.sessionStore.getManaged(session.id)?.status;
|
|
9603
9776
|
this.sessionStore.updateManaged(session.id, {
|
|
9604
9777
|
status: session.status,
|
|
9605
9778
|
completedAt: session.completedAt,
|
|
@@ -9654,7 +9827,7 @@ var StreamerServer = class {
|
|
|
9654
9827
|
if (resp) {
|
|
9655
9828
|
this.wsHub.broadcast({ type: "session_update", session: resp });
|
|
9656
9829
|
}
|
|
9657
|
-
void this.liveActivityNotifier?.onStatusChange(session);
|
|
9830
|
+
void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
|
|
9658
9831
|
this.sessionStatusBus.emit(`status:${session.id}`, session.status);
|
|
9659
9832
|
}
|
|
9660
9833
|
});
|
|
@@ -9705,6 +9878,7 @@ var StreamerServer = class {
|
|
|
9705
9878
|
conversationsRepo: () => this.conversationsRepo,
|
|
9706
9879
|
sessionsRepo: () => this.sessionsRepo,
|
|
9707
9880
|
cacheMetadataRepo: () => this.cacheMetadataRepo,
|
|
9881
|
+
runtimeStore: () => this.runtimeStore,
|
|
9708
9882
|
ptyAttachedIds: () => this.ptyAttachedIds(),
|
|
9709
9883
|
handleListSessions: (url, res) => this.handleListSessions(url, res),
|
|
9710
9884
|
handleSessionsCount: (res) => this.handleSessionsCount(res),
|
|
@@ -10004,6 +10178,58 @@ var StreamerServer = class {
|
|
|
10004
10178
|
}
|
|
10005
10179
|
return verdicts;
|
|
10006
10180
|
}
|
|
10181
|
+
/**
|
|
10182
|
+
* Seed the session list with what previous runs left behind (persistence plan
|
|
10183
|
+
* Phase 1, gaps G1/G2/G8).
|
|
10184
|
+
*
|
|
10185
|
+
* Reconciliation classifies rows and stops there; a verdict is overlaid onto a
|
|
10186
|
+
* SessionResponse that already exists, and after a clean restart none does —
|
|
10187
|
+
* `SessionStore` starts empty. So the user's session did not become
|
|
10188
|
+
* `resumable`, it became *absent*. This is the half that puts it back.
|
|
10189
|
+
*
|
|
10190
|
+
* The seeded stubs hold no PTY and are never handed to `LiveSessionManager`,
|
|
10191
|
+
* so `reapIdleSessions` and `startGraceTimer` — both of which iterate
|
|
10192
|
+
* `ptyManager.listSessions()` — cannot observe them. A later resume calls
|
|
10193
|
+
* `sessionStore.addManaged` with the real session, which overwrites the stub
|
|
10194
|
+
* by id rather than duplicating it.
|
|
10195
|
+
*/
|
|
10196
|
+
rehydratePreviousSessions(verdicts) {
|
|
10197
|
+
if (!this.featureFlags.sessionRehydration || !this.managedSessionsRepo) return;
|
|
10198
|
+
try {
|
|
10199
|
+
const now = Date.now();
|
|
10200
|
+
const rows = this.managedSessionsRepo.listRecoverable({
|
|
10201
|
+
sinceMs: now - REHYDRATE_WINDOW_MS,
|
|
10202
|
+
limit: REHYDRATE_MAX + 1
|
|
10203
|
+
});
|
|
10204
|
+
const truncated = rows.length > REHYDRATE_MAX;
|
|
10205
|
+
const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
|
|
10206
|
+
if (candidates.length === 0) return;
|
|
10207
|
+
const lifecycleByVerdict = new Map(verdicts.map((v) => [v.sessionId, v.lifecycle]));
|
|
10208
|
+
let rehydrated = 0;
|
|
10209
|
+
for (const row of candidates) {
|
|
10210
|
+
if (this.sessionStore.getManaged(row.session_id)) continue;
|
|
10211
|
+
if (!shouldRehydrate(row, { now, projectExists: import_fs19.existsSync })) continue;
|
|
10212
|
+
this.sessionStore.addManaged(rowToStubSession(row));
|
|
10213
|
+
this.sessionLifecycles.set(
|
|
10214
|
+
row.session_id,
|
|
10215
|
+
lifecycleByVerdict.get(row.session_id) ?? "resumable"
|
|
10216
|
+
);
|
|
10217
|
+
if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
|
|
10218
|
+
rehydrated++;
|
|
10219
|
+
}
|
|
10220
|
+
this.log.info(`[rehydrate] recovered ${rehydrated} session(s) from the registry`, {
|
|
10221
|
+
event: "sessions.rehydrated",
|
|
10222
|
+
rehydrated,
|
|
10223
|
+
skipped: candidates.length - rehydrated,
|
|
10224
|
+
truncated
|
|
10225
|
+
});
|
|
10226
|
+
} catch (err) {
|
|
10227
|
+
this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
|
|
10228
|
+
event: "sessions.rehydrate_failed",
|
|
10229
|
+
err
|
|
10230
|
+
});
|
|
10231
|
+
}
|
|
10232
|
+
}
|
|
10007
10233
|
/**
|
|
10008
10234
|
* Pick a token guaranteed to appear in the spawned process's argv, for the
|
|
10009
10235
|
* reconciler's pid-reuse guard.
|
|
@@ -10237,6 +10463,17 @@ var StreamerServer = class {
|
|
|
10237
10463
|
port,
|
|
10238
10464
|
event: "server.listening"
|
|
10239
10465
|
});
|
|
10466
|
+
try {
|
|
10467
|
+
this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
|
|
10468
|
+
this.managedSessionsRepo = new ManagedSessionsRepository(this.runtimeStore.getDatabase());
|
|
10469
|
+
} catch (err) {
|
|
10470
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
10471
|
+
const abiMismatch = message.includes("NODE_MODULE_VERSION") || message.includes("was compiled against a different Node.js version");
|
|
10472
|
+
this.log.error(
|
|
10473
|
+
`Runtime store failed to open \u2014 session persistence DISABLED; sessions will not survive a restart.` + (abiMismatch ? ` Fix: npm rebuild better-sqlite3` : "") + ` (${message})`,
|
|
10474
|
+
{ error: message, abiMismatch, path: this.runtimeDbPath, event: "runtime.open_failed" }
|
|
10475
|
+
);
|
|
10476
|
+
}
|
|
10240
10477
|
try {
|
|
10241
10478
|
this.cache = ConversationCache.open(
|
|
10242
10479
|
(0, import_path18.join)(this.cacheDir, "cache.db"),
|
|
@@ -10264,8 +10501,20 @@ var StreamerServer = class {
|
|
|
10264
10501
|
this.projectsRepo = new ProjectsRepository(db);
|
|
10265
10502
|
this.conversationsRepo = new ConversationsRepository(this.cache);
|
|
10266
10503
|
this.sessionsRepo = new SessionsRepository(this.sessionStore);
|
|
10267
|
-
|
|
10268
|
-
|
|
10504
|
+
try {
|
|
10505
|
+
const copied = this.runtimeStore?.importLegacyManagedSessions(db) ?? 0;
|
|
10506
|
+
if (copied > 0) {
|
|
10507
|
+
this.log.info(`Copied ${copied} managed session row(s) from cache.db to runtime.db`, {
|
|
10508
|
+
copied,
|
|
10509
|
+
event: "runtime.legacy_import"
|
|
10510
|
+
});
|
|
10511
|
+
}
|
|
10512
|
+
} catch (err) {
|
|
10513
|
+
this.log.warn("[registry] legacy managed_sessions copy failed", {
|
|
10514
|
+
event: "runtime.legacy_import_failed",
|
|
10515
|
+
err
|
|
10516
|
+
});
|
|
10517
|
+
}
|
|
10269
10518
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
10270
10519
|
this.pushRepo = new PushRepository(db);
|
|
10271
10520
|
this.devicesRepo = new DevicesRepository(db);
|
|
@@ -10301,6 +10550,7 @@ var StreamerServer = class {
|
|
|
10301
10550
|
);
|
|
10302
10551
|
this.scannerPersistenceDisabled = true;
|
|
10303
10552
|
}
|
|
10553
|
+
void this.reconcilePreviousSessions().then((v) => this.rehydratePreviousSessions(v));
|
|
10304
10554
|
if (this.skipStartupWarmup) {
|
|
10305
10555
|
this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
|
|
10306
10556
|
event: "cache.warmup_skipped"
|
|
@@ -10508,6 +10758,7 @@ var StreamerServer = class {
|
|
|
10508
10758
|
this.allScanners.clear();
|
|
10509
10759
|
this.scanner = null;
|
|
10510
10760
|
this.cache?.close();
|
|
10761
|
+
this.runtimeStore?.close();
|
|
10511
10762
|
this.ptyManager.dispose();
|
|
10512
10763
|
this.fileWatcher.dispose();
|
|
10513
10764
|
this.externalTails.clear();
|
|
@@ -10955,7 +11206,8 @@ var StreamerServer = class {
|
|
|
10955
11206
|
}
|
|
10956
11207
|
handleSessionsCount(res) {
|
|
10957
11208
|
if (this.rejectIfWarmingUp(res)) return;
|
|
10958
|
-
|
|
11209
|
+
const total = this.sessionStore.list(this.ptyAttachedIds()).filter((s) => s.ownership !== "historical").length;
|
|
11210
|
+
json(res, 200, { total });
|
|
10959
11211
|
}
|
|
10960
11212
|
handleGetRecentSessions(url, res) {
|
|
10961
11213
|
if (this.rejectIfWarmingUp(res)) return;
|