kernelpm 0.1.9 → 0.1.11

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
  }
@@ -83,7 +91,7 @@ class Daemon {
83
91
  async init() {
84
92
  await this.store.init();
85
93
  const metas = this.store.list().filter((m) => m.status !== 'exited');
86
- // Kick off all session recoveries concurrently WITHOUT awaiting: the
94
+ // Kick off all session recoveries WITHOUT awaiting: the
87
95
  // control socket must open immediately so the broker can connect, and a
88
96
  // slow / stuck `opencode serve` (one that never becomes healthy) must not
89
97
  // keep the daemon from coming up — otherwise `kernelpm daemon start` polls
@@ -91,19 +99,58 @@ class Daemon {
91
99
  // time" even though the daemon is still busy reattaching. A session that
92
100
  // fails to recover is marked 'exited' so the app sees the truth instead of
93
101
  // a perpetual 'starting'.
94
- for (const meta of metas) {
95
- const session = this.spawn(meta);
96
- void session
97
- .start(true)
98
- .catch(async (err) => {
99
- console.error(`[kernelpm] could not recover session ${meta.id}:`, err);
100
- try {
101
- await this.store.upsert({ ...meta, status: 'exited', updatedAt: Date.now() });
102
- }
103
- catch {
104
- /* best-effort store may already be in a bad state */
105
- }
106
- });
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 */
107
154
  }
108
155
  }
109
156
  list() {
@@ -127,7 +174,19 @@ class Daemon {
127
174
  };
128
175
  await this.store.upsert(meta);
129
176
  const session = this.spawn(meta, model);
130
- 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
+ }
131
190
  return this.store.get(id) ?? meta;
132
191
  }
133
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;
@@ -111,6 +132,15 @@ class ServeOpencodeDriver {
111
132
  await this.ensureSession(resume);
112
133
  this.startStreaming();
113
134
  this.startPoll();
135
+ // Once the serve is up and the session is bound, the session is ready and
136
+ // waiting for its first user message. Without this transition the meta
137
+ // stays 'starting' forever, and the app — which derives its typing
138
+ // indicator from `isWorking(status)` — keeps showing "escribiendo…" on a
139
+ // brand-new session that has never received a prompt. Only transition if
140
+ // no SSE event (e.g. an early session.status=busy) has moved the status
141
+ // already, so we don't clobber a legitimately-working resumed session.
142
+ if (this.status === 'starting')
143
+ this.emitStatus('awaiting_input');
114
144
  }
115
145
  async sendMessage(text) {
116
146
  await this.prompt(text);
@@ -237,9 +267,76 @@ class ServeOpencodeDriver {
237
267
  if (this.exited) {
238
268
  this.emitStatus('exited');
239
269
  this.stopWatching();
270
+ return;
240
271
  }
272
+ this.watchdog();
241
273
  }, POLL_MS);
242
274
  }
