kernelpm 0.1.8 → 0.1.10
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/daemon.js +82 -11
- package/dist/opencodeDriver.js +111 -3
- package/dist/store.js +29 -1
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -51,6 +51,14 @@ const session_1 = require("./session");
|
|
|
51
51
|
exports.DAEMON_VERSION = '0.1.0';
|
|
52
52
|
/** Default model when a session doesn't specify one (GLM Coding Plan). */
|
|
53
53
|
const DEFAULT_MODEL = 'zai-coding-plan/glm-5.2';
|
|
54
|
+
/**
|
|
55
|
+
* Max concurrent `opencode serve` spawns during daemon startup recovery.
|
|
56
|
+
* Each serve is a node process that loads the model provider and indexes the
|
|
57
|
+
* project cwd on cold start — running too many at once tips the box over and
|
|
58
|
+
* they start timing out / exiting during startup. 2 is a safe default that
|
|
59
|
+
* still recovers multiple sessions in parallel; bump it on big hosts.
|
|
60
|
+
*/
|
|
61
|
+
const RECOVERY_CONCURRENCY = 2;
|
|
54
62
|
function controlSocketPath() {
|
|
55
63
|
return path.join((0, store_1.kernelpmHome)(), 'control.sock');
|
|
56
64
|
}
|
|
@@ -82,16 +90,67 @@ class Daemon {
|
|
|
82
90
|
}
|
|
83
91
|
async init() {
|
|
84
92
|
await this.store.init();
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
const metas = this.store.list().filter((m) => m.status !== 'exited');
|
|
94
|
+
// Kick off all session recoveries WITHOUT awaiting: the
|
|
95
|
+
// control socket must open immediately so the broker can connect, and a
|
|
96
|
+
// slow / stuck `opencode serve` (one that never becomes healthy) must not
|
|
97
|
+
// keep the daemon from coming up — otherwise `kernelpm daemon start` polls
|
|
98
|
+
// its readiness window, sees no socket, and reports "did not come up in
|
|
99
|
+
// time" even though the daemon is still busy reattaching. A session that
|
|
100
|
+
// fails to recover is marked 'exited' so the app sees the truth instead of
|
|
101
|
+
// a perpetual 'starting'.
|
|
102
|
+
//
|
|
103
|
+
// Concurrency is capped, though: spawning N `opencode serve` processes at
|
|
104
|
+
// once saturates the host (CPU/RAM/file descriptors) and turns the
|
|
105
|
+
// HEALTH_TIMEOUT_MS window into a coin flip — observed as
|
|
106
|
+
// `opencode serve exited during startup` cascading failures during a
|
|
107
|
+
// recovery with many sessions. PROCESS AT A TIME keeps each serve's cold
|
|
108
|
+
// start isolated.
|
|
109
|
+
void this.recoverAll(metas);
|
|
110
|
+
}
|
|
111
|
+
async recoverAll(metas) {
|
|
112
|
+
const queue = [...metas];
|
|
113
|
+
const workers = Array.from({ length: Math.min(RECOVERY_CONCURRENCY, queue.length) }, () => this.runRecoveryWorker(queue));
|
|
114
|
+
await Promise.all(workers);
|
|
115
|
+
}
|
|
116
|
+
async runRecoveryWorker(queue) {
|
|
117
|
+
while (queue.length) {
|
|
118
|
+
const meta = queue.shift();
|
|
119
|
+
if (!meta)
|
|
120
|
+
return;
|
|
121
|
+
await this.recoverOne(meta);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async recoverOne(meta) {
|
|
125
|
+
const session = this.spawn(meta);
|
|
126
|
+
try {
|
|
127
|
+
await session.start(true);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
console.error(`[kernelpm] could not recover session ${meta.id}:`, err);
|
|
131
|
+
await this.cleanupFailedSession(session, meta);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Tear down a Session whose `start()` threw: kill its (possibly still
|
|
136
|
+
* running) opencode serve child, drop it from the live registry, and mark
|
|
137
|
+
* the persisted meta as `exited` so the app sees the truth. Idempotent and
|
|
138
|
+
* best-effort — every step is guarded so a partial failure here never masks
|
|
139
|
+
* the original error.
|
|
140
|
+
*/
|
|
141
|
+
async cleanupFailedSession(session, meta) {
|
|
142
|
+
try {
|
|
143
|
+
await session.kill();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* driver may be half-initialized; the serve process is best-effort */
|
|
147
|
+
}
|
|
148
|
+
this.sessions.delete(meta.id);
|
|
149
|
+
try {
|
|
150
|
+
await this.store.upsert({ ...meta, status: 'exited', updatedAt: Date.now() });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* store may already be in a bad state */
|
|
95
154
|
}
|
|
96
155
|
}
|
|
97
156
|
list() {
|
|
@@ -115,7 +174,19 @@ class Daemon {
|
|
|
115
174
|
};
|
|
116
175
|
await this.store.upsert(meta);
|
|
117
176
|
const session = this.spawn(meta, model);
|
|
118
|
-
|
|
177
|
+
try {
|
|
178
|
+
await session.start(false);
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
// `start` bails on a serve that never became healthy, but the spawned
|
|
182
|
+
// `opencode serve` may still be running and the registry now holds a
|
|
183
|
+
// half-initialized Session. Without this cleanup the orphan keeps a
|
|
184
|
+
// process slot (and on a small VPS, repeated createSession failures
|
|
185
|
+
// cascade: each retry leaves another stuck serve + another 'starting'
|
|
186
|
+
// session forever).
|
|
187
|
+
await this.cleanupFailedSession(session, meta);
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
119
190
|
return this.store.get(id) ?? meta;
|
|
120
191
|
}
|
|
121
192
|
send(id, text) {
|
package/dist/opencodeDriver.js
CHANGED
|
@@ -56,8 +56,27 @@ const child_process_1 = require("child_process");
|
|
|
56
56
|
const net = __importStar(require("net"));
|
|
57
57
|
const decisions_1 = require("./decisions");
|
|
58
58
|
const POLL_MS = 1000;
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Window for `opencode serve` to respond healthy on `/global/health`. 60 s
|
|
61
|
+
* covers a cold start on a 1 GB VPS under moderate load (model provider
|
|
62
|
+
* handshake + project indexing). On a fast host it returns in 1–3 s, so the
|
|
63
|
+
* extra headroom costs nothing in the common case and avoids spurious
|
|
64
|
+
* "did not become healthy" failures when the box is busy.
|
|
65
|
+
*/
|
|
66
|
+
const HEALTH_TIMEOUT_MS = 60_000;
|
|
60
67
|
const MAX_TOOL_SUMMARY = 400;
|
|
68
|
+
/**
|
|
69
|
+
* Watchdog cadence. Once a session has been `working` for at least
|
|
70
|
+
* `WORKING_GRACE_MS`, the poll triggers a `reconcile()` (re-list opencode
|
|
71
|
+
* messages and flush any completed assistant turn we missed from the SSE
|
|
72
|
+
* stream). If `WORKING_HARD_TIMEOUT_MS` elapses with no progress at all, we
|
|
73
|
+
* force `awaiting_input` so the app doesn't sit on a permanently spinning
|
|
74
|
+
* session — the model may have finished but opencode never sent the
|
|
75
|
+
* completion event (or the daemon's `patch` silently dropped it, see the
|
|
76
|
+
* Store race fixed in store.ts).
|
|
77
|
+
*/
|
|
78
|
+
const WORKING_GRACE_MS = 15_000;
|
|
79
|
+
const WORKING_HARD_TIMEOUT_MS = 180_000;
|
|
61
80
|
/**
|
|
62
81
|
* Inline permission config: allow everything, including the two safety guards
|
|
63
82
|
* that default to "ask". Matches the old `--permission-mode bypassPermissions`.
|
|
@@ -95,6 +114,8 @@ class ServeOpencodeDriver {
|
|
|
95
114
|
emittedTurn = new Set();
|
|
96
115
|
abort = null;
|
|
97
116
|
status = 'starting';
|
|
117
|
+
/** Timestamp (ms) of the last `working` emit; null when not working. */
|
|
118
|
+
workingSince = null;
|
|
98
119
|
constructor(sessionId, cwd, title, opts, cb, initial) {
|
|
99
120
|
this.sessionId = sessionId;
|
|
100
121
|
this.cwd = cwd;
|
|
@@ -237,9 +258,76 @@ class ServeOpencodeDriver {
|
|
|
237
258
|
if (this.exited) {
|
|
238
259
|
this.emitStatus('exited');
|
|
239
260
|
this.stopWatching();
|
|
261
|
+
return;
|
|
240
262
|
}
|
|
263
|
+
this.watchdog();
|
|
241
264
|
}, POLL_MS);
|
|
242
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Reconcile stuck `working` state. Two failure modes:
|
|
268
|
+
* 1. opencode finished the turn but we never saw the `message.updated` with
|
|
269
|
+
* `time.completed` (SSE event dropped, or the network blip between
|
|
270
|
+
* driver and serve). → re-list messages and flush what we missed.
|
|
271
|
+
* 2. The daemon's `Store.patch` threw (the pre-fix race) and the status
|
|
272
|
+
* never propagated even though `emitStatus` ran. The driver-local
|
|
273
|
+
* `this.status` is already correct in that case, but if for any reason
|
|
274
|
+
* the daemon-side meta didn't move, forcing `awaiting_input` here at
|
|
275
|
+
* least unblocks the user.
|
|
276
|
+
*/
|
|
277
|
+
watchdog() {
|
|
278
|
+
if (this.status !== 'working' || this.workingSince === null)
|
|
279
|
+
return;
|
|
280
|
+
const startedAt = this.workingSince;
|
|
281
|
+
const elapsed = Date.now() - startedAt;
|
|
282
|
+
if (elapsed < WORKING_GRACE_MS)
|
|
283
|
+
return;
|
|
284
|
+
// Best-effort reconcile: ask opencode for the message list and flush any
|
|
285
|
+
// completed-but-unhandled assistant turn. Cheap (~1 HTTP call), and the
|
|
286
|
+
// SSE stream may have missed the completion event entirely.
|
|
287
|
+
void this.reconcile().then((flushed) => {
|
|
288
|
+
if (flushed)
|
|
289
|
+
return;
|
|
290
|
+
// Nothing to flush and we've been silent for too long — give up and let
|
|
291
|
+
// the user send the next message rather than spin forever.
|
|
292
|
+
if (this.status === 'working' && Date.now() - startedAt >= WORKING_HARD_TIMEOUT_MS) {
|
|
293
|
+
console.warn(`[kernelpm] session ${this.sessionId} stuck in working for ${Math.round(elapsed / 1000)}s, forcing awaiting_input`);
|
|
294
|
+
this.emitStatus('awaiting_input');
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Re-list opencode messages and emit anything we missed. Returns true if it
|
|
300
|
+
* flushed a turn (caller uses that to reset the watchdog grace window).
|
|
301
|
+
*/
|
|
302
|
+
async reconcile() {
|
|
303
|
+
if (!this.opencodeId)
|
|
304
|
+
return false;
|
|
305
|
+
let entries = [];
|
|
306
|
+
try {
|
|
307
|
+
entries = await this.listMessages();
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
let flushed = false;
|
|
313
|
+
for (const m of entries) {
|
|
314
|
+
const info = m.info;
|
|
315
|
+
if (!info || info.sessionID !== this.opencodeId)
|
|
316
|
+
continue;
|
|
317
|
+
this.messageInfo.set(info.id, info);
|
|
318
|
+
const parts = new Map();
|
|
319
|
+
for (const p of m.parts ?? []) {
|
|
320
|
+
parts.set(p.id, p);
|
|
321
|
+
this.storePart(p);
|
|
322
|
+
}
|
|
323
|
+
this.partsByMessage.set(info.id, parts);
|
|
324
|
+
if (info.role === 'assistant' && info.time?.completed && !this.emittedTurn.has(info.id)) {
|
|
325
|
+
this.flushMessage(info.id);
|
|
326
|
+
flushed = true;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return flushed;
|
|
330
|
+
}
|
|
243
331
|
stopWatching() {
|
|
244
332
|
if (this.abort) {
|
|
245
333
|
this.abort.abort();
|
|
@@ -519,11 +607,30 @@ class ServeOpencodeDriver {
|
|
|
519
607
|
}
|
|
520
608
|
}
|
|
521
609
|
http(path, init = {}) {
|
|
522
|
-
|
|
610
|
+
// opencode 1.18+ validates the request content-type and rejects POSTs
|
|
611
|
+
// without an explicit `application/json` header with HTTP 415
|
|
612
|
+
// ("Unsupported content-type: text/plain"). Node's fetch defaults the
|
|
613
|
+
// header to text/plain when given a string body, so any JSON POST must
|
|
614
|
+
// set it explicitly. GETs (no body) are left untouched. SSE streaming
|
|
615
|
+
// calls pass `Accept: text/event-stream` directly and bypass this path.
|
|
616
|
+
const headers = { ...init.headers };
|
|
617
|
+
if (init.body && !headers['Content-Type'] && !headers['content-type']) {
|
|
618
|
+
headers['Content-Type'] = 'application/json';
|
|
619
|
+
}
|
|
620
|
+
return fetch(this.baseUrl + path, { ...init, headers });
|
|
523
621
|
}
|
|
524
622
|
async probeServe(port) {
|
|
525
623
|
try {
|
|
526
|
-
|
|
624
|
+
// opencode's serve has a brief window where it has bound the port but
|
|
625
|
+
// hasn't wired up its HTTP handlers yet — a plain `fetch` to /global/health
|
|
626
|
+
// in that window connects, sends the request, and then *hangs forever*
|
|
627
|
+
// (Node's fetch has no default timeout). That single stuck probe blocks
|
|
628
|
+
// `waitForHealth`'s loop indefinitely and the daemon never reports
|
|
629
|
+
// `created` back to the broker. The explicit timeout turns the hang into
|
|
630
|
+
// a normal "not ready yet, retry in 250ms" and the loop converges.
|
|
631
|
+
const r = await fetch(`http://127.0.0.1:${port}/global/health`, {
|
|
632
|
+
signal: AbortSignal.timeout(2000),
|
|
633
|
+
});
|
|
527
634
|
if (!r.ok)
|
|
528
635
|
return false;
|
|
529
636
|
const j = (await r.json());
|
|
@@ -570,6 +677,7 @@ class ServeOpencodeDriver {
|
|
|
570
677
|
if (status === this.status)
|
|
571
678
|
return;
|
|
572
679
|
this.status = status;
|
|
680
|
+
this.workingSince = status === 'working' ? Date.now() : null;
|
|
573
681
|
this.cb.onStatus(status);
|
|
574
682
|
}
|
|
575
683
|
}
|
package/dist/store.js
CHANGED
|
@@ -55,6 +55,15 @@ class Store {
|
|
|
55
55
|
statePath = path.join(this.home, 'sessions.json');
|
|
56
56
|
meta = new Map();
|
|
57
57
|
appendChains = new Map();
|
|
58
|
+
/**
|
|
59
|
+
* Single global chain for `sessions.json` writes. Without this, two
|
|
60
|
+
* concurrent `persistMeta()` calls race on the fixed `.tmp` filename
|
|
61
|
+
* (`A.writeFile → B.writeFile → A.rename → B.rename = ENOENT`), which is
|
|
62
|
+
* the intermittent `sendMessage/createSession → error in ~40ms` seen in
|
|
63
|
+
* the broker log. `serializeAppend` only covers per-session `.jsonl`
|
|
64
|
+
* appends; this covers the shared meta file.
|
|
65
|
+
*/
|
|
66
|
+
metaChain = Promise.resolve();
|
|
58
67
|
async init() {
|
|
59
68
|
await fs.promises.mkdir(this.sessionsDir, { recursive: true });
|
|
60
69
|
try {
|
|
@@ -136,10 +145,29 @@ class Store {
|
|
|
136
145
|
return next;
|
|
137
146
|
}
|
|
138
147
|
async persistMeta() {
|
|
148
|
+
return this.serializeMeta(() => this.writeMetaFile());
|
|
149
|
+
}
|
|
150
|
+
/** The actual disk write — must run inside `serializeMeta`. */
|
|
151
|
+
async writeMetaFile() {
|
|
139
152
|
const state = { version: 1, sessions: Object.fromEntries(this.meta) };
|
|
140
|
-
|
|
153
|
+
// Unique tmp name per call: extra defense so a future code path that
|
|
154
|
+
// bypasses `metaChain` (or a concurrent Store instance during tests) can't
|
|
155
|
+
// trip on the same ENOENT rename race the fixed name caused.
|
|
156
|
+
const tmp = `${this.statePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
141
157
|
await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
142
158
|
await fs.promises.rename(tmp, this.statePath);
|
|
143
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Serialize all writes to `sessions.json`. Callers already hold the in-memory
|
|
162
|
+
* `meta` map mutation (synchronous), but the actual disk write must be
|
|
163
|
+
* queued so the tmp+rename pair runs atomically per write. Without this,
|
|
164
|
+
* concurrent calls step on each other's tmp file and one rename fails with
|
|
165
|
+
* ENOENT — surfaced to the app as a generic `error`.
|
|
166
|
+
*/
|
|
167
|
+
serializeMeta(fn) {
|
|
168
|
+
const next = this.metaChain.then(fn, fn);
|
|
169
|
+
this.metaChain = next.catch(() => undefined);
|
|
170
|
+
return next.then(() => undefined);
|
|
171
|
+
}
|
|
144
172
|
}
|
|
145
173
|
exports.Store = Store;
|
package/package.json
CHANGED