@threadbase-sh/streamer 1.41.1 → 1.42.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 +3094 -2316
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +432 -141
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +192 -6
- package/dist/index.d.ts +192 -6
- package/dist/index.js +426 -135
- 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.js
CHANGED
|
@@ -1148,7 +1148,10 @@ var CodexPtyRunner = class {
|
|
|
1148
1148
|
const projectName = options.projectName ?? basename(options.projectPath);
|
|
1149
1149
|
const proc = nodePty.spawn(
|
|
1150
1150
|
resolveCodexExe(),
|
|
1151
|
-
|
|
1151
|
+
// `sessionId` stays the runner's map key — only argv carries the
|
|
1152
|
+
// provider-side id, so a resumed Codex session keeps the placeholder id
|
|
1153
|
+
// its client already navigated to.
|
|
1154
|
+
["resume", options.resumeId ?? sessionId, "--cd", options.projectPath, "--no-alt-screen"],
|
|
1152
1155
|
{
|
|
1153
1156
|
name: "xterm-256color",
|
|
1154
1157
|
cols: PTY_COLS,
|
|
@@ -3073,7 +3076,7 @@ import {
|
|
|
3073
3076
|
existsSync as existsSync11,
|
|
3074
3077
|
watch as fsWatch,
|
|
3075
3078
|
readdirSync as readdirSync6,
|
|
3076
|
-
readFileSync as
|
|
3079
|
+
readFileSync as readFileSync9,
|
|
3077
3080
|
statSync as statSync9
|
|
3078
3081
|
} from "fs";
|
|
3079
3082
|
import { realpath as realpath2 } from "fs/promises";
|
|
@@ -4101,6 +4104,86 @@ function redactValue(value) {
|
|
|
4101
4104
|
return value;
|
|
4102
4105
|
}
|
|
4103
4106
|
|
|
4107
|
+
// src/services/sessions/resumeIdentity.ts
|
|
4108
|
+
function resumeIdForRow(row) {
|
|
4109
|
+
if (row.provider !== CODEX_CLI_PROVIDER) return row.session_id;
|
|
4110
|
+
return row.bound_conversation_id;
|
|
4111
|
+
}
|
|
4112
|
+
|
|
4113
|
+
// src/services/sessions/rehydrateSessions.ts
|
|
4114
|
+
var REHYDRATE_MAX = 25;
|
|
4115
|
+
var REHYDRATE_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
4116
|
+
var AGENT_EXIT_SOURCES = /* @__PURE__ */ new Set(["exit", "process-exit"]);
|
|
4117
|
+
function rehydrateSkipReason(row, opts) {
|
|
4118
|
+
if (resumeIdForRow(row) == null) return "codex_unbound";
|
|
4119
|
+
if (!opts.projectExists(row.project_path)) return "project_missing";
|
|
4120
|
+
if (opts.now - row.status_updated_at > REHYDRATE_WINDOW_MS) return "too_old";
|
|
4121
|
+
if (AGENT_EXIT_SOURCES.has(row.status_source) && row.failure_reason == null) {
|
|
4122
|
+
return "agent_exited";
|
|
4123
|
+
}
|
|
4124
|
+
return null;
|
|
4125
|
+
}
|
|
4126
|
+
function rowToStubSession(row) {
|
|
4127
|
+
return {
|
|
4128
|
+
id: row.session_id,
|
|
4129
|
+
provider: row.provider,
|
|
4130
|
+
projectPath: row.project_path,
|
|
4131
|
+
projectName: row.project_name,
|
|
4132
|
+
branch: row.branch,
|
|
4133
|
+
// No PTY exists for a stub, so this is the only truthful status.
|
|
4134
|
+
status: "idle",
|
|
4135
|
+
startedAt: new Date(row.started_at),
|
|
4136
|
+
completedAt: row.completed_at != null ? new Date(row.completed_at) : null,
|
|
4137
|
+
promptCount: row.prompt_count,
|
|
4138
|
+
lastOutput: "",
|
|
4139
|
+
rehydrated: true,
|
|
4140
|
+
...row.session_name != null && { sessionName: row.session_name },
|
|
4141
|
+
...row.project_id != null && { projectId: row.project_id },
|
|
4142
|
+
...row.bound_conversation_id != null && { boundConversationId: row.bound_conversation_id },
|
|
4143
|
+
...row.resumed_from_conversation_id != null && {
|
|
4144
|
+
resumedFromConversationId: row.resumed_from_conversation_id
|
|
4145
|
+
},
|
|
4146
|
+
...row.failure_reason != null && { failureReason: row.failure_reason },
|
|
4147
|
+
...row.last_activity_at != null && { lastActivityAt: new Date(row.last_activity_at) },
|
|
4148
|
+
// Only `shutdown` crosses over. It is the one registry source that is also a
|
|
4149
|
+
// wire StatusSource *and* that genuinely describes the `idle` above — the
|
|
4150
|
+
// streamer stopped this session. A crashed row still says `transition` over
|
|
4151
|
+
// a `running` status, and copying that here would attach observed-confidence
|
|
4152
|
+
// provenance to a status we derived at boot, so leave it unset instead.
|
|
4153
|
+
...row.status_source === "shutdown" && {
|
|
4154
|
+
statusSource: "shutdown",
|
|
4155
|
+
statusUpdatedAt: new Date(row.status_updated_at),
|
|
4156
|
+
// `status` above had to flatten to `idle`, which erases whether the agent
|
|
4157
|
+
// was mid-answer when we stopped it. Carried separately so a client can
|
|
4158
|
+
// say "interrupted mid-response" without a novel SessionStatus value.
|
|
4159
|
+
// Gated on the same `shutdown` source: a crashed row's `running` is a
|
|
4160
|
+
// frozen value nobody confirmed, not an observation.
|
|
4161
|
+
...(row.status === "running" || row.status === "waiting_input") && {
|
|
4162
|
+
interruptedStatus: row.status
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
4165
|
+
};
|
|
4166
|
+
}
|
|
4167
|
+
|
|
4168
|
+
// src/utils/bootToken.ts
|
|
4169
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
4170
|
+
import os from "os";
|
|
4171
|
+
var cached2;
|
|
4172
|
+
function currentBootToken() {
|
|
4173
|
+
if (cached2 === void 0) cached2 = computeBootToken();
|
|
4174
|
+
return cached2;
|
|
4175
|
+
}
|
|
4176
|
+
function computeBootToken() {
|
|
4177
|
+
if (process.platform === "linux") {
|
|
4178
|
+
try {
|
|
4179
|
+
const bootId = readFileSync5("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
4180
|
+
if (bootId) return bootId;
|
|
4181
|
+
} catch {
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
return String(Math.round((Date.now() - os.uptime() * 1e3) / 1e4));
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4104
4187
|
// src/api/routes/diagnostics.routes.ts
|
|
4105
4188
|
function providerCheck(name, resolve2) {
|
|
4106
4189
|
try {
|
|
@@ -4180,6 +4263,49 @@ var createDiagnosticsRoutes = (deps) => {
|
|
|
4180
4263
|
);
|
|
4181
4264
|
return c.json(redactValue(buildReport(checks)));
|
|
4182
4265
|
});
|
|
4266
|
+
app.get("/sessions", (c) => {
|
|
4267
|
+
const repo = deps.managedSessionsRepo();
|
|
4268
|
+
if (!repo) {
|
|
4269
|
+
return c.json(
|
|
4270
|
+
{ error: "Session registry is unavailable", code: "REGISTRY_UNAVAILABLE" },
|
|
4271
|
+
503
|
|
4272
|
+
);
|
|
4273
|
+
}
|
|
4274
|
+
const rows = repo.listAll();
|
|
4275
|
+
const verdicts = deps.sessionVerdicts();
|
|
4276
|
+
const bootToken = currentBootToken();
|
|
4277
|
+
const now = Date.now();
|
|
4278
|
+
const sessions = rows.map((row) => {
|
|
4279
|
+
const verdict = verdicts.get(row.session_id);
|
|
4280
|
+
const skip = rehydrateSkipReason(row, { now, projectExists: existsSync5 });
|
|
4281
|
+
return {
|
|
4282
|
+
sessionId: row.session_id,
|
|
4283
|
+
provider: row.provider,
|
|
4284
|
+
status: row.status,
|
|
4285
|
+
statusSource: row.status_source,
|
|
4286
|
+
statusUpdatedAt: new Date(row.status_updated_at).toISOString(),
|
|
4287
|
+
// Whether the recorded pid is probeable at all this boot, not whether
|
|
4288
|
+
// it is alive — a mismatch means the question was never asked.
|
|
4289
|
+
//
|
|
4290
|
+
// NOT named for the boot token it derives from: `redactValue`'s
|
|
4291
|
+
// SECRET_KEY_RE matches any key containing "token", so `bootTokenMatches`
|
|
4292
|
+
// was scrubbed to the string "[redacted]" and the field shipped useless.
|
|
4293
|
+
// The regex is deliberately over-broad for a payload meant to be pasted
|
|
4294
|
+
// into bug reports, so the field moved rather than the guard.
|
|
4295
|
+
recordedThisBoot: row.boot_token != null && row.boot_token === bootToken,
|
|
4296
|
+
// Absent when this boot never classified the row: a clean restart
|
|
4297
|
+
// stamps completed_at on the way out, which takes it out of the probe
|
|
4298
|
+
// set entirely. That absence is itself the answer.
|
|
4299
|
+
lifecycle: verdict?.lifecycle ?? null,
|
|
4300
|
+
lifecycleReason: verdict?.reason ?? null,
|
|
4301
|
+
rehydrated: skip == null,
|
|
4302
|
+
rehydrateSkipReason: skip,
|
|
4303
|
+
projectExists: existsSync5(row.project_path),
|
|
4304
|
+
projectPath: redactPath(row.project_path)
|
|
4305
|
+
};
|
|
4306
|
+
});
|
|
4307
|
+
return c.json(redactValue({ generatedAt: (/* @__PURE__ */ new Date()).toISOString(), sessions }));
|
|
4308
|
+
});
|
|
4183
4309
|
return app;
|
|
4184
4310
|
};
|
|
4185
4311
|
|
|
@@ -4330,7 +4456,7 @@ import { Hono as Hono11 } from "hono";
|
|
|
4330
4456
|
import { hostname as hostname2 } from "os";
|
|
4331
4457
|
|
|
4332
4458
|
// src/config/update-config.ts
|
|
4333
|
-
import { readFileSync as
|
|
4459
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
4334
4460
|
import { homedir as homedir5 } from "os";
|
|
4335
4461
|
import { join as join10 } from "path";
|
|
4336
4462
|
import { parse as parseYaml } from "yaml";
|
|
@@ -4353,7 +4479,7 @@ function loadUpdateConfig(opts = {}) {
|
|
|
4353
4479
|
const path = opts.path ?? DEFAULT_CONFIG_PATH;
|
|
4354
4480
|
let raw;
|
|
4355
4481
|
try {
|
|
4356
|
-
raw =
|
|
4482
|
+
raw = readFileSync6(path, "utf-8");
|
|
4357
4483
|
} catch (err) {
|
|
4358
4484
|
if (err.code === "ENOENT") return null;
|
|
4359
4485
|
throw err;
|
|
@@ -5185,7 +5311,7 @@ import { dirname as dirname7 } from "path";
|
|
|
5185
5311
|
import { setImmediate as yieldToEventLoop } from "timers/promises";
|
|
5186
5312
|
|
|
5187
5313
|
// src/db/sqlite-migrate.ts
|
|
5188
|
-
import { readdirSync as readdirSync2, readFileSync as
|
|
5314
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync7 } from "fs";
|
|
5189
5315
|
import { dirname as dirname6, join as join12 } from "path";
|
|
5190
5316
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5191
5317
|
function getMigrationsDir2() {
|
|
@@ -5217,7 +5343,7 @@ function runSqliteMigrations(db, migrationsDir) {
|
|
|
5217
5343
|
skipped.push(file);
|
|
5218
5344
|
continue;
|
|
5219
5345
|
}
|
|
5220
|
-
const sql =
|
|
5346
|
+
const sql = readFileSync7(join12(dir, file), "utf-8");
|
|
5221
5347
|
const tx = db.transaction(() => {
|
|
5222
5348
|
db.exec(sql);
|
|
5223
5349
|
recordApplied.run(file, (/* @__PURE__ */ new Date()).toISOString());
|
|
@@ -5247,8 +5373,8 @@ function isAgentLine(line, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
|
|
|
5247
5373
|
function isAgentFile(filePath, entrypoints = DEFAULT_AGENT_ENTRYPOINTS) {
|
|
5248
5374
|
if (entrypoints.size === 0) return false;
|
|
5249
5375
|
const key = cacheKey(filePath, entrypoints);
|
|
5250
|
-
const
|
|
5251
|
-
if (
|
|
5376
|
+
const cached3 = fileDecisionCache.get(key);
|
|
5377
|
+
if (cached3 !== void 0) return cached3;
|
|
5252
5378
|
let fd;
|
|
5253
5379
|
try {
|
|
5254
5380
|
fd = openSync2(filePath, "r");
|
|
@@ -5904,9 +6030,9 @@ var ConversationCache = class _ConversationCache {
|
|
|
5904
6030
|
classifyAgentFile(filePath, mtimeMs, fileSize) {
|
|
5905
6031
|
if (this.agentEntrypoints.size === 0) return false;
|
|
5906
6032
|
const entrypointsKey = this.agentEntrypointsKey();
|
|
5907
|
-
const
|
|
5908
|
-
if (
|
|
5909
|
-
return
|
|
6033
|
+
const cached3 = this.stmts.getFileMetadata.get(filePath);
|
|
6034
|
+
if (cached3 && cached3.mtime_ms === mtimeMs && cached3.file_size === fileSize && cached3.agent_entrypoints_key === entrypointsKey) {
|
|
6035
|
+
return cached3.is_agent === 1;
|
|
5910
6036
|
}
|
|
5911
6037
|
const isAgent = isAgentFile(filePath, this.agentEntrypoints);
|
|
5912
6038
|
this.stmts.upsertFileMetadata.run({
|
|
@@ -6611,11 +6737,17 @@ var ConversationsRepository = class {
|
|
|
6611
6737
|
};
|
|
6612
6738
|
|
|
6613
6739
|
// src/db/repositories/managed-sessions.repository.ts
|
|
6740
|
+
var PROBE_SET_MAX = 200;
|
|
6741
|
+
var DIAGNOSTICS_MAX = 200;
|
|
6742
|
+
var TERMINAL_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
6614
6743
|
var ManagedSessionsRepository = class {
|
|
6615
6744
|
upsertStmt;
|
|
6616
6745
|
updateStatusStmt;
|
|
6746
|
+
bindStmt;
|
|
6617
6747
|
getStmt;
|
|
6618
6748
|
listNonTerminalStmt;
|
|
6749
|
+
listAllStmt;
|
|
6750
|
+
pruneTerminalStmt;
|
|
6619
6751
|
listRecoverableStmt;
|
|
6620
6752
|
deleteStmt;
|
|
6621
6753
|
constructor(db) {
|
|
@@ -6625,13 +6757,13 @@ var ManagedSessionsRepository = class {
|
|
|
6625
6757
|
status, status_source, status_updated_at, started_at, completed_at,
|
|
6626
6758
|
last_activity_at, prompt_count, session_name, project_id,
|
|
6627
6759
|
bound_conversation_id, resumed_from_conversation_id, failure_reason,
|
|
6628
|
-
streamer_instance_id
|
|
6760
|
+
streamer_instance_id, boot_token
|
|
6629
6761
|
) VALUES (
|
|
6630
6762
|
@session_id, @provider, @pid, @cmdline, @project_path, @project_name, @branch,
|
|
6631
6763
|
@status, @status_source, @status_updated_at, @started_at, @completed_at,
|
|
6632
6764
|
@last_activity_at, @prompt_count, @session_name, @project_id,
|
|
6633
6765
|
@bound_conversation_id, @resumed_from_conversation_id, @failure_reason,
|
|
6634
|
-
@streamer_instance_id
|
|
6766
|
+
@streamer_instance_id, @boot_token
|
|
6635
6767
|
)
|
|
6636
6768
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
6637
6769
|
pid = excluded.pid,
|
|
@@ -6650,7 +6782,8 @@ var ManagedSessionsRepository = class {
|
|
|
6650
6782
|
bound_conversation_id = excluded.bound_conversation_id,
|
|
6651
6783
|
resumed_from_conversation_id = excluded.resumed_from_conversation_id,
|
|
6652
6784
|
failure_reason = excluded.failure_reason,
|
|
6653
|
-
streamer_instance_id = excluded.streamer_instance_id
|
|
6785
|
+
streamer_instance_id = excluded.streamer_instance_id,
|
|
6786
|
+
boot_token = excluded.boot_token
|
|
6654
6787
|
`);
|
|
6655
6788
|
this.updateStatusStmt = db.prepare(`
|
|
6656
6789
|
UPDATE managed_sessions
|
|
@@ -6664,11 +6797,27 @@ var ManagedSessionsRepository = class {
|
|
|
6664
6797
|
session_name = COALESCE(@session_name, session_name)
|
|
6665
6798
|
WHERE session_id = @session_id
|
|
6666
6799
|
`);
|
|
6800
|
+
this.bindStmt = db.prepare(`
|
|
6801
|
+
UPDATE managed_sessions
|
|
6802
|
+
SET bound_conversation_id = @bound_conversation_id
|
|
6803
|
+
WHERE session_id = @session_id
|
|
6804
|
+
`);
|
|
6667
6805
|
this.getStmt = db.prepare("SELECT * FROM managed_sessions WHERE session_id = ?");
|
|
6668
6806
|
this.listNonTerminalStmt = db.prepare(`
|
|
6669
6807
|
SELECT * FROM managed_sessions
|
|
6670
6808
|
WHERE completed_at IS NULL
|
|
6671
6809
|
ORDER BY started_at ASC
|
|
6810
|
+
LIMIT @limit
|
|
6811
|
+
`);
|
|
6812
|
+
this.listAllStmt = db.prepare(`
|
|
6813
|
+
SELECT * FROM managed_sessions
|
|
6814
|
+
ORDER BY status_updated_at DESC
|
|
6815
|
+
LIMIT @limit
|
|
6816
|
+
`);
|
|
6817
|
+
this.pruneTerminalStmt = db.prepare(`
|
|
6818
|
+
DELETE FROM managed_sessions
|
|
6819
|
+
WHERE completed_at IS NOT NULL
|
|
6820
|
+
AND completed_at < @before
|
|
6672
6821
|
`);
|
|
6673
6822
|
this.listRecoverableStmt = db.prepare(`
|
|
6674
6823
|
SELECT * FROM managed_sessions
|
|
@@ -6701,7 +6850,10 @@ var ManagedSessionsRepository = class {
|
|
|
6701
6850
|
bound_conversation_id: session.boundConversationId ?? null,
|
|
6702
6851
|
resumed_from_conversation_id: session.resumedFromConversationId ?? null,
|
|
6703
6852
|
failure_reason: session.failureReason ?? null,
|
|
6704
|
-
streamer_instance_id: streamerInstanceId
|
|
6853
|
+
streamer_instance_id: streamerInstanceId,
|
|
6854
|
+
// Recorded, never backfilled: the pid above is only probeable while this
|
|
6855
|
+
// token still matches the running machine.
|
|
6856
|
+
boot_token: currentBootToken()
|
|
6705
6857
|
});
|
|
6706
6858
|
}
|
|
6707
6859
|
/**
|
|
@@ -6722,12 +6874,48 @@ var ManagedSessionsRepository = class {
|
|
|
6722
6874
|
session_name: fields.sessionName ?? null
|
|
6723
6875
|
});
|
|
6724
6876
|
}
|
|
6877
|
+
/**
|
|
6878
|
+
* Persist the Codex rollout id discovered after spawn.
|
|
6879
|
+
*
|
|
6880
|
+
* Its own statement rather than a `recordSpawn` re-run: the binding arrives
|
|
6881
|
+
* while the session is live, and re-upserting would also rewrite `cmdline`
|
|
6882
|
+
* with an id that is *not* in a fresh Codex process's argv, turning the
|
|
6883
|
+
* reconciler's identity check into a false `orphaned`. Without this write the
|
|
6884
|
+
* binding lives only in memory and dies with the streamer — which is the
|
|
6885
|
+
* whole reason a restarted Codex session could not be resumed (G6).
|
|
6886
|
+
*/
|
|
6887
|
+
recordBinding(sessionId, boundConversationId) {
|
|
6888
|
+
this.bindStmt.run({
|
|
6889
|
+
session_id: sessionId,
|
|
6890
|
+
bound_conversation_id: boundConversationId
|
|
6891
|
+
});
|
|
6892
|
+
}
|
|
6725
6893
|
get(sessionId) {
|
|
6726
6894
|
return this.getStmt.get(sessionId) ?? null;
|
|
6727
6895
|
}
|
|
6728
|
-
/**
|
|
6729
|
-
|
|
6730
|
-
|
|
6896
|
+
/**
|
|
6897
|
+
* Rows with no recorded completion — the reconciler's probe set.
|
|
6898
|
+
*
|
|
6899
|
+
* Capped. Callers must compare the result length against the limit and say so
|
|
6900
|
+
* when it clips: a silently truncated probe set reads as "we checked
|
|
6901
|
+
* everything" when it did not.
|
|
6902
|
+
*/
|
|
6903
|
+
listNonTerminal(limit = PROBE_SET_MAX) {
|
|
6904
|
+
return this.listNonTerminalStmt.all({ limit });
|
|
6905
|
+
}
|
|
6906
|
+
/** Every row, most recently touched first, for the diagnostics surface. */
|
|
6907
|
+
listAll(limit = DIAGNOSTICS_MAX) {
|
|
6908
|
+
return this.listAllStmt.all({ limit });
|
|
6909
|
+
}
|
|
6910
|
+
/**
|
|
6911
|
+
* Delete terminal rows older than `olderThanMs`, returning how many went.
|
|
6912
|
+
*
|
|
6913
|
+
* Only rows carrying a `completed_at` are eligible, so nothing the reconciler
|
|
6914
|
+
* or rehydrator might still want is reachable from here — a row without one
|
|
6915
|
+
* is by definition unfinished business, however old it looks.
|
|
6916
|
+
*/
|
|
6917
|
+
pruneTerminal(olderThanMs = TERMINAL_RETENTION_MS) {
|
|
6918
|
+
return this.pruneTerminalStmt.run({ before: Date.now() - olderThanMs }).changes;
|
|
6731
6919
|
}
|
|
6732
6920
|
/**
|
|
6733
6921
|
* Rows a restart could bring back: still open, or closed by our own shutdown,
|
|
@@ -7074,7 +7262,7 @@ import { createHash as createHash3 } from "crypto";
|
|
|
7074
7262
|
import { existsSync as existsSync9 } from "fs";
|
|
7075
7263
|
|
|
7076
7264
|
// src/services/cache-integrity/alertStore.ts
|
|
7077
|
-
import { mkdirSync as mkdirSync4, readFileSync as
|
|
7265
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
|
|
7078
7266
|
import { homedir as homedir7 } from "os";
|
|
7079
7267
|
import { dirname as dirname8, join as join14 } from "path";
|
|
7080
7268
|
function alertStatePath() {
|
|
@@ -7083,7 +7271,7 @@ function alertStatePath() {
|
|
|
7083
7271
|
}
|
|
7084
7272
|
function loadAlertState() {
|
|
7085
7273
|
try {
|
|
7086
|
-
const parsed = JSON.parse(
|
|
7274
|
+
const parsed = JSON.parse(readFileSync8(alertStatePath(), "utf-8"));
|
|
7087
7275
|
return parsed && typeof parsed === "object" ? parsed : {};
|
|
7088
7276
|
} catch {
|
|
7089
7277
|
return {};
|
|
@@ -8696,8 +8884,14 @@ function readIdempotencyKey(body) {
|
|
|
8696
8884
|
}
|
|
8697
8885
|
|
|
8698
8886
|
// src/services/sessions/reconcileSessions.ts
|
|
8699
|
-
|
|
8887
|
+
var PRE_BOOT_REASON = "recorded before this machine boot";
|
|
8888
|
+
async function classifySession(row, probe, currentInstanceId, currentBootToken2 = null) {
|
|
8700
8889
|
const { session_id: sessionId } = row;
|
|
8890
|
+
const resumable = (reason) => resumeIdForRow(row) == null ? {
|
|
8891
|
+
sessionId,
|
|
8892
|
+
lifecycle: "failed",
|
|
8893
|
+
reason: "Codex session ended before its rollout id was known"
|
|
8894
|
+
} : { sessionId, lifecycle: "resumable", reason };
|
|
8701
8895
|
if (row.completed_at != null) {
|
|
8702
8896
|
const clean = probe.endedCleanly?.(row) ?? row.failure_reason == null;
|
|
8703
8897
|
return {
|
|
@@ -8707,18 +8901,19 @@ async function classifySession(row, probe, currentInstanceId) {
|
|
|
8707
8901
|
};
|
|
8708
8902
|
}
|
|
8709
8903
|
if (row.pid == null) {
|
|
8710
|
-
return
|
|
8904
|
+
return resumable("no pid recorded");
|
|
8905
|
+
}
|
|
8906
|
+
if (currentBootToken2 != null && row.boot_token !== currentBootToken2) {
|
|
8907
|
+
return resumable(PRE_BOOT_REASON);
|
|
8711
8908
|
}
|
|
8712
8909
|
if (!probe.isPidAlive(row.pid)) {
|
|
8713
|
-
|
|
8714
|
-
if (clean) {
|
|
8910
|
+
if (probe.endedCleanly?.(row)) {
|
|
8715
8911
|
return { sessionId, lifecycle: "completed", reason: "process gone, history ended cleanly" };
|
|
8716
8912
|
}
|
|
8717
|
-
|
|
8718
|
-
sessionId,
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
};
|
|
8913
|
+
if (row.failure_reason != null) {
|
|
8914
|
+
return { sessionId, lifecycle: "failed", reason: "process gone, failure recorded" };
|
|
8915
|
+
}
|
|
8916
|
+
return resumable("process gone, resumable from provider history");
|
|
8722
8917
|
}
|
|
8723
8918
|
const args = await probe.getProcessArgs(row.pid);
|
|
8724
8919
|
const token = row.cmdline;
|
|
@@ -8736,52 +8931,10 @@ async function classifySession(row, probe, currentInstanceId) {
|
|
|
8736
8931
|
reason: sameRun ? "owned by this run" : "survived a previous streamer run"
|
|
8737
8932
|
};
|
|
8738
8933
|
}
|
|
8739
|
-
async function reconcileSessions(rows, probe, currentInstanceId) {
|
|
8740
|
-
return Promise.all(
|
|
8741
|
-
|
|
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
|
-
};
|
|
8934
|
+
async function reconcileSessions(rows, probe, currentInstanceId, currentBootToken2 = null) {
|
|
8935
|
+
return Promise.all(
|
|
8936
|
+
rows.map((row) => classifySession(row, probe, currentInstanceId, currentBootToken2))
|
|
8937
|
+
);
|
|
8785
8938
|
}
|
|
8786
8939
|
|
|
8787
8940
|
// src/types.ts
|
|
@@ -8972,7 +9125,8 @@ function managedToResponse(s, ptyAttached) {
|
|
|
8972
9125
|
...s.resumedFromConversationId != null && {
|
|
8973
9126
|
resumedFromConversationId: s.resumedFromConversationId
|
|
8974
9127
|
},
|
|
8975
|
-
...s.boundConversationId != null && { boundConversationId: s.boundConversationId }
|
|
9128
|
+
...s.boundConversationId != null && { boundConversationId: s.boundConversationId },
|
|
9129
|
+
...s.interruptedStatus != null && { interruptedStatus: s.interruptedStatus }
|
|
8976
9130
|
};
|
|
8977
9131
|
}
|
|
8978
9132
|
function discoveredToResponse(d, conversationId) {
|
|
@@ -9517,7 +9671,7 @@ var StreamerServer = class {
|
|
|
9517
9671
|
idempotency = new IdempotencyStore();
|
|
9518
9672
|
// sessionId → lifecycle verdict from boot reconciliation. Only holds sessions
|
|
9519
9673
|
// this run did NOT spawn; live ones derive their lifecycle from ptyAttached.
|
|
9520
|
-
|
|
9674
|
+
sessionVerdicts = /* @__PURE__ */ new Map();
|
|
9521
9675
|
// Periodic sweep that releases PTYs no agent is using. Null until listen().
|
|
9522
9676
|
idleReaperTimer = null;
|
|
9523
9677
|
// Map of clientId → WS socket (populated by the "register" WS handshake)
|
|
@@ -9776,7 +9930,14 @@ var StreamerServer = class {
|
|
|
9776
9930
|
this.sessionStore.updateManaged(session.id, {
|
|
9777
9931
|
status: session.status,
|
|
9778
9932
|
completedAt: session.completedAt,
|
|
9779
|
-
...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt }
|
|
9933
|
+
...session.lastActivityAt != null && { lastActivityAt: session.lastActivityAt },
|
|
9934
|
+
// The runner derives this from the first user message, on its own
|
|
9935
|
+
// copy of the session. Without mirroring it here SessionStore never
|
|
9936
|
+
// learns it, so a fresh live session is served with no sessionName
|
|
9937
|
+
// even though the registry has one — the name only appeared after a
|
|
9938
|
+
// restart rebuilt the row as a stub. Guarded so a runner that has not
|
|
9939
|
+
// derived one yet cannot blank a name set by enrichResumedSessionAsync.
|
|
9940
|
+
...session.sessionName != null && { sessionName: session.sessionName }
|
|
9780
9941
|
});
|
|
9781
9942
|
this.managedSessionsRepo?.recordStatus(
|
|
9782
9943
|
session.id,
|
|
@@ -9883,6 +10044,8 @@ var StreamerServer = class {
|
|
|
9883
10044
|
sessionsRepo: () => this.sessionsRepo,
|
|
9884
10045
|
cacheMetadataRepo: () => this.cacheMetadataRepo,
|
|
9885
10046
|
runtimeStore: () => this.runtimeStore,
|
|
10047
|
+
managedSessionsRepo: () => this.managedSessionsRepo,
|
|
10048
|
+
sessionVerdicts: () => this.sessionVerdicts,
|
|
9886
10049
|
ptyAttachedIds: () => this.ptyAttachedIds(),
|
|
9887
10050
|
handleListSessions: (url, res) => this.handleListSessions(url, res),
|
|
9888
10051
|
handleSessionsCount: (res) => this.handleSessionsCount(res),
|
|
@@ -10080,12 +10243,12 @@ var StreamerServer = class {
|
|
|
10080
10243
|
* it.
|
|
10081
10244
|
*/
|
|
10082
10245
|
withReconciledLifecycle(sessions) {
|
|
10083
|
-
if (this.
|
|
10246
|
+
if (this.sessionVerdicts.size === 0) return sessions;
|
|
10084
10247
|
return sessions.map((s) => {
|
|
10085
10248
|
if (s.ptyAttached) return s;
|
|
10086
|
-
const verdict = this.
|
|
10249
|
+
const verdict = this.sessionVerdicts.get(s.id);
|
|
10087
10250
|
if (!verdict) return s;
|
|
10088
|
-
return { ...s, lifecycle: verdict, lifecycleSource: "reconcile" };
|
|
10251
|
+
return { ...s, lifecycle: verdict.lifecycle, lifecycleSource: "reconcile" };
|
|
10089
10252
|
});
|
|
10090
10253
|
}
|
|
10091
10254
|
addSessionSubscriber(sessionId, ws) {
|
|
@@ -10153,14 +10316,30 @@ var StreamerServer = class {
|
|
|
10153
10316
|
let verdicts = [];
|
|
10154
10317
|
try {
|
|
10155
10318
|
const rows = this.managedSessionsRepo.listNonTerminal();
|
|
10319
|
+
if (rows.length === PROBE_SET_MAX) {
|
|
10320
|
+
this.log.warn(
|
|
10321
|
+
`[reconcile] probe set hit its cap of ${PROBE_SET_MAX} \u2014 older rows skipped`,
|
|
10322
|
+
{
|
|
10323
|
+
event: "registry.probe_truncated",
|
|
10324
|
+
limit: PROBE_SET_MAX
|
|
10325
|
+
}
|
|
10326
|
+
);
|
|
10327
|
+
}
|
|
10156
10328
|
if (rows.length === 0) return [];
|
|
10157
10329
|
verdicts = await reconcileSessions(
|
|
10158
10330
|
rows,
|
|
10159
10331
|
{ isPidAlive, getProcessArgs },
|
|
10160
|
-
this.streamerInstanceId
|
|
10332
|
+
this.streamerInstanceId,
|
|
10333
|
+
currentBootToken()
|
|
10161
10334
|
);
|
|
10162
10335
|
for (const v of verdicts) {
|
|
10163
|
-
this.
|
|
10336
|
+
this.sessionVerdicts.set(v.sessionId, v);
|
|
10337
|
+
if (v.reason === PRE_BOOT_REASON) {
|
|
10338
|
+
this.log.info(`[reconcile] ${v.sessionId} predates this machine boot \u2014 pid not probed`, {
|
|
10339
|
+
event: "sessions.boot_token_mismatch",
|
|
10340
|
+
sessionId: v.sessionId
|
|
10341
|
+
});
|
|
10342
|
+
}
|
|
10164
10343
|
if (v.lifecycle === "completed" || v.lifecycle === "failed") {
|
|
10165
10344
|
this.managedSessionsRepo.recordStatus(v.sessionId, "idle", "reconcile", {
|
|
10166
10345
|
completedAt: /* @__PURE__ */ new Date()
|
|
@@ -10182,6 +10361,30 @@ var StreamerServer = class {
|
|
|
10182
10361
|
}
|
|
10183
10362
|
return verdicts;
|
|
10184
10363
|
}
|
|
10364
|
+
/**
|
|
10365
|
+
* Drop finished sessions the registry has held long enough (plan Phase 4).
|
|
10366
|
+
*
|
|
10367
|
+
* The registry is authoritative and never rebuilt from the cache, so nothing
|
|
10368
|
+
* else would ever remove a row: without this it grows for the life of the
|
|
10369
|
+
* install, and every boot pays for rows about sessions from months ago.
|
|
10370
|
+
*/
|
|
10371
|
+
pruneTerminalSessions() {
|
|
10372
|
+
if (!this.managedSessionsRepo) return;
|
|
10373
|
+
try {
|
|
10374
|
+
const pruned = this.managedSessionsRepo.pruneTerminal();
|
|
10375
|
+
if (pruned > 0) {
|
|
10376
|
+
this.log.info(`[registry] pruned ${pruned} terminal session row(s)`, {
|
|
10377
|
+
event: "registry.pruned",
|
|
10378
|
+
pruned
|
|
10379
|
+
});
|
|
10380
|
+
}
|
|
10381
|
+
} catch (err) {
|
|
10382
|
+
this.log.warn("[registry] failed to prune terminal sessions", {
|
|
10383
|
+
event: "registry.prune_failed",
|
|
10384
|
+
err
|
|
10385
|
+
});
|
|
10386
|
+
}
|
|
10387
|
+
}
|
|
10185
10388
|
/**
|
|
10186
10389
|
* Seed the session list with what previous runs left behind (persistence plan
|
|
10187
10390
|
* Phase 1, gaps G1/G2/G8).
|
|
@@ -10208,15 +10411,29 @@ var StreamerServer = class {
|
|
|
10208
10411
|
const truncated = rows.length > REHYDRATE_MAX;
|
|
10209
10412
|
const candidates = truncated ? rows.slice(0, REHYDRATE_MAX) : rows;
|
|
10210
10413
|
if (candidates.length === 0) return;
|
|
10211
|
-
const
|
|
10414
|
+
const verdictById = new Map(verdicts.map((v) => [v.sessionId, v]));
|
|
10212
10415
|
let rehydrated = 0;
|
|
10416
|
+
const skippedBy = {};
|
|
10213
10417
|
for (const row of candidates) {
|
|
10214
10418
|
if (this.sessionStore.getManaged(row.session_id)) continue;
|
|
10215
|
-
|
|
10419
|
+
const skip = rehydrateSkipReason(row, { now, projectExists: existsSync11 });
|
|
10420
|
+
if (skip) {
|
|
10421
|
+
skippedBy[skip] = (skippedBy[skip] ?? 0) + 1;
|
|
10422
|
+
this.log.info(`[rehydrate] skipped ${row.session_id}: ${skip}`, {
|
|
10423
|
+
event: "sessions.rehydrate_skipped",
|
|
10424
|
+
sessionId: row.session_id,
|
|
10425
|
+
reason: skip
|
|
10426
|
+
});
|
|
10427
|
+
continue;
|
|
10428
|
+
}
|
|
10216
10429
|
this.sessionStore.addManaged(rowToStubSession(row));
|
|
10217
|
-
this.
|
|
10430
|
+
this.sessionVerdicts.set(
|
|
10218
10431
|
row.session_id,
|
|
10219
|
-
|
|
10432
|
+
verdictById.get(row.session_id) ?? {
|
|
10433
|
+
sessionId: row.session_id,
|
|
10434
|
+
lifecycle: "resumable",
|
|
10435
|
+
reason: "recovered from the registry at boot"
|
|
10436
|
+
}
|
|
10220
10437
|
);
|
|
10221
10438
|
if (row.completed_at != null) this.selfPtyEndedAt.set(row.session_id, row.completed_at);
|
|
10222
10439
|
rehydrated++;
|
|
@@ -10225,6 +10442,7 @@ var StreamerServer = class {
|
|
|
10225
10442
|
event: "sessions.rehydrated",
|
|
10226
10443
|
rehydrated,
|
|
10227
10444
|
skipped: candidates.length - rehydrated,
|
|
10445
|
+
skippedBy,
|
|
10228
10446
|
truncated
|
|
10229
10447
|
});
|
|
10230
10448
|
} catch (err) {
|
|
@@ -10316,7 +10534,7 @@ var StreamerServer = class {
|
|
|
10316
10534
|
const now = /* @__PURE__ */ new Date();
|
|
10317
10535
|
for (const session of this.ptyManager.listSessions()) {
|
|
10318
10536
|
try {
|
|
10319
|
-
this.managedSessionsRepo.recordStatus(session.id,
|
|
10537
|
+
this.managedSessionsRepo.recordStatus(session.id, session.status, "shutdown", {
|
|
10320
10538
|
completedAt: now,
|
|
10321
10539
|
lastActivityAt: session.lastActivityAt ?? null,
|
|
10322
10540
|
promptCount: session.promptCount
|
|
@@ -10554,7 +10772,10 @@ var StreamerServer = class {
|
|
|
10554
10772
|
);
|
|
10555
10773
|
this.scannerPersistenceDisabled = true;
|
|
10556
10774
|
}
|
|
10557
|
-
void this.reconcilePreviousSessions().then((v) =>
|
|
10775
|
+
void this.reconcilePreviousSessions().then((v) => {
|
|
10776
|
+
this.rehydratePreviousSessions(v);
|
|
10777
|
+
this.pruneTerminalSessions();
|
|
10778
|
+
});
|
|
10558
10779
|
if (this.skipStartupWarmup) {
|
|
10559
10780
|
this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
|
|
10560
10781
|
event: "cache.warmup_skipped"
|
|
@@ -12122,32 +12343,85 @@ var StreamerServer = class {
|
|
|
12122
12343
|
json(res, 400, { error: "Missing sessionId" });
|
|
12123
12344
|
return;
|
|
12124
12345
|
}
|
|
12346
|
+
const outcome = await this.resumeSession({
|
|
12347
|
+
sessionId,
|
|
12348
|
+
force: body.force === true,
|
|
12349
|
+
projectName: body.projectName,
|
|
12350
|
+
branch: body.branch
|
|
12351
|
+
});
|
|
12352
|
+
if (!outcome.ok) {
|
|
12353
|
+
switch (outcome.reason) {
|
|
12354
|
+
case "history_file_missing":
|
|
12355
|
+
json(res, 404, {
|
|
12356
|
+
error: "Conversation history file is missing; it can no longer be resumed",
|
|
12357
|
+
code: "history_file_missing"
|
|
12358
|
+
});
|
|
12359
|
+
return;
|
|
12360
|
+
case "no_project_path":
|
|
12361
|
+
json(res, 400, { error: "Could not determine project path" });
|
|
12362
|
+
return;
|
|
12363
|
+
case "conversation_busy":
|
|
12364
|
+
json(res, 409, {
|
|
12365
|
+
error: "This conversation looks active in another session",
|
|
12366
|
+
code: "CONVERSATION_BUSY",
|
|
12367
|
+
detectedBy: outcome.detectedBy,
|
|
12368
|
+
lastActivityMs: outcome.lastActivityMs,
|
|
12369
|
+
likelyOwner: outcome.likelyOwner
|
|
12370
|
+
});
|
|
12371
|
+
return;
|
|
12372
|
+
}
|
|
12373
|
+
}
|
|
12374
|
+
if (outcome.alreadyRunning) {
|
|
12375
|
+
json(res, 200, outcome.response);
|
|
12376
|
+
return;
|
|
12377
|
+
}
|
|
12378
|
+
this.broadcastOrUnicastSessionList(req);
|
|
12379
|
+
json(res, 201, outcome.response ?? outcome.session);
|
|
12380
|
+
}
|
|
12381
|
+
/**
|
|
12382
|
+
* Resume a session, from an HTTP request or from the boot path.
|
|
12383
|
+
*
|
|
12384
|
+
* Extracted from `handleResume` so both callers hit the **same collision
|
|
12385
|
+
* probe** (plan Phase 7c). The probe is what stops this streamer attaching to
|
|
12386
|
+
* a conversation an external terminal already owns; a second, hand-adapted
|
|
12387
|
+
* copy of this sequence in the boot path is how two agents end up appending
|
|
12388
|
+
* to one JSONL at 4am with nobody watching.
|
|
12389
|
+
*
|
|
12390
|
+
* Returns a typed reason rather than writing a response, so the HTTP caller
|
|
12391
|
+
* maps it to a status code and the boot caller logs it.
|
|
12392
|
+
*/
|
|
12393
|
+
async resumeSession(opts) {
|
|
12394
|
+
const { sessionId } = opts;
|
|
12125
12395
|
if (this.ptyManager.hasSession(sessionId)) {
|
|
12126
|
-
const
|
|
12127
|
-
if (
|
|
12128
|
-
|
|
12129
|
-
|
|
12396
|
+
const resp = this.sessionStore.get(sessionId, this.ptyAttachedIds());
|
|
12397
|
+
if (resp) {
|
|
12398
|
+
return { ok: true, alreadyRunning: true, session: null, response: resp };
|
|
12399
|
+
}
|
|
12400
|
+
}
|
|
12401
|
+
let jsonlPath = this.findJsonlPath(sessionId);
|
|
12402
|
+
let conv = await this.findConversationByUuid(sessionId);
|
|
12403
|
+
let historyId = sessionId;
|
|
12404
|
+
let registryProvider;
|
|
12405
|
+
if (!jsonlPath && !conv) {
|
|
12406
|
+
const row = this.managedSessionsRepo?.get(sessionId) ?? null;
|
|
12407
|
+
const boundId = row ? resumeIdForRow(row) : null;
|
|
12408
|
+
if (boundId != null && boundId !== sessionId) {
|
|
12409
|
+
historyId = boundId;
|
|
12410
|
+
registryProvider = row?.provider;
|
|
12411
|
+
jsonlPath = this.findJsonlPath(boundId);
|
|
12412
|
+
conv = await this.findConversationByUuid(boundId);
|
|
12130
12413
|
}
|
|
12131
12414
|
}
|
|
12132
|
-
const jsonlPath = this.findJsonlPath(sessionId);
|
|
12133
12415
|
const jsonlCwd = jsonlPath ? await this.readCwdFromJsonl(jsonlPath) : null;
|
|
12134
|
-
const conv = await this.findConversationByUuid(sessionId);
|
|
12135
12416
|
const projectPath = jsonlCwd ?? conv?.projectPath;
|
|
12136
12417
|
if (!projectPath) {
|
|
12137
|
-
if (!conv && !jsonlPath) {
|
|
12138
|
-
|
|
12139
|
-
error: "Conversation history file is missing; it can no longer be resumed",
|
|
12140
|
-
code: "history_file_missing"
|
|
12141
|
-
});
|
|
12142
|
-
return;
|
|
12143
|
-
}
|
|
12144
|
-
json(res, 400, { error: "Could not determine project path" });
|
|
12145
|
-
return;
|
|
12418
|
+
if (!conv && !jsonlPath) return { ok: false, reason: "history_file_missing" };
|
|
12419
|
+
return { ok: false, reason: "no_project_path" };
|
|
12146
12420
|
}
|
|
12147
12421
|
let discovered = [];
|
|
12148
|
-
const
|
|
12149
|
-
if (
|
|
12150
|
-
discovered =
|
|
12422
|
+
const cached3 = this.discoveryCache;
|
|
12423
|
+
if (cached3 && Date.now() - cached3.fetchedAt < DISCOVERY_TTL_MS) {
|
|
12424
|
+
discovered = cached3.entries;
|
|
12151
12425
|
} else {
|
|
12152
12426
|
try {
|
|
12153
12427
|
discovered = await Promise.race([
|
|
@@ -12163,45 +12437,50 @@ var StreamerServer = class {
|
|
|
12163
12437
|
}
|
|
12164
12438
|
}
|
|
12165
12439
|
const busy = conversationBusy({
|
|
12166
|
-
|
|
12440
|
+
// The id another owner's argv would actually carry — for a placeholder
|
|
12441
|
+
// that is the bound rollout id, not the one the client asked for.
|
|
12442
|
+
conversationId: historyId,
|
|
12167
12443
|
projectPath,
|
|
12168
12444
|
jsonlPath,
|
|
12169
12445
|
discovered,
|
|
12170
12446
|
windowMs: resolveResumeBusyWindowMs(),
|
|
12171
12447
|
selfPtyEndedAt: this.selfPtyEndedAt.get(sessionId) ?? null
|
|
12172
12448
|
});
|
|
12173
|
-
if (busy.busy &&
|
|
12174
|
-
|
|
12175
|
-
|
|
12176
|
-
|
|
12449
|
+
if (busy.busy && opts.force !== true) {
|
|
12450
|
+
return {
|
|
12451
|
+
ok: false,
|
|
12452
|
+
reason: "conversation_busy",
|
|
12177
12453
|
detectedBy: busy.detectedBy,
|
|
12178
12454
|
lastActivityMs: busy.lastActivityMs,
|
|
12179
12455
|
likelyOwner: busy.likelyOwner
|
|
12180
|
-
}
|
|
12181
|
-
return;
|
|
12456
|
+
};
|
|
12182
12457
|
}
|
|
12183
12458
|
if (busy.busy) {
|
|
12184
12459
|
this.contendedSessions.add(sessionId);
|
|
12185
12460
|
}
|
|
12186
|
-
const cachedConvMeta = this.cache?.getMetaById(
|
|
12187
|
-
const provider = coerceProviderForRunner(
|
|
12461
|
+
const cachedConvMeta = this.cache?.getMetaById(historyId);
|
|
12462
|
+
const provider = coerceProviderForRunner(
|
|
12463
|
+
conv?.provider ?? cachedConvMeta?.provider ?? registryProvider
|
|
12464
|
+
);
|
|
12188
12465
|
this.discoveryCache = null;
|
|
12189
12466
|
const session = await this.ptyManager.start(sessionId, {
|
|
12190
12467
|
provider,
|
|
12191
12468
|
projectPath,
|
|
12192
|
-
projectName:
|
|
12193
|
-
branch:
|
|
12469
|
+
projectName: opts.projectName,
|
|
12470
|
+
branch: opts.branch,
|
|
12471
|
+
// Omitted on every ordinary resume, so argv is unchanged there.
|
|
12472
|
+
...historyId !== sessionId && { resumeId: historyId },
|
|
12194
12473
|
claudeFlags: this.claudeFlags,
|
|
12195
12474
|
claudeExtraArgs: this.claudeExtraArgs,
|
|
12196
12475
|
...this.spawnFlagOverrides()
|
|
12197
12476
|
});
|
|
12477
|
+
if (historyId !== sessionId) session.boundConversationId = historyId;
|
|
12198
12478
|
this.sessionStore.addManaged(session);
|
|
12199
12479
|
this.recordSessionSpawn(session);
|
|
12200
|
-
void this.watchConversationFile(sessionId);
|
|
12201
|
-
const
|
|
12202
|
-
this.broadcastOrUnicastSessionList(req);
|
|
12203
|
-
json(res, 201, resp ?? session);
|
|
12480
|
+
void this.watchConversationFile(sessionId, historyId);
|
|
12481
|
+
const response = this.sessionStore.get(session.id, this.ptyAttachedIds());
|
|
12204
12482
|
this.enrichResumedSessionAsync(sessionId, projectPath, conv);
|
|
12483
|
+
return { ok: true, alreadyRunning: false, session, response };
|
|
12205
12484
|
}
|
|
12206
12485
|
enrichResumedSessionAsync(sessionId, projectPath, conv) {
|
|
12207
12486
|
try {
|
|
@@ -12214,18 +12493,18 @@ var StreamerServer = class {
|
|
|
12214
12493
|
session.filePath = conv.filePath ?? void 0;
|
|
12215
12494
|
}
|
|
12216
12495
|
if (!this.cache || !this.projectsRepo || !this.conversationsRepo) return;
|
|
12217
|
-
const
|
|
12218
|
-
if (
|
|
12219
|
-
session.model =
|
|
12220
|
-
session.preview =
|
|
12221
|
-
const first =
|
|
12222
|
-
const last =
|
|
12496
|
+
const cached3 = this.cache.getMetaById(sessionId);
|
|
12497
|
+
if (cached3) {
|
|
12498
|
+
session.model = cached3.model ?? void 0;
|
|
12499
|
+
session.preview = cached3.preview ?? void 0;
|
|
12500
|
+
const first = cached3.firstMessage ? JSON.parse(cached3.firstMessage) : null;
|
|
12501
|
+
const last = cached3.lastMessage ? JSON.parse(cached3.lastMessage) : null;
|
|
12223
12502
|
session.firstMessageText = first?.text ?? void 0;
|
|
12224
12503
|
session.firstMessageAt = first?.timestamp ? new Date(first.timestamp).toISOString() : void 0;
|
|
12225
12504
|
session.lastMessageText = last?.text ?? void 0;
|
|
12226
12505
|
session.lastMessageAt = last?.timestamp ? new Date(last.timestamp).toISOString() : void 0;
|
|
12227
12506
|
}
|
|
12228
|
-
let resolvedProjectId =
|
|
12507
|
+
let resolvedProjectId = cached3?.projectId ?? null;
|
|
12229
12508
|
if (!resolvedProjectId) {
|
|
12230
12509
|
const project = this.projectsRepo.upsertProjectByPath(projectPath);
|
|
12231
12510
|
resolvedProjectId = project.id;
|
|
@@ -12797,9 +13076,12 @@ var StreamerServer = class {
|
|
|
12797
13076
|
}
|
|
12798
13077
|
}
|
|
12799
13078
|
// ─── File Watcher Wiring ─────────────────────────────────────────
|
|
12800
|
-
|
|
13079
|
+
// `historyId` is the id the provider filed the history under, which for a
|
|
13080
|
+
// fresh Codex session is its rollout id rather than our placeholder. The map
|
|
13081
|
+
// stays keyed by `sessionId` — that is what broadcasts resolve against.
|
|
13082
|
+
async watchConversationFile(sessionId, historyId = sessionId) {
|
|
12801
13083
|
try {
|
|
12802
|
-
const conversation = await this.findConversationByUuid(
|
|
13084
|
+
const conversation = await this.findConversationByUuid(historyId);
|
|
12803
13085
|
if (conversation?.filePath) {
|
|
12804
13086
|
this.sessionFileMap.set(sessionId, conversation.filePath);
|
|
12805
13087
|
this.fileWatcher.watch(conversation.filePath);
|
|
@@ -12813,7 +13095,7 @@ var StreamerServer = class {
|
|
|
12813
13095
|
// file isn't slurped in full.
|
|
12814
13096
|
readFirstLineSessionId(filePath) {
|
|
12815
13097
|
try {
|
|
12816
|
-
const content =
|
|
13098
|
+
const content = readFileSync9(filePath, "utf8");
|
|
12817
13099
|
const nl = content.indexOf("\n");
|
|
12818
13100
|
const firstLine = nl === -1 ? content : content.slice(0, nl);
|
|
12819
13101
|
if (!firstLine.trim()) return null;
|
|
@@ -12863,7 +13145,7 @@ var StreamerServer = class {
|
|
|
12863
13145
|
cleanup();
|
|
12864
13146
|
this.sessionFileMap.set(sessionId, resolvedFilePath);
|
|
12865
13147
|
try {
|
|
12866
|
-
const existing =
|
|
13148
|
+
const existing = readFileSync9(resolvedFilePath, "utf8").split("\n").filter(Boolean);
|
|
12867
13149
|
if (existing.length > 0) {
|
|
12868
13150
|
this.broadcastConversationLines(sessionId, existing);
|
|
12869
13151
|
}
|
|
@@ -12916,7 +13198,7 @@ var StreamerServer = class {
|
|
|
12916
13198
|
};
|
|
12917
13199
|
const matchesProjectPath = (candidatePath) => {
|
|
12918
13200
|
try {
|
|
12919
|
-
const firstLine =
|
|
13201
|
+
const firstLine = readFileSync9(candidatePath, "utf8").split("\n", 1)[0];
|
|
12920
13202
|
if (!firstLine) return null;
|
|
12921
13203
|
const parsed = JSON.parse(firstLine);
|
|
12922
13204
|
if (parsed?.type !== "session_meta") return null;
|
|
@@ -12962,10 +13244,19 @@ var StreamerServer = class {
|
|
|
12962
13244
|
const codexSessionId = match.id;
|
|
12963
13245
|
cleanup();
|
|
12964
13246
|
this.sessionStore.updateManaged(sessionId, { boundConversationId: codexSessionId });
|
|
13247
|
+
try {
|
|
13248
|
+
this.managedSessionsRepo?.recordBinding(sessionId, codexSessionId);
|
|
13249
|
+
} catch (err) {
|
|
13250
|
+
this.log.warn("[registry] failed to record Codex rollout binding", {
|
|
13251
|
+
event: "registry.binding_write_failed",
|
|
13252
|
+
sessionId,
|
|
13253
|
+
err
|
|
13254
|
+
});
|
|
13255
|
+
}
|
|
12965
13256
|
this.sessionFileMap.set(sessionId, candidatePath);
|
|
12966
13257
|
this.fileWatcher.watch(candidatePath);
|
|
12967
13258
|
try {
|
|
12968
|
-
const existing =
|
|
13259
|
+
const existing = readFileSync9(candidatePath, "utf8").split("\n").filter(Boolean);
|
|
12969
13260
|
if (existing.length > 0) {
|
|
12970
13261
|
this.broadcastConversationLines(sessionId, existing);
|
|
12971
13262
|
}
|