@threadbase-sh/streamer 1.41.2 → 1.43.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/dist/cli.cjs +3221 -2321
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +559 -146
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +196 -6
- package/dist/index.d.ts +196 -6
- package/dist/index.js +553 -140
- package/dist/index.js.map +1 -1
- package/dist/runtime-migrations/002_add_managed_session_boot_token.sql +10 -0
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1201,7 +1201,10 @@ var CodexPtyRunner = class {
|
|
|
1201
1201
|
const projectName = options.projectName ?? (0, import_path5.basename)(options.projectPath);
|
|
1202
1202
|
const proc = nodePty.spawn(
|
|
1203
1203
|
resolveCodexExe(),
|
|
1204
|
-
|
|
1204
|
+
// `sessionId` stays the runner's map key — only argv carries the
|
|
1205
|
+
// provider-side id, so a resumed Codex session keeps the placeholder id
|
|
1206
|
+
// its client already navigated to.
|
|
1207
|
+
["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
|
|
1205
1208
|
{
|
|
1206
1209
|
name: "xterm-256color",
|
|
1207
1210
|
cols: PTY_COLS,
|
|
@@ -4140,6 +4143,86 @@ function redactValue(value) {
|
|
|
4140
4143
|
return value;
|
|
4141
4144
|
}
|
|
4142
4145
|
|
|
4146
|
+
// src/services/sessions/resumeIdentity.ts
|
|
4147
|
+
function resumeIdForRow(row) {
|
|
4148
|
+
if (row.provider !== CODEX_CLI_PROVIDER) return row.session_id;
|
|
4149
|
+
return row.bound_conversation_id;
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
// src/services/sessions/rehydrateSessions.ts
|
|
4153
|
+
var REHYDRATE_MAX = 25;
|
|
4154
|
+
var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
4155
|
+
var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
|
|
4156
|
+
function rehydrateSkipReason(row, opts) {
|
|
4157
|
+
if (resumeIdForRow(row) == null) return "codex_unbound";
|
|
4158
|
+
if (!opts.projectExists(row.project_path)) return "project_missing";
|
|
4159
|
+
if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return "too_old";
|
|
4160
|
+
if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) {
|
|
4161
|
+
return "agent_exited";
|
|
4162
|
+
}
|
|
4163
|
+
return null;
|
|
4164
|
+
}
|
|
4165
|
+
function rowToStubSession(row) {
|
|
4166
|
+
return {
|
|
4167
|
+
id: row.session_id,
|
|
4168
|
+
provider: row.provider,
|
|
4169
|
+
projectPath: row.project_path,
|
|
4170
|
+
projectName: row.project_name,
|
|
4171
|
+
branch: row.branch,
|
|
4172
|
+
// No PTY exists for a stub, so this is the only truthful status.
|
|
4173
|
+
status: "idle",
|
|
4174
|
+
startedAt: new Date(row.started_at),
|
|
4175
|
+
completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
|
|
4176
|
+
promptCount: row.prompt_count,
|
|
4177
|
+
lastOutput: "",
|
|
4178
|
+
rehydrated: true,
|
|
4179
|
+
...row.session_name != null && { sessionName: row.session_name },
|
|
4180
|
+
...row.project_id != null && { projectId: row.project_id },
|
|
4181
|
+
...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
|
|
4182
|
+
...row.resumed_from_conversation_id != null && {
|
|
4183
|
+
resumedFromConversationId: row.resumed_from_conversation_id
|
|
4184
|
+
},
|
|
4185
|
+
...row.failure_reason != null && { failureReason: row.failure_reason },
|
|
4186
|
+
...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
|
|
4187
|
+
// Only `shutdown` crosses over. It is the one registry source that is also a
|
|
4188
|
+
// wire StatusSource *and* that genuinely describes the `idle` above — the
|
|
4189
|
+
// streamer stopped this session. A crashed row still says `transition` over
|
|
4190
|
+
// a `running` status, and copying that here would attach observed-confidence
|
|
4191
|
+
// provenance to a status we derived at boot, so leave it unset instead.
|
|
4192
|
+
...row.status_source === "shutdown" && {
|
|
4193
|
+
statusSource: "shutdown",
|
|
4194
|
+
statusUpdatedAt: new Date(row.status_updated_at),
|
|
4195
|
+
// `status` above had to flatten to `idle`, which erases whether the agent
|
|
4196
|
+
// was mid-answer when we stopped it. Carried separately so a client can
|
|
4197
|
+
// say "interrupted mid-response" without a novel SessionStatus value.
|
|
4198
|
+
// Gated on the same `shutdown` source: a crashed row's `running` is a
|
|
4199
|
+
// frozen value nobody confirmed, not an observation.
|
|
4200
|
+
...(row.status === "running" || row.status === "waiting_input") && {
|
|
4201
|
+
interruptedStatus: row.status
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
};
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
// src/utils/bootToken.ts
|
|
4208
|
+
var import_node_fs3 = require("fs");
|
|
4209
|
+
var import_node_os = __toESM(require("os"), 1);
|
|
4210
|
+
var cached2;
|
|
4211
|
+
function currentBootToken() {
|
|
4212
|
+
if (cached2 === void 0) cached2 = computeBootToken();
|
|
4213
|
+
return cached2;
|
|
4214
|
+
}
|
|
4215
|
+
function computeBootToken() {
|
|
4216
|
+
if (process.platform === "linux") {
|
|
4217
|
+
try {
|
|
4218
|
+
const bootId = (0, import_node_fs3.readFileSync)("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
4219
|
+
if (bootId) return bootId;
|
|
4220
|
+
} catch {
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
return String(Math.round((Date.now() - import_node_os.default.uptime() * 1e3) / 1e4));
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4143
4226
|
// src/api/routes/diagnostics.routes.ts
|
|
4144
4227
|
function providerCheck(name, resolve2) {
|
|
4145
4228
|
try {
|
|
@@ -4219,6 +4302,49 @@ var createDiagnosticsRoutes = (deps) => {
|
|
|
4219
4302
|
);
|
|
4220
4303
|
return c.json(redactValue(buildReport(checks)));
|
|
4221
4304
|
});
|
|
4305
|
+
app.get("/sessions", (c) => {
|
|
4306
|
+
const repo = deps.managedSessionsRepo();
|
|
4307
|
+
if (!repo) {
|
|
4308
|
+
return c.json(
|
|
4309
|
+
{ error: "Session registry is unavailable", code: "REGISTRY_UNAVAILABLE" },
|
|
4310
|
+
503
|
|
4311
|
+
);
|
|
4312
|
+
}
|
|
4313
|
+
const rows = repo.listAll();
|
|
4314
|
+
const verdicts = deps.sessionVerdicts();
|
|
4315
|
+
const bootToken = currentBootToken();
|
|
4316
|
+
const now = Date.now();
|
|
4317
|
+
const sessions = rows.map((row) => {
|
|
4318
|
+
const verdict = verdicts.get(row.session_id);
|
|
4319
|
+
const skip = rehydrateSkipReason(row, { now, projectExists: import_fs7.existsSync });
|
|
4320
|
+
return {
|
|
4321
|
+
sessionId: row.session_id,
|
|
4322
|
+
provider: row.provider,
|
|
4323
|
+
status: row.status,
|
|
4324
|
+
statusSource: row.status_source,
|
|
4325
|
+
statusUpdatedAt: new Date(row.status_updated_at).toISOString(),
|
|
4326
|
+
// Whether the recorded pid is probeable at all this boot, not whether
|
|
4327
|
+
// it is alive — a mismatch means the question was never asked.
|
|
4328
|
+
//
|
|
4329
|
+
// NOT named for the boot token it derives from: `redactValue`'s
|
|
4330
|
+
// SECRET_KEY_RE matches any key containing "token", so `bootTokenMatches`
|
|
4331
|
+
// was scrubbed to the string "[redacted]" and the field shipped useless.
|
|
4332
|
+
// The regex is deliberately over-broad for a payload meant to be pasted
|
|
4333
|
+
// into bug reports, so the field moved rather than the guard.
|
|
4334
|
+
recordedThisBoot: row.boot_token != null && row.boot_token === bootToken,
|
|
4335
|
+
// Absent when this boot never classified the row: a clean restart
|
|
4336
|
+
// stamps completed_at on the way out, which takes it out of the probe
|
|
4337
|
+
// set entirely. That absence is itself the answer.
|
|
4338
|
+
lifecycle: verdict?.lifecycle ?? null,
|
|
4339
|
+
lifecycleReason: verdict?.reason ?? null,
|
|
4340
|
+
rehydrated: skip == null,
|
|
4341
|
+
rehydrateSkipReason: skip,
|
|
4342
|
+
projectExists: (0, import_fs7.existsSync)(row.project_path),
|
|
4343
|
+
projectPath: redactPath(row.project_path)
|
|
4344
|
+
};
|
|
4345
|
+
});
|
|
4346
|
+
return c.json(redactValue({ generatedAt: (/* @__PURE__ */ new Date()).toISOString(), sessions }));
|
|
4347
|
+
});
|
|
4222
4348
|
return app;
|
|
4223
4349
|
};
|
|
4224
4350
|
|
|
@@ -4234,16 +4360,16 @@ var createHealthRoutes = (deps) => {
|
|
|
4234
4360
|
};
|
|
4235
4361
|
|
|
4236
4362
|
// src/api/routes/logs.routes.ts
|
|
4237
|
-
var
|
|
4363
|
+
var import_node_fs4 = require("fs");
|
|
4238
4364
|
var import_node_path5 = require("path");
|
|
4239
4365
|
var import_hono10 = require("hono");
|
|
4240
4366
|
|
|
4241
4367
|
// src/lifecycle/constants.ts
|
|
4242
|
-
var
|
|
4368
|
+
var import_node_os2 = require("os");
|
|
4243
4369
|
var import_node_path4 = require("path");
|
|
4244
4370
|
var TASK_NAME = process.env.THREADBASE_TASK_NAME ?? "Threadbase";
|
|
4245
4371
|
function installDir() {
|
|
4246
|
-
return process.env.THREADBASE_INSTALL_DIR ?? (0, import_node_path4.join)((0,
|
|
4372
|
+
return process.env.THREADBASE_INSTALL_DIR ?? (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".threadbase");
|
|
4247
4373
|
}
|
|
4248
4374
|
|
|
4249
4375
|
// src/api/routes/logs.routes.ts
|
|
@@ -4254,22 +4380,22 @@ function resolveLogPath(source) {
|
|
|
4254
4380
|
function pickDefaultSource() {
|
|
4255
4381
|
for (const source of ["stdout", "stderr", "dev"]) {
|
|
4256
4382
|
const p = resolveLogPath(source);
|
|
4257
|
-
if ((0,
|
|
4383
|
+
if ((0, import_node_fs4.existsSync)(p) && (0, import_node_fs4.statSync)(p).size > 0) return source;
|
|
4258
4384
|
}
|
|
4259
4385
|
return "stdout";
|
|
4260
4386
|
}
|
|
4261
4387
|
function readLogLines(filePath, sinceOffset, limit) {
|
|
4262
|
-
if (!(0,
|
|
4388
|
+
if (!(0, import_node_fs4.existsSync)(filePath)) {
|
|
4263
4389
|
return { lines: [], offset: 0, total: 0 };
|
|
4264
4390
|
}
|
|
4265
|
-
const fd = (0,
|
|
4391
|
+
const fd = (0, import_node_fs4.openSync)(filePath, "r");
|
|
4266
4392
|
try {
|
|
4267
|
-
const { size } = (0,
|
|
4393
|
+
const { size } = (0, import_node_fs4.fstatSync)(fd);
|
|
4268
4394
|
if (size === 0) return { lines: [], offset: 0, total: 0 };
|
|
4269
4395
|
const maxBytes = Math.min(size, 2 * 1024 * 1024);
|
|
4270
4396
|
const start = size - maxBytes;
|
|
4271
4397
|
const buf = Buffer.alloc(maxBytes);
|
|
4272
|
-
(0,
|
|
4398
|
+
(0, import_node_fs4.readSync)(fd, buf, 0, maxBytes, start);
|
|
4273
4399
|
let text = buf.toString("utf8");
|
|
4274
4400
|
if (start > 0) {
|
|
4275
4401
|
const firstNl = text.indexOf("\n");
|
|
@@ -4290,7 +4416,7 @@ function readLogLines(filePath, sinceOffset, limit) {
|
|
|
4290
4416
|
}
|
|
4291
4417
|
return { lines, offset: newOffset, total: allLines.length };
|
|
4292
4418
|
} finally {
|
|
4293
|
-
(0,
|
|
4419
|
+
(0, import_node_fs4.closeSync)(fd);
|
|
4294
4420
|
}
|
|
4295
4421
|
}
|
|
4296
4422
|
function createLogsRoutes() {
|
|
@@ -4302,7 +4428,7 @@ function createLogsRoutes() {
|
|
|
4302
4428
|
const logPath = resolveLogPath(source);
|
|
4303
4429
|
const sinceOffset = parseInt(c.req.query("since") || "0", 10);
|
|
4304
4430
|
const limit = Math.min(parseInt(c.req.query("limit") || "100", 10) || 100, 1e3);
|
|
4305
|
-
if (!(0,
|
|
4431
|
+
if (!(0, import_node_fs4.existsSync)(logPath)) {
|
|
4306
4432
|
return c.json({
|
|
4307
4433
|
logs: [],
|
|
4308
4434
|
message: `No log file found for source=${source}`,
|
|
@@ -4312,7 +4438,7 @@ function createLogsRoutes() {
|
|
|
4312
4438
|
});
|
|
4313
4439
|
}
|
|
4314
4440
|
const { lines, offset, total } = readLogLines(logPath, sinceOffset, limit);
|
|
4315
|
-
const stats = (0,
|
|
4441
|
+
const stats = (0, import_node_fs4.statSync)(logPath);
|
|
4316
4442
|
return c.json({
|
|
4317
4443
|
logs: lines,
|
|
4318
4444
|
offset,
|
|
@@ -4339,10 +4465,10 @@ function createLogsRoutes() {
|
|
|
4339
4465
|
try {
|
|
4340
4466
|
const sources = ["stdout", "stderr", "dev"].map((source) => {
|
|
4341
4467
|
const logPath = resolveLogPath(source);
|
|
4342
|
-
if (!(0,
|
|
4468
|
+
if (!(0, import_node_fs4.existsSync)(logPath)) {
|
|
4343
4469
|
return { source, exists: false, total: 0, fileSize: 0 };
|
|
4344
4470
|
}
|
|
4345
|
-
const stats = (0,
|
|
4471
|
+
const stats = (0, import_node_fs4.statSync)(logPath);
|
|
4346
4472
|
return {
|
|
4347
4473
|
source,
|
|
4348
4474
|
exists: true,
|
|
@@ -4369,8 +4495,8 @@ var import_hono11 = require("hono");
|
|
|
4369
4495
|
var import_os6 = require("os");
|
|
4370
4496
|
|
|
4371
4497
|
// src/config/update-config.ts
|
|
4372
|
-
var
|
|
4373
|
-
var
|
|
4498
|
+
var import_node_fs5 = require("fs");
|
|
4499
|
+
var import_node_os3 = require("os");
|
|
4374
4500
|
var import_node_path6 = require("path");
|
|
4375
4501
|
var import_yaml = require("yaml");
|
|
4376
4502
|
|
|
@@ -4387,12 +4513,12 @@ var UpdateConfigSchema = import_zod3.z.object({
|
|
|
4387
4513
|
}).strict();
|
|
4388
4514
|
|
|
4389
4515
|
// src/config/update-config.ts
|
|
4390
|
-
var DEFAULT_CONFIG_PATH = (0, import_node_path6.join)((0,
|
|
4516
|
+
var DEFAULT_CONFIG_PATH = (0, import_node_path6.join)((0, import_node_os3.homedir)(), ".threadbase", "update.yaml");
|
|
4391
4517
|
function loadUpdateConfig(opts = {}) {
|
|
4392
4518
|
const path = opts.path ?? DEFAULT_CONFIG_PATH;
|
|
4393
4519
|
let raw;
|
|
4394
4520
|
try {
|
|
4395
|
-
raw = (0,
|
|
4521
|
+
raw = (0, import_node_fs5.readFileSync)(path, "utf-8");
|
|
4396
4522
|
} catch (err) {
|
|
4397
4523
|
if (err.code === "ENOENT") return null;
|
|
4398
4524
|
throw err;
|
|
@@ -5284,8 +5410,8 @@ function isAgentLine(line, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
|
|
|
5284
5410
|
function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
|
|
5285
5411
|
if (entrypoints.size === 0) return false;
|
|
5286
5412
|
const key = cacheKey(filePath, entrypoints);
|
|
5287
|
-
const
|
|
5288
|
-
if (
|
|
5413
|
+
const cached3 = fileDecisionCache.get(key);
|
|
5414
|
+
if (cached3 !== void 0) return cached3;
|
|
5289
5415
|
let fd;
|
|
5290
5416
|
try {
|
|
5291
5417
|
fd = (0, import_fs9.openSync)(filePath, "r");
|
|
@@ -5941,9 +6067,9 @@ var ConversationCache = class _ConversationCache {
|
|
|
5941
6067
|
classifyAgentFile(filePath, mtimeMs, fileSize) {
|
|
5942
6068
|
if (this.agentEntrypoints.size === 0) return false;
|
|
5943
6069
|
const entrypointsKey = this.agentEntrypointsKey();
|
|
5944
|
-
const
|
|
5945
|
-
if (
|
|
5946
|
-
return
|
|
6070
|
+
const cached3 = this.stmts.getFileMetadata.get(filePath);
|
|
6071
|
+
if (cached3 && cached3.mtime_ms === mtimeMs && cached3.file_size === fileSize && cached3.agent_entrypoints_key === entrypointsKey) {
|
|
6072
|
+
return cached3.is_agent === 1;
|
|
5947
6073
|
}
|
|
5948
6074
|
const isAgent = isAgentFile(filePath, this.agentEntrypoints);
|
|
5949
6075
|
this.stmts.upsertFileMetadata.run({
|
|
@@ -6648,11 +6774,17 @@ var ConversationsRepository = class {
|
|
|
6648
6774
|
};
|
|
6649
6775
|
|
|
6650
6776
|
// src/db/repositories/managed-sessions.repository.ts
|
|
6777
|
+
var PROBE_SET_MAX = 200;
|
|
6778
|
+
var DIAGNOSTICS_MAX = 200;
|
|
6779
|
+
var TERMINAL_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
6651
6780
|
var ManagedSessionsRepository = class {
|
|
6652
6781
|
upsertStmt;
|
|
6653
6782
|
updateStatusStmt;
|
|
6783
|
+
bindStmt;
|
|
6654
6784
|
getStmt;
|
|
6655
6785
|
listNonTerminalStmt;
|
|
6786
|
+
listAllStmt;
|
|
6787
|
+
pruneTerminalStmt;
|
|
6656
6788
|
listRecoverableStmt;
|
|
6657
6789
|
deleteStmt;
|
|
6658
6790
|
constructor(db) {
|
|
@@ -6662,13 +6794,13 @@ var ManagedSessionsRepository = class {
|
|
|
6662
6794
|
status, status_source, status_updated_at, started_at, completed_at,
|
|
6663
6795
|
last_activity_at, prompt_count, session_name, project_id,
|
|
6664
6796
|
bound_conversation_id, resumed_from_conversation_id, failure_reason,
|
|
6665
|
-
streamer_instance_id
|
|
6797
|
+
streamer_instance_id, boot_token
|
|
6666
6798
|
) VALUES (
|
|
6667
6799
|
@session_id, @provider, @pid, @cmdline, @project_path, @project_name, @branch,
|
|
6668
6800
|
@status, @status_source, @status_updated_at, @started_at, @completed_at,
|
|
6669
6801
|
@last_activity_at, @prompt_count, @session_name, @project_id,
|
|
6670
6802
|
@bound_conversation_id, @resumed_from_conversation_id, @failure_reason,
|
|
6671
|
-
@streamer_instance_id
|
|
6803
|
+
@streamer_instance_id, @boot_token
|
|
6672
6804
|
)
|
|
6673
6805
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
6674
6806
|
pid = excluded.pid,
|
|
@@ -6687,7 +6819,8 @@ var ManagedSessionsRepository = class {
|
|
|
6687
6819
|
bound_conversation_id = excluded.bound_conversation_id,
|
|
6688
6820
|
resumed_from_conversation_id = excluded.resumed_from_conversation_id,
|
|
6689
6821
|
failure_reason = excluded.failure_reason,
|
|
6690
|
-
streamer_instance_id = excluded.streamer_instance_id
|
|
6822
|
+
streamer_instance_id = excluded.streamer_instance_id,
|
|
6823
|
+
boot_token = excluded.boot_token
|
|
6691
6824
|
`);
|
|
6692
6825
|
this.updateStatusStmt = db.prepare(`
|
|
6693
6826
|
UPDATE managed_sessions
|
|
@@ -6701,11 +6834,27 @@ var ManagedSessionsRepository = class {
|
|
|
6701
6834
|
session_name = COALESCE(@session_name, session_name)
|
|
6702
6835
|
WHERE session_id = @session_id
|
|
6703
6836
|
`);
|
|
6837
|
+
this.bindStmt = db.prepare(`
|
|
6838
|
+
UPDATE managed_sessions
|
|
6839
|
+
SET bound_conversation_id = @bound_conversation_id
|
|
6840
|
+
WHERE session_id = @session_id
|
|
6841
|
+
`);
|
|
6704
6842
|
this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
|
|
6705
6843
|
this.listNonTerminalStmt = db.prepare(`
|
|
6706
6844
|
SELECT * FROM managed_sessions
|
|
6707
6845
|
WHERE completed_at IS NULL
|
|
6708
6846
|
ORDER BY started_at ASC
|
|
6847
|
+
LIMIT @limit
|
|
6848
|
+
`);
|
|
6849
|
+
this.listAllStmt = db.prepare(`
|
|
6850
|
+
SELECT * FROM managed_sessions
|
|
6851
|
+
ORDER BY status_updated_at DESC
|
|
6852
|
+
LIMIT @limit
|
|
6853
|
+
`);
|
|
6854
|
+
this.pruneTerminalStmt = db.prepare(`
|
|
6855
|
+
DELETE FROM managed_sessions
|
|
6856
|
+
WHERE completed_at IS NOT NULL
|
|
6857
|
+
AND completed_at < @before
|
|
6709
6858
|
`);
|
|
6710
6859
|
this.listRecoverableStmt = db.prepare(`
|
|
6711
6860
|
SELECT * FROM managed_sessions
|
|
@@ -6738,7 +6887,10 @@ var ManagedSessionsRepository = class {
|
|
|
6738
6887
|
bound_conversation_id: session.boundConversationId ?? null,
|
|
6739
6888
|
resumed_from_conversation_id: session.resumedFromConversationId ?? null,
|
|
6740
6889
|
failure_reason: session.failureReason ?? null,
|
|
6741
|
-
streamer_instance_id: streamerInstanceId
|
|
6890
|
+
streamer_instance_id: streamerInstanceId,
|
|
6891
|
+
// Recorded, never backfilled: the pid above is only probeable while this
|
|
6892
|
+
// token still matches the running machine.
|
|
6893
|
+
boot_token: currentBootToken()
|
|
6742
6894
|
});
|
|
6743
6895
|
}
|
|
6744
6896
|
/**
|
|
@@ -6759,12 +6911,48 @@ var ManagedSessionsRepository = class {
|
|
|
6759
6911
|
session_name: fields.sessionName ?? null
|
|
6760
6912
|
});
|
|
6761
6913
|
}
|
|
6914
|
+
/**
|
|
6915
|
+
* Persist the Codex rollout id discovered after spawn.
|
|
6916
|
+
*
|
|
6917
|
+
* Its own statement rather than a `recordSpawn` re-run: the binding arrives
|
|
6918
|
+
* while the session is live, and re-upserting would also rewrite `cmdline`
|
|
6919
|
+
* with an id that is *not* in a fresh Codex process's argv, turning the
|
|
6920
|
+
* reconciler's identity check into a false `orphaned`. Without this write the
|
|
6921
|
+
* binding lives only in memory and dies with the streamer — which is the
|
|
6922
|
+
* whole reason a restarted Codex session could not be resumed (G6).
|
|
6923
|
+
*/
|
|
6924
|
+
recordBinding(sessionId, boundConversationId) {
|
|
6925
|
+
this.bindStmt.run({
|
|
6926
|
+
session_id: sessionId,
|
|
6927
|
+
bound_conversation_id: boundConversationId
|
|
6928
|
+
});
|
|
6929
|
+
}
|
|
6762
6930
|
get(sessionId) {
|
|
6763
6931
|
return this.getStmt.get(sessionId) ?? null;
|
|
6764
6932
|
}
|
|
6765
|
-
/**
|
|
6766
|
-
|
|
6767
|
-
|
|
6933
|
+
/**
|
|
6934
|
+
* Rows with no recorded completion — the reconciler's probe set.
|
|
6935
|
+
*
|
|
6936
|
+
* Capped. Callers must compare the result length against the limit and say so
|
|
6937
|
+
* when it clips: a silently truncated probe set reads as "we checked
|
|
6938
|
+
* everything" when it did not.
|
|
6939
|
+
*/
|
|
6940
|
+
listNonTerminal(limit = PROBE_SET_MAX) {
|
|
6941
|
+
return this.listNonTerminalStmt.all({ limit });
|
|
6942
|
+
}
|
|
6943
|
+
/** Every row, most recently touched first, for the diagnostics surface. */
|
|
6944
|
+
listAll(limit = DIAGNOSTICS_MAX) {
|
|
6945
|
+
return this.listAllStmt.all({ limit });
|
|
6946
|
+
}
|
|
6947
|
+
/**
|
|
6948
|
+
* Delete terminal rows older than `olderThanMs`, returning how many went.
|
|
6949
|
+
*
|
|
6950
|
+
* Only rows carrying a `completed_at` are eligible, so nothing the reconciler
|
|
6951
|
+
* or rehydrator might still want is reachable from here — a row without one
|
|
6952
|
+
* is by definition unfinished business, however old it looks.
|
|
6953
|
+
*/
|
|
6954
|
+
pruneTerminal(olderThanMs = TERMINAL_RETENTION_MS) {
|
|
6955
|
+
return this.pruneTerminalStmt.run({ before: Date.now() - olderThanMs }).changes;
|
|
6768
6956
|
}
|
|
6769
6957
|
/**
|
|
6770
6958
|
* Rows a restart could bring back: still open, or closed by our own shutdown,
|
|
@@ -8630,6 +8818,34 @@ function paginate(results, offset, limit) {
|
|
|
8630
8818
|
};
|
|
8631
8819
|
}
|
|
8632
8820
|
|
|
8821
|
+
// src/services/sessions/autoResumeOnBoot.ts
|
|
8822
|
+
var AUTO_RESUME_WINDOW_MS = 15 * 60 * 1e3;
|
|
8823
|
+
var AUTO_RESUME_MAX = 5;
|
|
8824
|
+
var AUTO_RESUME_CONCURRENCY = 2;
|
|
8825
|
+
var AUTO_RESUME_STAGGER_MS = 500;
|
|
8826
|
+
function autoResumeSkipReason(row, opts) {
|
|
8827
|
+
if (row.status_source !== "shutdown") return "not_shutdown";
|
|
8828
|
+
if (row.status !== "running" && row.status !== "waiting_input") return "not_interrupted";
|
|
8829
|
+
if (opts.now - row.status_updated_at > AUTO_RESUME_WINDOW_MS) return "too_old";
|
|
8830
|
+
if (!opts.projectExists(row.project_path)) return "project_missing";
|
|
8831
|
+
if (resumeIdForRow(row) == null) return "resume_identity_missing";
|
|
8832
|
+
return null;
|
|
8833
|
+
}
|
|
8834
|
+
function planAutoResume(rows, opts) {
|
|
8835
|
+
const eligible = [];
|
|
8836
|
+
const skipped = [];
|
|
8837
|
+
for (const row of rows) {
|
|
8838
|
+
const reason = autoResumeSkipReason(row, opts);
|
|
8839
|
+
if (reason) skipped.push({ row, reason });
|
|
8840
|
+
else eligible.push(row);
|
|
8841
|
+
}
|
|
8842
|
+
return {
|
|
8843
|
+
attempts: eligible.slice(0, AUTO_RESUME_MAX),
|
|
8844
|
+
skipped,
|
|
8845
|
+
overflow: eligible.slice(AUTO_RESUME_MAX)
|
|
8846
|
+
};
|
|
8847
|
+
}
|
|
8848
|
+
|
|
8633
8849
|
// src/services/sessions/conversationBusy.ts
|
|
8634
8850
|
var import_fs18 = require("fs");
|
|
8635
8851
|
var RESUME_BUSY_WINDOW_MS = 12e4;
|
|
@@ -8733,8 +8949,14 @@ function readIdempotencyKey(body) {
|
|
|
8733
8949
|
}
|
|
8734
8950
|
|
|
8735
8951
|
// src/services/sessions/reconcileSessions.ts
|
|
8736
|
-
|
|
8952
|
+
var PRE_BOOT_REASON = "recorded before this machine boot";
|
|
8953
|
+
async function classifySession(row, probe, currentInstanceId, currentBootToken2 = null) {
|
|
8737
8954
|
const { session_id: sessionId } = row;
|
|
8955
|
+
const resumable = (reason) => resumeIdForRow(row) == null ? {
|
|
8956
|
+
sessionId,
|
|
8957
|
+
lifecycle: "failed",
|
|
8958
|
+
reason: "Codex session ended before its rollout id was known"
|
|
8959
|
+
} : { sessionId, lifecycle: "resumable", reason };
|
|
8738
8960
|
if (row.completed_at != null) {
|
|
8739
8961
|
const clean = probe.endedCleanly?.(row) ?? row.failure_reason == null;
|
|
8740
8962
|
return {
|
|
@@ -8744,18 +8966,19 @@ async function classifySession(row, probe, currentInstanceId) {
|
|
|
8744
8966
|
};
|
|
8745
8967
|
}
|
|
8746
8968
|
if (row.pid == null) {
|
|
8747
|
-
return
|
|
8969
|
+
return resumable("no pid recorded");
|
|
8970
|
+
}
|
|
8971
|
+
if (currentBootToken2 != null && row.boot_token !== currentBootToken2) {
|
|
8972
|
+
return resumable(PRE_BOOT_REASON);
|
|
8748
8973
|
}
|
|
8749
8974
|
if (!probe.isPidAlive(row.pid)) {
|
|
8750
|
-
|
|
8751
|
-
if (clean) {
|
|
8975
|
+
if (probe.endedCleanly?.(row)) {
|
|
8752
8976
|
return { sessionId, lifecycle: "completed", reason: "process gone, history ended cleanly" };
|
|
8753
8977
|
}
|
|
8754
|
-
|
|
8755
|
-
sessionId,
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
};
|
|
8978
|
+
if (row.failure_reason != null) {
|
|
8979
|
+
return { sessionId, lifecycle: "failed", reason: "process gone, failure recorded" };
|
|
8980
|
+
}
|
|
8981
|
+
return resumable("process gone, resumable from provider history");
|
|
8759
8982
|
}
|
|
8760
8983
|
const args = await probe.getProcessArgs(row.pid);
|
|
8761
8984
|
const token = row.cmdline;
|
|
@@ -8773,52 +8996,10 @@ async function classifySession(row, probe, currentInstanceId) {
|
|
|
8773
8996
|
reason: sameRun ? "owned by this run" : "survived a previous streamer run"
|
|
8774
8997
|
};
|
|
8775
8998
|
}
|
|
8776
|
-
async function reconcileSessions(rows, probe, currentInstanceId) {
|
|
8777
|
-
return Promise.all(
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
// src/services/sessions/rehydrateSessions.ts
|
|
8781
|
-
var REHYDRATE_MAX = 25;
|
|
8782
|
-
var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
8783
|
-
var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
|
|
8784
|
-
function shouldRehydrate(row, opts) {
|
|
8785
|
-
if (!opts.projectExists(row.project_path)) return false;
|
|
8786
|
-
if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return false;
|
|
8787
|
-
if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) return false;
|
|
8788
|
-
return true;
|
|
8789
|
-
}
|
|
8790
|
-
function rowToStubSession(row) {
|
|
8791
|
-
return {
|
|
8792
|
-
id: row.session_id,
|
|
8793
|
-
provider: row.provider,
|
|
8794
|
-
projectPath: row.project_path,
|
|
8795
|
-
projectName: row.project_name,
|
|
8796
|
-
branch: row.branch,
|
|
8797
|
-
// No PTY exists for a stub, so this is the only truthful status.
|
|
8798
|
-
status: "idle",
|
|
8799
|
-
startedAt: new Date(row.started_at),
|
|
8800
|
-
completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
|
|
8801
|
-
promptCount: row.prompt_count,
|
|
8802
|
-
lastOutput: "",
|
|
8803
|
-
rehydrated: true,
|
|
8804
|
-
...row.session_name != null && { sessionName: row.session_name },
|
|
8805
|
-
...row.project_id != null && { projectId: row.project_id },
|
|
8806
|
-
...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
|
|
8807
|
-
...row.resumed_from_conversation_id != null && {
|
|
8808
|
-
resumedFromConversationId: row.resumed_from_conversation_id
|
|
8809
|
-
},
|
|
8810
|
-
...row.failure_reason != null && { failureReason: row.failure_reason },
|
|
8811
|
-
...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
|
|
8812
|
-
// Only `shutdown` crosses over. It is the one registry source that is also a
|
|
8813
|
-
// wire StatusSource *and* that genuinely describes the `idle` above — the
|
|
8814
|
-
// streamer stopped this session. A crashed row still says `transition` over
|
|
8815
|
-
// a `running` status, and copying that here would attach observed-confidence
|
|
8816
|
-
// provenance to a status we derived at boot, so leave it unset instead.
|
|
8817
|
-
...row.status_source === "shutdown" && {
|
|
8818
|
-
statusSource: "shutdown",
|
|
8819
|
-
statusUpdatedAt: new Date(row.status_updated_at)
|
|
8820
|
-
}
|
|
8821
|
-
};
|
|
8999
|
+
async function reconcileSessions(rows, probe, currentInstanceId, currentBootToken2 = null) {
|
|
9000
|
+
return Promise.all(
|
|
9001
|
+
rows.map((row) => classifySession(row, probe, currentInstanceId, currentBootToken2))
|
|
9002
|
+
);
|
|
8822
9003
|
}
|
|
8823
9004
|
|
|
8824
9005
|
// src/types.ts
|
|
@@ -9009,7 +9190,8 @@ function managedToResponse(s, ptyAttached) {
|
|
|
9009
9190
|
...s.resumedFromConversationId != null && {
|
|
9010
9191
|
resumedFromConversationId: s.resumedFromConversationId
|
|
9011
9192
|
},
|
|
9012
|
-
...s.boundConversationId != null && { boundConversationId: s.boundConversationId }
|
|
9193
|
+
...s.boundConversationId != null && { boundConversationId: s.boundConversationId },
|
|
9194
|
+
...s.interruptedStatus != null && { interruptedStatus: s.interruptedStatus }
|
|
9013
9195
|
};
|
|
9014
9196
|
}
|
|
9015
9197
|
function discoveredToResponse(d, conversationId) {
|
|
@@ -9505,6 +9687,7 @@ var StreamerServer = class {
|
|
|
9505
9687
|
disableDb = false;
|
|
9506
9688
|
// Skip the startup warm-up scan (test hook; see ServerConfig.skipStartupWarmup).
|
|
9507
9689
|
skipStartupWarmup;
|
|
9690
|
+
autoResumeOnBoot;
|
|
9508
9691
|
browseRoot = null;
|
|
9509
9692
|
publicUrl = null;
|
|
9510
9693
|
browserCors;
|
|
@@ -9554,7 +9737,7 @@ var StreamerServer = class {
|
|
|
9554
9737
|
idempotency = new IdempotencyStore();
|
|
9555
9738
|
// sessionId → lifecycle verdict from boot reconciliation. Only holds sessions
|
|
9556
9739
|
// this run did NOT spawn; live ones derive their lifecycle from ptyAttached.
|
|
9557
|
-
|
|
9740
|
+
sessionVerdicts = /* @__PURE__ */ new Map();
|
|
9558
9741
|
// Periodic sweep that releases PTYs no agent is using. Null until listen().
|
|
9559
9742
|
idleReaperTimer = null;
|
|
9560
9743
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
@@ -9621,6 +9804,7 @@ var StreamerServer = class {
|
|
|
9621
9804
|
this.verbose = config.verbose ?? false;
|
|
9622
9805
|
this.disableDb = config.disableDb ?? false;
|
|
9623
9806
|
this.skipStartupWarmup = config.skipStartupWarmup ?? false;
|
|
9807
|
+
this.autoResumeOnBoot = config.autoResumeOnBoot ?? false;
|
|
9624
9808
|
this.scannerPersistenceDisabled = config.scannerPersistent === false;
|
|
9625
9809
|
this.scanProfiles = config.scanProfiles;
|
|
9626
9810
|
this.codexRoots = config.codexRoots ?? [(0, import_path18.join)((0, import_os10.homedir)(), ".codex", "sessions")];
|
|
@@ -9927,6 +10111,8 @@ var StreamerServer = class {
|
|
|
9927
10111
|
sessionsRepo: () => this.sessionsRepo,
|
|
9928
10112
|
cacheMetadataRepo: () => this.cacheMetadataRepo,
|
|
9929
10113
|
runtimeStore: () => this.runtimeStore,
|
|
10114
|
+
managedSessionsRepo: () => this.managedSessionsRepo,
|
|
10115
|
+
sessionVerdicts: () => this.sessionVerdicts,
|
|
9930
10116
|
ptyAttachedIds: () => this.ptyAttachedIds(),
|
|
9931
10117
|
handleListSessions: (url, res) => this.handleListSessions(url, res),
|
|
9932
10118
|
handleSessionsCount: (res) => this.handleSessionsCount(res),
|
|
@@ -10099,16 +10285,19 @@ var StreamerServer = class {
|
|
|
10099
10285
|
broadcastOrUnicastSessionList(req) {
|
|
10100
10286
|
const clientId = req.headers["x-client-id"];
|
|
10101
10287
|
const ws = typeof clientId === "string" ? this.clientIdToWs.get(clientId) : void 0;
|
|
10102
|
-
const payload =
|
|
10103
|
-
type: "session_list",
|
|
10104
|
-
sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
10105
|
-
};
|
|
10288
|
+
const payload = this.sessionListPayload();
|
|
10106
10289
|
if (ws) {
|
|
10107
10290
|
this.wsHub.unicast(ws, payload);
|
|
10108
10291
|
} else {
|
|
10109
10292
|
this.wsHub.broadcast(payload);
|
|
10110
10293
|
}
|
|
10111
10294
|
}
|
|
10295
|
+
sessionListPayload() {
|
|
10296
|
+
return {
|
|
10297
|
+
type: "session_list",
|
|
10298
|
+
sessions: this.withReconciledLifecycle(this.sessionStore.list(this.ptyAttachedIds()))
|
|
10299
|
+
};
|
|
10300
|
+
}
|
|
10112
10301
|
/**
|
|
10113
10302
|
* Overlay boot-reconciliation verdicts onto session responses.
|
|
10114
10303
|
*
|
|
@@ -10124,12 +10313,12 @@ var StreamerServer = class {
|
|
|
10124
10313
|
* it.
|
|
10125
10314
|
*/
|
|
10126
10315
|
withReconciledLifecycle(sessions) {
|
|
10127
|
-
if (this.
|
|
10316
|
+
if (this.sessionVerdicts.size === 0) return sessions;
|
|
10128
10317
|
return sessions.map((s) => {
|
|
10129
10318
|
if (s.ptyAttached) return s;
|
|
10130
|
-
const verdict = this.
|
|
10319
|
+
const verdict = this.sessionVerdicts.get(s.id);
|
|
10131
10320
|
if (!verdict) return s;
|
|
10132
|
-
return { ...s, lifecycle: verdict, lifecycleSource: "reconcile" };
|
|
10321
|
+
return { ...s, lifecycle: verdict.lifecycle, lifecycleSource: "reconcile" };
|
|
10133
10322
|
});
|
|
10134
10323
|
}
|
|
10135
10324
|
addSessionSubscriber(sessionId, ws) {
|
|
@@ -10197,14 +10386,30 @@ var StreamerServer = class {
|
|
|
10197
10386
|
let verdicts = [];
|
|
10198
10387
|
try {
|
|
10199
10388
|
const rows = this.managedSessionsRepo.listNonTerminal();
|
|
10389
|
+
if (rows.length === PROBE_SET_MAX) {
|
|
10390
|
+
this.log.warn(
|
|
10391
|
+
`[reconcile] probe set hit its cap of ${PROBE_SET_MAX} \u2014 older rows skipped`,
|
|
10392
|
+
{
|
|
10393
|
+
event: "registry.probe_truncated",
|
|
10394
|
+
limit: PROBE_SET_MAX
|
|
10395
|
+
}
|
|
10396
|
+
);
|
|
10397
|
+
}
|
|
10200
10398
|
if (rows.length === 0) return [];
|
|
10201
10399
|
verdicts = await reconcileSessions(
|
|
10202
10400
|
rows,
|
|
10203
10401
|
{ isPidAlive, getProcessArgs },
|
|
10204
|
-
this.streamerInstanceId
|
|
10402
|
+
this.streamerInstanceId,
|
|
10403
|
+
currentBootToken()
|
|
10205
10404
|
);
|
|
10206
10405
|
for (const v of verdicts) {
|
|
10207
|
-
this.
|
|
10406
|
+
this.sessionVerdicts.set(v.sessionId, v);
|
|
10407
|
+
if (v.reason === PRE_BOOT_REASON) {
|
|
10408
|
+
this.log.info(`[reconcile] ${v.sessionId} predates this machine boot \u2014 pid not probed`, {
|
|
10409
|
+
event: "sessions.boot_token_mismatch",
|
|
10410
|
+
sessionId: v.sessionId
|
|
10411
|
+
});
|
|
10412
|
+
}
|
|
10208
10413
|
if (v.lifecycle === "completed" || v.lifecycle === "failed") {
|
|
10209
10414
|
this.managedSessionsRepo.recordStatus(v.sessionId, "idle", "reconcile", {
|
|
10210
10415
|
completedAt: /* @__PURE__ */ new Date()
|
|
@@ -10226,6 +10431,30 @@ var StreamerServer = class {
|
|
|
10226
10431
|
}
|
|
10227
10432
|
return verdicts;
|
|
10228
10433
|
}
|
|
10434
|
+
/**
|
|
10435
|
+
* Drop finished sessions the registry has held long enough (plan Phase 4).
|
|
10436
|
+
*
|
|
10437
|
+
* The registry is authoritative and never rebuilt from the cache, so nothing
|
|
10438
|
+
* else would ever remove a row: without this it grows for the life of the
|
|
10439
|
+
* install, and every boot pays for rows about sessions from months ago.
|
|
10440
|
+
*/
|
|
10441
|
+
pruneTerminalSessions() {
|
|
10442
|
+
if (!this.managedSessionsRepo) return;
|
|
10443
|
+
try {
|
|
10444
|
+
const pruned = this.managedSessionsRepo.pruneTerminal();
|
|
10445
|
+
if (pruned > 0) {
|
|
10446
|
+
this.log.info(`[registry] pruned ${pruned} terminal session row(s)`, {
|
|
10447
|
+
event: "registry.pruned",
|
|
10448
|
+
pruned
|
|
10449
|
+
});
|
|
10450
|
+
}
|
|
10451
|
+
} catch (err) {
|
|
10452
|
+
this.log.warn("[registry] failed to prune terminal sessions", {
|
|
10453
|
+
event: "registry.prune_failed",
|
|
10454
|
+
err
|
|
10455
|
+
});
|
|
10456
|
+
}
|
|
10457
|
+
}
|
|
10229
10458
|
/**
|
|
10230
10459
|
* Seed the session list with what previous runs left behind (persistence plan
|
|
10231
10460
|
* Phase 1, gaps G1/G2/G8).
|
|
@@ -10242,7 +10471,7 @@ var StreamerServer = class {
|
|
|
10242
10471
|
* by id rather than duplicating it.
|
|
10243
10472
|
*/
|
|
10244
10473
|
rehydratePreviousSessions(verdicts) {
|
|
10245
|
-
if (!this.
|
|
10474
|
+
if (!this.managedSessionsRepo) return [];
|
|
10246
10475
|
try {
|
|
10247
10476
|
const now = Date.now();
|
|
10248
10477
|
const rows = this.managedSessionsRepo.listRecoverable({
|
|
@@ -10251,16 +10480,30 @@ var StreamerServer = class {
|
|
|
10251
10480
|
});
|
|
10252
10481
|
const truncated = rows.length > REHYDRATE_MAX;
|
|
10253
10482
|
const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
|
|
10254
|
-
if (candidates.length === 0) return;
|
|
10255
|
-
const
|
|
10483
|
+
if (!this.featureFlags.sessionRehydration || candidates.length === 0) return candidates;
|
|
10484
|
+
const verdictById = new Map(verdicts.map((v) => [v.sessionId, v]));
|
|
10256
10485
|
let rehydrated = 0;
|
|
10486
|
+
const skippedBy = {};
|
|
10257
10487
|
for (const row of candidates) {
|
|
10258
10488
|
if (this.sessionStore.getManaged(row.session_id)) continue;
|
|
10259
|
-
|
|
10489
|
+
const skip = rehydrateSkipReason(row, { now, projectExists: import_fs19.existsSync });
|
|
10490
|
+
if (skip) {
|
|
10491
|
+
skippedBy[skip] = (skippedBy[skip] ?? 0) + 1;
|
|
10492
|
+
this.log.info(`[rehydrate] skipped ${row.session_id}: ${skip}`, {
|
|
10493
|
+
event: "sessions.rehydrate_skipped",
|
|
10494
|
+
sessionId: row.session_id,
|
|
10495
|
+
reason: skip
|
|
10496
|
+
});
|
|
10497
|
+
continue;
|
|
10498
|
+
}
|
|
10260
10499
|
this.sessionStore.addManaged(rowToStubSession(row));
|
|
10261
|
-
this.
|
|
10500
|
+
this.sessionVerdicts.set(
|
|
10262
10501
|
row.session_id,
|
|
10263
|
-
|
|
10502
|
+
verdictById.get(row.session_id) ?? {
|
|
10503
|
+
sessionId: row.session_id,
|
|
10504
|
+
lifecycle: "resumable",
|
|
10505
|
+
reason: "recovered from the registry at boot"
|
|
10506
|
+
}
|
|
10264
10507
|
);
|
|
10265
10508
|
if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
|
|
10266
10509
|
rehydrated++;
|
|
@@ -10269,14 +10512,110 @@ var StreamerServer = class {
|
|
|
10269
10512
|
event: "sessions.rehydrated",
|
|
10270
10513
|
rehydrated,
|
|
10271
10514
|
skipped: candidates.length - rehydrated,
|
|
10515
|
+
skippedBy,
|
|
10272
10516
|
truncated
|
|
10273
10517
|
});
|
|
10518
|
+
return candidates;
|
|
10274
10519
|
} catch (err) {
|
|
10275
10520
|
this.log.warn("[rehydrate] failed to rehydrate previous sessions", {
|
|
10276
10521
|
event: "sessions.rehydrate_failed",
|
|
10277
10522
|
err
|
|
10278
10523
|
});
|
|
10524
|
+
return [];
|
|
10525
|
+
}
|
|
10526
|
+
}
|
|
10527
|
+
/** Resume only the recent sessions the user explicitly allowed us to start at boot. */
|
|
10528
|
+
async autoResumePreviousSessions(rows) {
|
|
10529
|
+
if (!this.autoResumeOnBoot) return;
|
|
10530
|
+
const plan = planAutoResume(rows, { now: Date.now(), projectExists: import_fs19.existsSync });
|
|
10531
|
+
const skippedBy = {};
|
|
10532
|
+
for (const { row, reason } of plan.skipped) {
|
|
10533
|
+
skippedBy[reason] = (skippedBy[reason] ?? 0) + 1;
|
|
10534
|
+
this.log.debug(`[auto-resume] skipped ${row.session_id}: ${reason}`, {
|
|
10535
|
+
event: "sessions.auto_resume_skipped",
|
|
10536
|
+
sessionId: row.session_id,
|
|
10537
|
+
reason
|
|
10538
|
+
});
|
|
10539
|
+
}
|
|
10540
|
+
if (plan.skipped.length > 0) {
|
|
10541
|
+
this.log.info(
|
|
10542
|
+
`[auto-resume] left ${plan.skipped.length} ineligible session(s) for manual resume`,
|
|
10543
|
+
{
|
|
10544
|
+
event: "sessions.auto_resume_skipped",
|
|
10545
|
+
skipped: plan.skipped.length,
|
|
10546
|
+
skippedBy
|
|
10547
|
+
}
|
|
10548
|
+
);
|
|
10549
|
+
}
|
|
10550
|
+
for (const row of plan.overflow) {
|
|
10551
|
+
this.log.info(`[auto-resume] left ${row.session_id} for manual resume: ceiling reached`, {
|
|
10552
|
+
event: "sessions.auto_resume_skipped",
|
|
10553
|
+
sessionId: row.session_id,
|
|
10554
|
+
reason: "ceiling_reached"
|
|
10555
|
+
});
|
|
10279
10556
|
}
|
|
10557
|
+
let resumed = 0;
|
|
10558
|
+
let failed = 0;
|
|
10559
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
10560
|
+
let started = 0;
|
|
10561
|
+
const resume = async (row) => {
|
|
10562
|
+
try {
|
|
10563
|
+
const outcome = await this.resumeSession({
|
|
10564
|
+
sessionId: row.session_id,
|
|
10565
|
+
projectName: row.project_name,
|
|
10566
|
+
branch: row.branch
|
|
10567
|
+
});
|
|
10568
|
+
if (!outcome.ok) {
|
|
10569
|
+
failed++;
|
|
10570
|
+
this.log.info(`[auto-resume] skipped ${row.session_id}: ${outcome.reason}`, {
|
|
10571
|
+
event: "sessions.auto_resume_skipped",
|
|
10572
|
+
sessionId: row.session_id,
|
|
10573
|
+
reason: outcome.reason,
|
|
10574
|
+
...outcome.reason === "conversation_busy" && {
|
|
10575
|
+
detectedBy: outcome.detectedBy,
|
|
10576
|
+
lastActivityMs: outcome.lastActivityMs,
|
|
10577
|
+
likelyOwner: outcome.likelyOwner
|
|
10578
|
+
}
|
|
10579
|
+
});
|
|
10580
|
+
return;
|
|
10581
|
+
}
|
|
10582
|
+
resumed++;
|
|
10583
|
+
this.log.info(`[auto-resume] resumed ${row.session_id}`, {
|
|
10584
|
+
event: "sessions.auto_resume_succeeded",
|
|
10585
|
+
sessionId: row.session_id,
|
|
10586
|
+
alreadyRunning: outcome.alreadyRunning
|
|
10587
|
+
});
|
|
10588
|
+
} catch (err) {
|
|
10589
|
+
failed++;
|
|
10590
|
+
this.log.warn(`[auto-resume] failed to resume ${row.session_id}`, {
|
|
10591
|
+
event: "sessions.auto_resume_failed",
|
|
10592
|
+
sessionId: row.session_id,
|
|
10593
|
+
err
|
|
10594
|
+
});
|
|
10595
|
+
}
|
|
10596
|
+
};
|
|
10597
|
+
for (const row of plan.attempts) {
|
|
10598
|
+
while (inFlight.size >= AUTO_RESUME_CONCURRENCY) {
|
|
10599
|
+
await Promise.race(inFlight);
|
|
10600
|
+
}
|
|
10601
|
+
if (started > 0) {
|
|
10602
|
+
await new Promise((resolve2) => setTimeout(resolve2, AUTO_RESUME_STAGGER_MS));
|
|
10603
|
+
}
|
|
10604
|
+
const task = resume(row);
|
|
10605
|
+
inFlight.add(task);
|
|
10606
|
+
void task.then(() => inFlight.delete(task));
|
|
10607
|
+
started++;
|
|
10608
|
+
}
|
|
10609
|
+
await Promise.all(inFlight);
|
|
10610
|
+
if (resumed > 0) this.wsHub.broadcast(this.sessionListPayload());
|
|
10611
|
+
this.log.info(`[auto-resume] completed boot recovery: ${resumed} resumed`, {
|
|
10612
|
+
event: "sessions.auto_resume_completed",
|
|
10613
|
+
attempted: plan.attempts.length,
|
|
10614
|
+
resumed,
|
|
10615
|
+
failed,
|
|
10616
|
+
ineligible: plan.skipped.length,
|
|
10617
|
+
overflow: plan.overflow.length
|
|
10618
|
+
});
|
|
10280
10619
|
}
|
|
10281
10620
|
/**
|
|
10282
10621
|
* Pick a token guaranteed to appear in the spawned process's argv, for the
|
|
@@ -10360,7 +10699,7 @@ var StreamerServer = class {
|
|
|
10360
10699
|
const now = /* @__PURE__ */ new Date();
|
|
10361
10700
|
for (const session of this.ptyManager.listSessions()) {
|
|
10362
10701
|
try {
|
|
10363
|
-
this.managedSessionsRepo.recordStatus(session.id,
|
|
10702
|
+
this.managedSessionsRepo.recordStatus(session.id, session.status, "shutdown", {
|
|
10364
10703
|
completedAt: now,
|
|
10365
10704
|
lastActivityAt: session.lastActivityAt ?? null,
|
|
10366
10705
|
promptCount: session.promptCount
|
|
@@ -10598,7 +10937,11 @@ var StreamerServer = class {
|
|
|
10598
10937
|
);
|
|
10599
10938
|
this.scannerPersistenceDisabled = true;
|
|
10600
10939
|
}
|
|
10601
|
-
void this.reconcilePreviousSessions().then((v) =>
|
|
10940
|
+
void this.reconcilePreviousSessions().then(async (v) => {
|
|
10941
|
+
const recoverableRows = this.rehydratePreviousSessions(v);
|
|
10942
|
+
await this.autoResumePreviousSessions(recoverableRows);
|
|
10943
|
+
this.pruneTerminalSessions();
|
|
10944
|
+
});
|
|
10602
10945
|
if (this.skipStartupWarmup) {
|
|
10603
10946
|
this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
|
|
10604
10947
|
event: "cache.warmup_skipped"
|
|
@@ -12166,32 +12509,85 @@ var StreamerServer = class {
|
|
|
12166
12509
|
json(res, 400, { error: "Missing sessionId" });
|
|
12167
12510
|
return;
|
|
12168
12511
|
}
|
|
12512
|
+
const outcome = await this.resumeSession({
|
|
12513
|
+
sessionId,
|
|
12514
|
+
force: body.force === true,
|
|
12515
|
+
projectName: body.projectName,
|
|
12516
|
+
branch: body.branch
|
|
12517
|
+
});
|
|
12518
|
+
if (!outcome.ok) {
|
|
12519
|
+
switch (outcome.reason) {
|
|
12520
|
+
case "history_file_missing":
|
|
12521
|
+
json(res, 404, {
|
|
12522
|
+
error: "Conversation history file is missing; it can no longer be resumed",
|
|
12523
|
+
code: "history_file_missing"
|
|
12524
|
+
});
|
|
12525
|
+
return;
|
|
12526
|
+
case "no_project_path":
|
|
12527
|
+
json(res, 400, { error: "Could not determine project path" });
|
|
12528
|
+
return;
|
|
12529
|
+
case "conversation_busy":
|
|
12530
|
+
json(res, 409, {
|
|
12531
|
+
error: "This conversation looks active in another session",
|
|
12532
|
+
code: "CONVERSATION_BUSY",
|
|
12533
|
+
detectedBy: outcome.detectedBy,
|
|
12534
|
+
lastActivityMs: outcome.lastActivityMs,
|
|
12535
|
+
likelyOwner: outcome.likelyOwner
|
|
12536
|
+
});
|
|
12537
|
+
return;
|
|
12538
|
+
}
|
|
12539
|
+
}
|
|
12540
|
+
if (outcome.alreadyRunning) {
|
|
12541
|
+
json(res, 200, outcome.response);
|
|
12542
|
+
return;
|
|
12543
|
+
}
|
|
12544
|
+
this.broadcastOrUnicastSessionList(req);
|
|
12545
|
+
json(res, 201, outcome.response ?? outcome.session);
|
|
12546
|
+
}
|
|
12547
|
+
/**
|
|
12548
|
+
* Resume a session, from an HTTP request or from the boot path.
|
|
12549
|
+
*
|
|
12550
|
+
* Extracted from `handleResume` so both callers hit the **same collision
|
|
12551
|
+
* probe** (plan Phase 7c). The probe is what stops this streamer attaching to
|
|
12552
|
+
* a conversation an external terminal already owns; a second, hand-adapted
|
|
12553
|
+
* copy of this sequence in the boot path is how two agents end up appending
|
|
12554
|
+
* to one JSONL at 4am with nobody watching.
|
|
12555
|
+
*
|
|
12556
|
+
* Returns a typed reason rather than writing a response, so the HTTP caller
|
|
12557
|
+
* maps it to a status code and the boot caller logs it.
|
|
12558
|
+
*/
|
|
12559
|
+
async resumeSession(opts) {
|
|
12560
|
+
const { sessionId } = opts;
|
|
12169
12561
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
12170
|
-
const
|
|
12171
|
-
if (
|
|
12172
|
-
|
|
12173
|
-
|
|
12562
|
+
const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
12563
|
+
if (resp) {
|
|
12564
|
+
return { ok: true, alreadyRunning: true, session: null, response: resp };
|
|
12565
|
+
}
|
|
12566
|
+
}
|
|
12567
|
+
let jsonlPath = this.findJsonlPath(sessionId);
|
|
12568
|
+
let conv = await this.findConversationByUuid(sessionId);
|
|
12569
|
+
let historyId = sessionId;
|
|
12570
|
+
let registryProvider;
|
|
12571
|
+
if (!jsonlPath && !conv) {
|
|
12572
|
+
const row = this.managedSessionsRepo?.get(sessionId) ?? null;
|
|
12573
|
+
const boundId = row ? resumeIdForRow(row) : null;
|
|
12574
|
+
if (boundId != null && boundId !== sessionId) {
|
|
12575
|
+
historyId = boundId;
|
|
12576
|
+
registryProvider = row?.provider;
|
|
12577
|
+
jsonlPath = this.findJsonlPath(boundId);
|
|
12578
|
+
conv = await this.findConversationByUuid(boundId);
|
|
12174
12579
|
}
|
|
12175
12580
|
}
|
|
12176
|
-
const jsonlPath = this.findJsonlPath(sessionId);
|
|
12177
12581
|
const jsonlCwd = jsonlPath ? await this.readCwdFromJsonl(jsonlPath) : null;
|
|
12178
|
-
const conv = await this.findConversationByUuid(sessionId);
|
|
12179
12582
|
const projectPath = jsonlCwd ?? conv?.projectPath;
|
|
12180
12583
|
if (!projectPath) {
|
|
12181
|
-
if (!conv && !jsonlPath) {
|
|
12182
|
-
|
|
12183
|
-
error: "Conversation history file is missing; it can no longer be resumed",
|
|
12184
|
-
code: "history_file_missing"
|
|
12185
|
-
});
|
|
12186
|
-
return;
|
|
12187
|
-
}
|
|
12188
|
-
json(res, 400, { error: "Could not determine project path" });
|
|
12189
|
-
return;
|
|
12584
|
+
if (!conv && !jsonlPath) return { ok: false, reason: "history_file_missing" };
|
|
12585
|
+
return { ok: false, reason: "no_project_path" };
|
|
12190
12586
|
}
|
|
12191
12587
|
let discovered = [];
|
|
12192
|
-
const
|
|
12193
|
-
if (
|
|
12194
|
-
discovered =
|
|
12588
|
+
const cached3 = this.discoveryCache;
|
|
12589
|
+
if (cached3 && Date.now() - cached3.fetchedAt < DISCOVERY_TTL_MS) {
|
|
12590
|
+
discovered = cached3.entries;
|
|
12195
12591
|
} else {
|
|
12196
12592
|
try {
|
|
12197
12593
|
discovered = await Promise.race([
|
|
@@ -12207,45 +12603,50 @@ var StreamerServer = class {
|
|
|
12207
12603
|
}
|
|
12208
12604
|
}
|
|
12209
12605
|
const busy = conversationBusy({
|
|
12210
|
-
|
|
12606
|
+
// The id another owner's argv would actually carry — for a placeholder
|
|
12607
|
+
// that is the bound rollout id, not the one the client asked for.
|
|
12608
|
+
conversationId: historyId,
|
|
12211
12609
|
projectPath,
|
|
12212
12610
|
jsonlPath,
|
|
12213
12611
|
discovered,
|
|
12214
12612
|
windowMs: resolveResumeBusyWindowMs(),
|
|
12215
12613
|
selfPtyEndedAt: this.selfPtyEndedAt.get(sessionId) ?? null
|
|
12216
12614
|
});
|
|
12217
|
-
if (busy.busy &&
|
|
12218
|
-
|
|
12219
|
-
|
|
12220
|
-
|
|
12615
|
+
if (busy.busy && opts.force !== true) {
|
|
12616
|
+
return {
|
|
12617
|
+
ok: false,
|
|
12618
|
+
reason: "conversation_busy",
|
|
12221
12619
|
detectedBy: busy.detectedBy,
|
|
12222
12620
|
lastActivityMs: busy.lastActivityMs,
|
|
12223
12621
|
likelyOwner: busy.likelyOwner
|
|
12224
|
-
}
|
|
12225
|
-
return;
|
|
12622
|
+
};
|
|
12226
12623
|
}
|
|
12227
12624
|
if (busy.busy) {
|
|
12228
12625
|
this.contendedSessions.add(sessionId);
|
|
12229
12626
|
}
|
|
12230
|
-
const cachedConvMeta = this.cache?.getMetaById(
|
|
12231
|
-
const provider = coerceProviderForRunner(
|
|
12627
|
+
const cachedConvMeta = this.cache?.getMetaById(historyId);
|
|
12628
|
+
const provider = coerceProviderForRunner(
|
|
12629
|
+
conv?.provider ?? cachedConvMeta?.provider ?? registryProvider
|
|
12630
|
+
);
|
|
12232
12631
|
this.discoveryCache = null;
|
|
12233
12632
|
const session = await this.ptyManager.start(sessionId, {
|
|
12234
12633
|
provider,
|
|
12235
12634
|
projectPath,
|
|
12236
|
-
projectName:
|
|
12237
|
-
branch:
|
|
12635
|
+
projectName: opts.projectName,
|
|
12636
|
+
branch: opts.branch,
|
|
12637
|
+
// Omitted on every ordinary resume, so argv is unchanged there.
|
|
12638
|
+
...historyId !== sessionId && { resumeId: historyId },
|
|
12238
12639
|
claudeFlags: this.claudeFlags,
|
|
12239
12640
|
claudeExtraArgs: this.claudeExtraArgs,
|
|
12240
12641
|
...this.spawnFlagOverrides()
|
|
12241
12642
|
});
|
|
12643
|
+
if (historyId !== sessionId) session.boundConversationId = historyId;
|
|
12242
12644
|
this.sessionStore.addManaged(session);
|
|
12243
12645
|
this.recordSessionSpawn(session);
|
|
12244
|
-
void this.watchConversationFile(sessionId);
|
|
12245
|
-
const
|
|
12246
|
-
this.broadcastOrUnicastSessionList(req);
|
|
12247
|
-
json(res, 201, resp ?? session);
|
|
12646
|
+
void this.watchConversationFile(sessionId, historyId);
|
|
12647
|
+
const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
12248
12648
|
this.enrichResumedSessionAsync(sessionId, projectPath, conv);
|
|
12649
|
+
return { ok: true, alreadyRunning: false, session, response };
|
|
12249
12650
|
}
|
|
12250
12651
|
enrichResumedSessionAsync(sessionId, projectPath, conv) {
|
|
12251
12652
|
try {
|
|
@@ -12258,18 +12659,18 @@ var StreamerServer = class {
|
|
|
12258
12659
|
session.filePath = conv.filePath ?? void 0;
|
|
12259
12660
|
}
|
|
12260
12661
|
if (!this.cache || !this.projectsRepo || !this.conversationsRepo) return;
|
|
12261
|
-
const
|
|
12262
|
-
if (
|
|
12263
|
-
session.model =
|
|
12264
|
-
session.preview =
|
|
12265
|
-
const first =
|
|
12266
|
-
const last =
|
|
12662
|
+
const cached3 = this.cache.getMetaById(sessionId);
|
|
12663
|
+
if (cached3) {
|
|
12664
|
+
session.model = cached3.model ?? void 0;
|
|
12665
|
+
session.preview = cached3.preview ?? void 0;
|
|
12666
|
+
const first = cached3.firstMessage ? JSON.parse(cached3.firstMessage) : null;
|
|
12667
|
+
const last = cached3.lastMessage ? JSON.parse(cached3.lastMessage) : null;
|
|
12267
12668
|
session.firstMessageText = first?.text ?? void 0;
|
|
12268
12669
|
session.firstMessageAt = first?.timestamp ? new Date(first.timestamp).toISOString() : void 0;
|
|
12269
12670
|
session.lastMessageText = last?.text ?? void 0;
|
|
12270
12671
|
session.lastMessageAt = last?.timestamp ? new Date(last.timestamp).toISOString() : void 0;
|
|
12271
12672
|
}
|
|
12272
|
-
let resolvedProjectId =
|
|
12673
|
+
let resolvedProjectId = cached3?.projectId ?? null;
|
|
12273
12674
|
if (!resolvedProjectId) {
|
|
12274
12675
|
const project = this.projectsRepo.upsertProjectByPath(projectPath);
|
|
12275
12676
|
resolvedProjectId = project.id;
|
|
@@ -12841,9 +13242,12 @@ var StreamerServer = class {
|
|
|
12841
13242
|
}
|
|
12842
13243
|
}
|
|
12843
13244
|
// ─── File Watcher Wiring ─────────────────────────────────────────
|
|
12844
|
-
|
|
13245
|
+
// `historyId` is the id the provider filed the history under, which for a
|
|
13246
|
+
// fresh Codex session is its rollout id rather than our placeholder. The map
|
|
13247
|
+
// stays keyed by `sessionId` — that is what broadcasts resolve against.
|
|
13248
|
+
async watchConversationFile(sessionId, historyId = sessionId) {
|
|
12845
13249
|
try {
|
|
12846
|
-
const conversation = await this.findConversationByUuid(
|
|
13250
|
+
const conversation = await this.findConversationByUuid(historyId);
|
|
12847
13251
|
if (conversation?.filePath) {
|
|
12848
13252
|
this.sessionFileMap.set(sessionId, conversation.filePath);
|
|
12849
13253
|
this.fileWatcher.watch(conversation.filePath);
|
|
@@ -13006,6 +13410,15 @@ var StreamerServer = class {
|
|
|
13006
13410
|
const codexSessionId = match.id;
|
|
13007
13411
|
cleanup();
|
|
13008
13412
|
this.sessionStore.updateManaged(sessionId, { boundConversationId: codexSessionId });
|
|
13413
|
+
try {
|
|
13414
|
+
this.managedSessionsRepo?.recordBinding(sessionId, codexSessionId);
|
|
13415
|
+
} catch (err) {
|
|
13416
|
+
this.log.warn("[registry] failed to record Codex rollout binding", {
|
|
13417
|
+
event: "registry.binding_write_failed",
|
|
13418
|
+
sessionId,
|
|
13419
|
+
err
|
|
13420
|
+
});
|
|
13421
|
+
}
|
|
13009
13422
|
this.sessionFileMap.set(sessionId, candidatePath);
|
|
13010
13423
|
this.fileWatcher.watch(candidatePath);
|
|
13011
13424
|
try {
|