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 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
- for (const meta of this.store.list()) {
86
- if (meta.status === 'exited')
87
- continue;
88
- const session = this.spawn(meta);
89
- try {
90
- await session.start(true); // reattach if alive, resume if not
91
- }
92
- catch (err) {
93
- console.error(`[kernelpm] could not recover session ${meta.id}:`, err);
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
- await session.start(false);
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) {
@@ -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
- const HEALTH_TIMEOUT_MS = 30_000;
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
- return fetch(this.baseUrl + path, init);
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
- const r = await fetch(`http://127.0.0.1:${port}/global/health`);
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
- const tmp = this.statePath + '.tmp';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kernelpm",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "kernelpm daemon — keeps opencode sessions alive on a server (via `opencode serve`) and exposes them over a local control socket.",
5
5
  "license": "MIT",
6
6
  "bin": {