@netnodeag/kraftwerk 0.15.0 → 0.16.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.
@@ -1,4 +1,6 @@
1
1
  import type { ChatAgentId, ChatMeta, ChatScope, StoredChatEvent } from "./types.js";
2
+ /** Server shutdown: no agent subprocess may outlive the inspector. */
3
+ export declare function disposeAllBackends(): void;
2
4
  export declare function createChat(opts: {
3
5
  agent: ChatAgentId;
4
6
  scope: ChatScope;
@@ -10,6 +10,32 @@ import { startAcpBackend } from "./acp.js";
10
10
  import { startPiBackend } from "./pi.js";
11
11
  import { appendEvent, listChatMetas, newChatId, readEvents, readMeta, safeChatDir, writeMeta, } from "./store.js";
12
12
  const states = new Map();
13
+ /** Kill a chat's agent subprocess; the next message spawns a fresh one. */
14
+ function dropBackend(state) {
15
+ state.backend?.dispose();
16
+ state.backend = null;
17
+ }
18
+ // Idle reaper: a finished chat must not keep its agent subprocess alive
19
+ // forever (one ACP adapter + one harness binary per chat adds up fast —
20
+ // hourly routines used to leak a process pair per tick). History is on
21
+ // disk and ensureBackend re-sends scope context, so reaping just costs
22
+ // the next message one respawn. busy=true covers the whole turn including
23
+ // pending permission prompts, so nothing is killed mid-work.
24
+ const IDLE_BACKEND_MS = 15 * 60_000;
25
+ const reaper = setInterval(() => {
26
+ const cutoff = Date.now() - IDLE_BACKEND_MS;
27
+ for (const state of states.values()) {
28
+ if (state.backend && !state.busy && Date.parse(state.meta.updatedAt) < cutoff) {
29
+ dropBackend(state);
30
+ }
31
+ }
32
+ }, 60_000);
33
+ reaper.unref?.();
34
+ /** Server shutdown: no agent subprocess may outlive the inspector. */
35
+ export function disposeAllBackends() {
36
+ for (const state of states.values())
37
+ dropBackend(state);
38
+ }
13
39
  async function loadState(id) {
14
40
  const existing = states.get(id);
15
41
  if (existing)
@@ -387,13 +413,17 @@ export async function postMessage(id, text) {
387
413
  const promptText = context ? `<context>\n${context}\n</context>\n\n${body}` : body;
388
414
  const stopReason = await backend.prompt(promptText);
389
415
  emit(state, { type: "turn_end", stopReason });
416
+ // Routine sessions are one-shot and unattended: release the agent
417
+ // process as soon as the turn ends instead of waiting for the reaper.
418
+ const scope = state.meta.scope;
419
+ if (scope.kind === "team" && scope.routine)
420
+ dropBackend(state);
390
421
  }
391
422
  catch (err) {
392
423
  emit(state, { type: "error", message: err.message });
393
424
  // A failed turn may mean a dead subprocess; drop it so the next
394
425
  // message spawns a fresh agent (thread history stays on disk).
395
- state.backend?.dispose();
396
- state.backend = null;
426
+ dropBackend(state);
397
427
  }
398
428
  finally {
399
429
  state.busy = false;
@@ -419,7 +449,7 @@ export async function deleteChat(id) {
419
449
  return { error: "not found" };
420
450
  for (const resolve of state.pendingPermissions.values())
421
451
  resolve(null);
422
- state.backend?.dispose();
452
+ dropBackend(state);
423
453
  states.delete(id);
424
454
  await fs.rm(safeChatDir(id), { recursive: true, force: true });
425
455
  return {};
@@ -6,7 +6,7 @@ import { resolveProject } from "../config.js";
6
6
  import { listRuns, getRun, readRunFile } from "./runs.js";
7
7
  import { listWorkflows, getWorkflow } from "./workflows.js";
8
8
  import { dockerStatus, triggerRun, stopRun } from "./runner.js";
9
- import { cancelChat, createChat, deleteChat, getChat, listChats, postMessage, resolvePermission, subscribeChat, } from "./chat/sessions.js";
9
+ import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, subscribeChat, } from "./chat/sessions.js";
10
10
  import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
11
11
  import { deleteMember, getMember, listMembers, saveMember, teamRoot, } from "./team.js";
12
12
  import { getSkill, listSkills, skillsRoot } from "./skills.js";
@@ -488,6 +488,15 @@ export function startInspector(opts) {
488
488
  if (opts.projectRoot)
489
489
  setProjectRoot(opts.projectRoot);
490
490
  startRoutineScheduler();
491
+ // Chat agent subprocesses must die with the server — signals bypass
492
+ // "exit" handlers, so hook the signals themselves.
493
+ for (const sig of ["SIGINT", "SIGTERM"]) {
494
+ process.once(sig, () => {
495
+ disposeAllBackends();
496
+ process.exit(sig === "SIGINT" ? 130 : 143);
497
+ });
498
+ }
499
+ process.once("exit", disposeAllBackends);
491
500
  const server = http.createServer(async (req, res) => {
492
501
  try {
493
502
  const url = new URL(req.url ?? "/", "http://localhost");