275
+ /**
276
+ * Reconcile stuck `working` state. Two failure modes:
277
+ * 1. opencode finished the turn but we never saw the `message.updated` with
278
+ * `time.completed` (SSE event dropped, or the network blip between
279
+ * driver and serve). → re-list messages and flush what we missed.
280
+ * 2. The daemon's `Store.patch` threw (the pre-fix race) and the status
281
+ * never propagated even though `emitStatus` ran. The driver-local
282
+ * `this.status` is already correct in that case, but if for any reason
283
+ * the daemon-side meta didn't move, forcing `awaiting_input` here at
284
+ * least unblocks the user.
285
+ */
286
+ watchdog() {
287
+ if (this.status !== 'working' || this.workingSince === null)
288
+ return;
289
+ const startedAt = this.workingSince;
290
+ const elapsed = Date.now() - startedAt;
291
+ if (elapsed < WORKING_GRACE_MS)
292
+ return;
293
+ // Best-effort reconcile: ask opencode for the message list and flush any
294
+ // completed-but-unhandled assistant turn. Cheap (~1 HTTP call), and the
295
+ // SSE stream may have missed the completion event entirely.
296
+ void this.reconcile().then((flushed) => {
297
+ if (flushed)
298
+ return;
299
+ // Nothing to flush and we've been silent for too long — give up and let
300
+ // the user send the next message rather than spin forever.
301
+ if (this.status === 'working' && Date.now() - startedAt >= WORKING_HARD_TIMEOUT_MS) {
302
+ console.warn(`[kernelpm] session ${this.sessionId} stuck in working for ${Math.round(elapsed / 1000)}s, forcing awaiting_input`);
303
+ this.emitStatus('awaiting_input');
304
+ }
305
+ });
306
+ }
307
+ /**
308
+ * Re-list opencode messages and emit anything we missed. Returns true if it
309
+ * flushed a turn (caller uses that to reset the watchdog grace window).
310
+ */
311
+ async reconcile() {
312
+ if (!this.opencodeId)
313
+ return false;
314
+ let entries = [];
315
+ try {
316
+ entries = await this.listMessages();
317
+ }
318
+ catch {
319
+ return false;
320
+ }
321
+ let flushed = false;
322
+ for (const m of entries) {
323
+ const info = m.info;
324
+ if (!info || info.sessionID !== this.opencodeId)
325
+ continue;
326
+ this.messageInfo.set(info.id, info);
327
+ const parts = new Map();
328
+ for (const p of m.parts ?? []) {
329
+ parts.set(p.id, p);
330
+ this.storePart(p);
331
+ }
332
+ this.partsByMessage.set(info.id, parts);
333
+ if (info.role === 'assistant' && info.time?.completed && !this.emittedTurn.has(info.id)) {
334
+ this.flushMessage(info.id);
335
+ flushed = true;
336
+ }
337
+ }
338
+ return flushed;
339
+ }
243
340
  stopWatching() {
244
341
  if (this.abort) {
245
342
  this.abort.abort();
@@ -519,11 +616,30 @@ class ServeOpencodeDriver {
519
616
  }
520
617
  }
521
618
  http(path, init = {}) {
522
- return fetch(this.baseUrl + path, init);
619
+ // opencode 1.18+ validates the request content-type and rejects POSTs
620
+ // without an explicit `application/json` header with HTTP 415
621
+ // ("Unsupported content-type: text/plain"). Node's fetch defaults the
622
+ // header to text/plain when given a string body, so any JSON POST must
623
+ // set it explicitly. GETs (no body) are left untouched. SSE streaming
624
+ // calls pass `Accept: text/event-stream` directly and bypass this path.
625
+ const headers = { ...init.headers };
626
+ if (init.body && !headers['Content-Type'] && !headers['content-type']) {
627
+ headers['Content-Type'] = 'application/json';
628
+ }
629
+ return fetch(this.baseUrl + path, { ...init, headers });
523
630
  }
524
631
  async probeServe(port) {
525
632
  try {
526
- const r = await fetch(`http://127.0.0.1:${port}/global/health`);
633
+ // opencode's serve has a brief window where it has bound the port but
634
+ // hasn't wired up its HTTP handlers yet — a plain `fetch` to /global/health
635
+ // in that window connects, sends the request, and then *hangs forever*
636
+ // (Node's fetch has no default timeout). That single stuck probe blocks
637
+ // `waitForHealth`'s loop indefinitely and the daemon never reports
638
+ // `created` back to the broker. The explicit timeout turns the hang into
639
+ // a normal "not ready yet, retry in 250ms" and the loop converges.
640
+ const r = await fetch(`http://127.0.0.1:${port}/global/health`, {
641
+ signal: AbortSignal.timeout(2000),
642
+ });
527
643
  if (!r.ok)
528
644
  return false;
529
645
  const j = (await r.json());
@@ -570,6 +686,7 @@ class ServeOpencodeDriver {
570
686
  if (status === this.status)
571
687
  return;
572
688
  this.status = status;
689
+ this.workingSince = status === 'working' ? Date.now() : null;
573
690
  this.cb.onStatus(status);
574
691
  }
575
692
  }
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.9",
3
+ "version": "0.1.11",
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": {