@roj-ai/sdk 0.1.33 → 0.1.34

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.
Files changed (37) hide show
  1. package/dist/bootstrap.d.ts +3 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/bootstrap.js +5 -0
  4. package/dist/bootstrap.js.map +1 -1
  5. package/dist/core/sessions/session-manager.d.ts +3 -0
  6. package/dist/core/sessions/session-manager.d.ts.map +1 -1
  7. package/dist/core/sessions/session-manager.js +3 -1
  8. package/dist/core/sessions/session-manager.js.map +1 -1
  9. package/dist/core/system.d.ts +2 -0
  10. package/dist/core/system.d.ts.map +1 -1
  11. package/dist/core/system.js +2 -1
  12. package/dist/core/system.js.map +1 -1
  13. package/dist/plugins/services/pid-registry.d.ts +72 -0
  14. package/dist/plugins/services/pid-registry.d.ts.map +1 -0
  15. package/dist/plugins/services/pid-registry.js +171 -0
  16. package/dist/plugins/services/pid-registry.js.map +1 -0
  17. package/dist/plugins/services/pid-registry.test.d.ts +2 -0
  18. package/dist/plugins/services/pid-registry.test.d.ts.map +1 -0
  19. package/dist/plugins/services/pid-registry.test.js +161 -0
  20. package/dist/plugins/services/pid-registry.test.js.map +1 -0
  21. package/dist/plugins/services/plugin.d.ts +3 -0
  22. package/dist/plugins/services/plugin.d.ts.map +1 -1
  23. package/dist/plugins/services/plugin.js +5 -1
  24. package/dist/plugins/services/plugin.js.map +1 -1
  25. package/dist/plugins/services/service.d.ts +4 -0
  26. package/dist/plugins/services/service.d.ts.map +1 -1
  27. package/dist/plugins/services/service.js +8 -0
  28. package/dist/plugins/services/service.js.map +1 -1
  29. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  30. package/package.json +2 -2
  31. package/src/bootstrap.ts +7 -0
  32. package/src/core/sessions/session-manager.ts +5 -1
  33. package/src/core/system.ts +4 -0
  34. package/src/plugins/services/pid-registry.test.ts +180 -0
  35. package/src/plugins/services/pid-registry.ts +191 -0
  36. package/src/plugins/services/plugin.ts +8 -1
  37. package/src/plugins/services/service.ts +12 -0
@@ -10,6 +10,7 @@
10
10
  import type z4 from 'zod/v4'
11
11
  import type { Logger } from '~/lib/logger/logger.js'
12
12
  import type { Platform } from '~/platform/index.js'
13
+ import type { ServicePidRegistry } from '~/plugins/services/pid-registry.js'
13
14
  import type { PortPool } from '~/plugins/services/port-pool.js'
14
15
  import type { PreprocessorRegistry } from '~/plugins/uploads/preprocessor.js'
15
16
  import type { EventStore } from './events/event-store.js'
@@ -120,6 +121,7 @@ export interface CreateSystemOptions<TPlugins extends readonly PluginDefinition<
120
121
  preprocessorRegistry?: PreprocessorRegistry
121
122
  llmLogger?: LLMLogger
122
123
  portPool?: PortPool
124
+ pidRegistry?: ServicePidRegistry
123
125
  /** Host-environment adapters (filesystem, process). */
124
126
  platform: Platform
125
127
  }
@@ -145,6 +147,7 @@ export function createSystem<const TPlugins extends readonly PluginDefinition<st
145
147
  preprocessorRegistry,
146
148
  llmLogger,
147
149
  portPool,
150
+ pidRegistry,
148
151
  platform,
149
152
  } = options
150
153
 
@@ -167,6 +170,7 @@ export function createSystem<const TPlugins extends readonly PluginDefinition<st
167
170
  preprocessorRegistry,
168
171
  llmLogger,
169
172
  portPool,
173
+ pidRegistry,
170
174
  platform,
171
175
  systemPlugins: [...plugins],
172
176
  })
@@ -0,0 +1,180 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { spawn } from 'node:child_process'
3
+ import { readdir, readFile, mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { silentLogger } from '~/lib/logger/logger.js'
7
+ import { createNodePlatform } from '~/testing/node-platform.js'
8
+ import { ServicePidRegistry } from './pid-registry.js'
9
+ import { getProcessStartTime } from './service.js'
10
+
11
+ const fs = createNodePlatform().fs
12
+
13
+ async function withDataDir<T>(fn: (dataPath: string) => Promise<T>): Promise<T> {
14
+ const dataPath = await mkdtemp(join(tmpdir(), 'pid-registry-'))
15
+ try {
16
+ return await fn(dataPath)
17
+ } finally {
18
+ await rm(dataPath, { recursive: true, force: true })
19
+ }
20
+ }
21
+
22
+ /** A detached `sleep`, so `kill(-pid)` has a real process group to take down. */
23
+ function spawnVictim(): { pid: number; exited: Promise<void> } {
24
+ const child = spawn('/bin/sh', ['-c', 'sleep 60'], { detached: true, stdio: 'ignore' })
25
+ child.unref()
26
+ if (!child.pid) throw new Error('failed to spawn victim')
27
+ const exited = new Promise<void>(resolve => child.on('close', () => resolve()))
28
+ return { pid: child.pid, exited }
29
+ }
30
+
31
+ function isAlive(pid: number): boolean {
32
+ try {
33
+ process.kill(pid, 0)
34
+ return true
35
+ } catch {
36
+ return false
37
+ }
38
+ }
39
+
40
+ /** A pid the kernel cannot have handed out, so it reads as a dead agent. */
41
+ async function unusablePid(): Promise<number> {
42
+ const max = Number((await readFile('/proc/sys/kernel/pid_max', 'utf-8')).trim())
43
+ return max + 1
44
+ }
45
+
46
+ async function writeRecord(dataPath: string, record: Record<string, unknown>): Promise<string> {
47
+ const dir = join(dataPath, 'service-pids')
48
+ await mkdir(dir, { recursive: true })
49
+ const path = join(dir, `${record.sessionId}__${record.serviceType}.json`)
50
+ await writeFile(path, JSON.stringify(record))
51
+ return path
52
+ }
53
+
54
+ const makeRegistry = (dataPath: string) => new ServicePidRegistry(fs, silentLogger, dataPath)
55
+
56
+ describe('ServicePidRegistry', () => {
57
+ it('kills a service left behind by an agent that is gone, and drops the record', async () => {
58
+ await withDataDir(async dataPath => {
59
+ const victim = spawnVictim()
60
+ await writeRecord(dataPath, {
61
+ sessionId: 'sess-1',
62
+ serviceType: 'astro-dev',
63
+ pid: victim.pid,
64
+ pidStartTime: await getProcessStartTime(fs, victim.pid),
65
+ command: 'sleep 60',
66
+ agentPid: await unusablePid(),
67
+ agentStartTime: 12345,
68
+ })
69
+
70
+ await makeRegistry(dataPath).sweepOrphans()
71
+
72
+ await victim.exited
73
+ expect(isAlive(victim.pid)).toBe(false)
74
+ expect(await readdir(join(dataPath, 'service-pids'))).toEqual([])
75
+ })
76
+ })
77
+
78
+ it('leaves a service whose agent is still running', async () => {
79
+ await withDataDir(async dataPath => {
80
+ const victim = spawnVictim()
81
+ try {
82
+ // This test process stands in for a second, still-live agent.
83
+ await writeRecord(dataPath, {
84
+ sessionId: 'sess-1',
85
+ serviceType: 'astro-dev',
86
+ pid: victim.pid,
87
+ pidStartTime: await getProcessStartTime(fs, victim.pid),
88
+ command: 'sleep 60',
89
+ agentPid: process.pid,
90
+ agentStartTime: await getProcessStartTime(fs, process.pid),
91
+ })
92
+
93
+ await makeRegistry(dataPath).sweepOrphans()
94
+
95
+ expect(isAlive(victim.pid)).toBe(true)
96
+ expect(await readdir(join(dataPath, 'service-pids'))).toHaveLength(1)
97
+ } finally {
98
+ process.kill(-victim.pid, 'SIGKILL')
99
+ }
100
+ })
101
+ })
102
+
103
+ it('refuses to kill when the pid has been recycled', async () => {
104
+ await withDataDir(async dataPath => {
105
+ const victim = spawnVictim()
106
+ try {
107
+ const actual = await getProcessStartTime(fs, victim.pid)
108
+ await writeRecord(dataPath, {
109
+ sessionId: 'sess-1',
110
+ serviceType: 'astro-dev',
111
+ pid: victim.pid,
112
+ // Deliberately not the process now holding this pid.
113
+ pidStartTime: (actual ?? 0) + 999,
114
+ command: 'sleep 60',
115
+ agentPid: await unusablePid(),
116
+ })
117
+
118
+ await makeRegistry(dataPath).sweepOrphans()
119
+
120
+ expect(isAlive(victim.pid)).toBe(true)
121
+ // The record still goes — whatever holds that pid now, it is not ours to track.
122
+ expect(await readdir(join(dataPath, 'service-pids'))).toEqual([])
123
+ } finally {
124
+ process.kill(-victim.pid, 'SIGKILL')
125
+ }
126
+ })
127
+ })
128
+
129
+ it('never sweeps what this very process started', async () => {
130
+ await withDataDir(async dataPath => {
131
+ const registry = makeRegistry(dataPath)
132
+ const victim = spawnVictim()
133
+ try {
134
+ await registry.record({
135
+ sessionId: 'sess-1',
136
+ serviceType: 'astro-dev',
137
+ pid: victim.pid,
138
+ pidStartTime: await getProcessStartTime(fs, victim.pid),
139
+ command: 'sleep 60',
140
+ })
141
+
142
+ await registry.sweepOrphans()
143
+
144
+ expect(isAlive(victim.pid)).toBe(true)
145
+ } finally {
146
+ process.kill(-victim.pid, 'SIGKILL')
147
+ }
148
+ })
149
+ })
150
+
151
+ it('drops an unreadable record instead of throwing', async () => {
152
+ await withDataDir(async dataPath => {
153
+ const dir = join(dataPath, 'service-pids')
154
+ await mkdir(dir, { recursive: true })
155
+ await writeFile(join(dir, 'sess-1__astro-dev.json'), 'not json')
156
+
157
+ await makeRegistry(dataPath).sweepOrphans()
158
+
159
+ expect(await readdir(dir)).toEqual([])
160
+ })
161
+ })
162
+
163
+ it('does nothing when no service has ever been recorded', async () => {
164
+ await withDataDir(async dataPath => {
165
+ await makeRegistry(dataPath).sweepOrphans()
166
+ })
167
+ })
168
+
169
+ it('forget removes the record for a service that has exited', async () => {
170
+ await withDataDir(async dataPath => {
171
+ const registry = makeRegistry(dataPath)
172
+ await registry.record({ sessionId: 'sess-1', serviceType: 'astro-dev', pid: 1, pidStartTime: 1, command: 'x' })
173
+ expect(await readdir(join(dataPath, 'service-pids'))).toHaveLength(1)
174
+
175
+ await registry.forget('sess-1', 'astro-dev')
176
+
177
+ expect(await readdir(join(dataPath, 'service-pids'))).toEqual([])
178
+ })
179
+ })
180
+ })
@@ -0,0 +1,191 @@
1
+ /**
2
+ * ServicePidRegistry — on-disk record of the service processes this host has spawned,
3
+ * so a later agent can find and kill the ones nobody owns any more.
4
+ *
5
+ * The session projection cannot do this job. It remembers one pid per service type, so
6
+ * every start overwrites the previous generation, and its reconcile runs from
7
+ * `onSessionReady` — which fires lazily, when a session is loaded. A session nobody
8
+ * reopens after a restart therefore never reconciles at all, and its dev server keeps
9
+ * running until the box runs out of memory. That is not theoretical: a production
10
+ * sandbox on 2026-08-05 held four dev servers at ~500 MB each across three sessions on
11
+ * 4 GB with no swap, and the kernel OOM-killer fired repeatedly.
12
+ *
13
+ * A file per (session, service type) survives both an agent restart and a VM
14
+ * pause/resume, and is enumerable without knowing which sessions exist — which is what
15
+ * makes a host-wide sweep at boot possible.
16
+ */
17
+
18
+ import { join } from 'node:path'
19
+ import type { FileSystem } from '~/platform/fs.js'
20
+ import type { Logger } from '../../lib/logger/logger.js'
21
+ import { getProcessStartTime } from './service.js'
22
+
23
+ /**
24
+ * What we need in order to decide, later and from another process, whether a recorded
25
+ * pid may be killed.
26
+ *
27
+ * `pidStartTime` is /proc's start time in clock ticks: paired with the pid it identifies
28
+ * one process, so a mismatch means the kernel recycled the pid and killing would hit an
29
+ * unrelated process. `agentPid`/`agentStartTime` identify the agent that spawned it — a
30
+ * booting agent must not kill the services of another agent that is still running.
31
+ */
32
+ export interface ServicePidRecord {
33
+ sessionId: string
34
+ serviceType: string
35
+ pid: number
36
+ pidStartTime?: number
37
+ command: string
38
+ agentPid: number
39
+ agentStartTime?: number
40
+ }
41
+
42
+ const REGISTRY_DIR = 'service-pids'
43
+
44
+ export class ServicePidRegistry {
45
+ constructor(
46
+ private readonly fs: FileSystem,
47
+ private readonly logger: Logger,
48
+ /** Agent data directory. Never the session workspace — that is a git worktree. */
49
+ private readonly dataPath: string,
50
+ ) {}
51
+
52
+ private get root(): string {
53
+ return join(this.dataPath, REGISTRY_DIR)
54
+ }
55
+
56
+ private recordPath(sessionId: string, serviceType: string): string {
57
+ // Service types are plugin-configured identifiers (`astro-dev`, `cms-sidecar`) and
58
+ // session ids are uuids, so neither needs escaping to be a safe file name.
59
+ return join(this.root, `${sessionId}__${serviceType}.json`)
60
+ }
61
+
62
+ /**
63
+ * Record a freshly spawned service process. Best-effort: failing to record costs us a
64
+ * later sweep, so it must never take down the start that just succeeded.
65
+ */
66
+ async record(record: Omit<ServicePidRecord, 'agentPid' | 'agentStartTime'>): Promise<void> {
67
+ try {
68
+ await this.fs.mkdir(this.root, { recursive: true })
69
+ const full: ServicePidRecord = {
70
+ ...record,
71
+ agentPid: process.pid,
72
+ agentStartTime: await getProcessStartTime(this.fs, process.pid),
73
+ }
74
+ await this.fs.writeFile(this.recordPath(record.sessionId, record.serviceType), JSON.stringify(full))
75
+ } catch (error) {
76
+ this.logger.debug('Could not record service pid', { sessionId: record.sessionId, serviceType: record.serviceType, error })
77
+ }
78
+ }
79
+
80
+ /** Drop the record for a service whose process is gone. */
81
+ async forget(sessionId: string, serviceType: string): Promise<void> {
82
+ try {
83
+ await this.fs.unlink(this.recordPath(sessionId, serviceType))
84
+ } catch {
85
+ // Already gone, or never recorded.
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Kill every recorded service process that no live agent owns, and clear its record.
91
+ *
92
+ * Meant to run once at agent boot, before any session loads: at that moment this agent
93
+ * tracks nothing, so every survivor of a previous incarnation is by definition an
94
+ * orphan. Records left by an agent that is still running are skipped — during an
95
+ * overlapping restart the outgoing agent's services are still its own.
96
+ */
97
+ async sweepOrphans(): Promise<void> {
98
+ let files: string[]
99
+ try {
100
+ files = await this.fs.readdir(this.root)
101
+ } catch {
102
+ return // No registry yet — nothing has ever been spawned here.
103
+ }
104
+
105
+ for (const file of files) {
106
+ if (!file.endsWith('.json')) continue
107
+ const path = join(this.root, file)
108
+ const record = await this.readRecord(path)
109
+ if (!record) {
110
+ await this.fs.unlink(path).catch(() => {})
111
+ continue
112
+ }
113
+
114
+ if (await this.isAgentAlive(record)) {
115
+ this.logger.debug('Leaving service to its still-running agent', {
116
+ sessionId: record.sessionId,
117
+ serviceType: record.serviceType,
118
+ agentPid: record.agentPid,
119
+ })
120
+ continue
121
+ }
122
+
123
+ await this.killIfStillOurs(record)
124
+ await this.fs.unlink(path).catch(() => {})
125
+ }
126
+ }
127
+
128
+ private async readRecord(path: string): Promise<ServicePidRecord | null> {
129
+ try {
130
+ const parsed: unknown = JSON.parse(await this.fs.readFile(path, 'utf-8'))
131
+ if (typeof parsed !== 'object' || parsed === null) return null
132
+ const record = parsed as Partial<ServicePidRecord>
133
+ if (typeof record.pid !== 'number' || typeof record.sessionId !== 'string' || typeof record.serviceType !== 'string') {
134
+ return null
135
+ }
136
+ return {
137
+ sessionId: record.sessionId,
138
+ serviceType: record.serviceType,
139
+ pid: record.pid,
140
+ pidStartTime: record.pidStartTime,
141
+ command: typeof record.command === 'string' ? record.command : '',
142
+ agentPid: typeof record.agentPid === 'number' ? record.agentPid : 0,
143
+ agentStartTime: record.agentStartTime,
144
+ }
145
+ } catch {
146
+ return null
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Whether a live agent still owns this record. Ourselves counts: a record carrying our own
152
+ * pid describes a service this very process started, which the sweep must never touch —
153
+ * that is what makes the sweep safe to run at any time, not only before the first start.
154
+ */
155
+ private async isAgentAlive(record: ServicePidRecord): Promise<boolean> {
156
+ if (record.agentPid === process.pid) return true
157
+ if (record.agentPid === 0) return false
158
+ const currentStartTime = await getProcessStartTime(this.fs, record.agentPid)
159
+ if (currentStartTime === undefined) return false
160
+ return record.agentStartTime === undefined || currentStartTime === record.agentStartTime
161
+ }
162
+
163
+ private async killIfStillOurs(record: ServicePidRecord): Promise<void> {
164
+ const currentStartTime = await getProcessStartTime(this.fs, record.pid)
165
+ if (currentStartTime === undefined) return // Process is gone.
166
+ if (record.pidStartTime !== undefined && currentStartTime !== record.pidStartTime) {
167
+ this.logger.warn('PID reuse detected during orphan sweep — refusing to kill', {
168
+ sessionId: record.sessionId,
169
+ serviceType: record.serviceType,
170
+ pid: record.pid,
171
+ storedStartTime: record.pidStartTime,
172
+ currentStartTime,
173
+ })
174
+ return
175
+ }
176
+
177
+ try {
178
+ // Negative pid: services are spawned detached, so this takes the whole group —
179
+ // the `/bin/sh -c` wrapper and the runtime it exec'd underneath it.
180
+ process.kill(-record.pid, 'SIGKILL')
181
+ this.logger.info('Killed orphaned service process group', {
182
+ sessionId: record.sessionId,
183
+ serviceType: record.serviceType,
184
+ pid: record.pid,
185
+ command: record.command,
186
+ })
187
+ } catch (error) {
188
+ this.logger.debug('Orphaned service process already gone', { pid: record.pid, error })
189
+ }
190
+ }
191
+ }
@@ -8,6 +8,7 @@ import { Err, Ok } from '~/lib/utils/result.js'
8
8
  import type { PortPool } from './port-pool.js'
9
9
  import { buildServiceStatusMessage } from './prompt.js'
10
10
  import type { ServiceConfig, ServiceEntry } from './schema.js'
11
+ import type { ServicePidRegistry } from './pid-registry.js'
11
12
  import { getProcessStartTime, ServiceExecutor } from './service.js'
12
13
 
13
14
  export const serviceEvents = createEventsFactory({
@@ -33,6 +34,8 @@ export type ServiceStatusChangedEvent = (typeof serviceEvents)['Events']['servic
33
34
  export interface ServicePluginConfig {
34
35
  services: ServiceConfig[]
35
36
  portPool: PortPool
37
+ /** Durable pid record swept at agent boot. Absent for embedders without a data dir. */
38
+ pidRegistry?: ServicePidRegistry
36
39
  }
37
40
 
38
41
  /**
@@ -133,7 +136,11 @@ export const servicePlugin = definePlugin('services')
133
136
  },
134
137
  })
135
138
  .context(async (ctx, pluginConfig) => {
136
- const executor = new ServiceExecutor(ctx.logger, pluginConfig.portPool, { fs: ctx.platform.fs, process: ctx.platform.process })
139
+ const executor = new ServiceExecutor(ctx.logger, pluginConfig.portPool, {
140
+ fs: ctx.platform.fs,
141
+ process: ctx.platform.process,
142
+ pidRegistry: pluginConfig.pidRegistry,
143
+ })
137
144
  executor.onStatusChanged = (sessionId, serviceType, status, details) => {
138
145
  void ctx.emitEvent(serviceEvents.create('service_status_changed', {
139
146
  serviceType,
@@ -13,6 +13,7 @@ import { Err, Ok } from '~/lib/utils/result.js'
13
13
  import type { FileSystem } from '~/platform/fs.js'
14
14
  import type { ChildProcess, ProcessRunner } from '~/platform/process.js'
15
15
  import type { PortPool } from '~/plugins/services/port-pool.js'
16
+ import type { ServicePidRegistry } from '~/plugins/services/pid-registry.js'
16
17
  import type { ServiceConfig, ServiceStartArgs, ServiceStatus } from '~/plugins/services/schema.js'
17
18
  import type { ToolError } from '../../core/tools/executor.js'
18
19
  import type { Logger } from '../../lib/logger/logger.js'
@@ -89,6 +90,8 @@ const MAX_PORT_CONFLICT_RETRIES = 3
89
90
  export interface ServiceExecutorDeps {
90
91
  fs: FileSystem
91
92
  process: ProcessRunner
93
+ /** Durable pid record, swept at agent boot. Optional so embedders without a data dir still work. */
94
+ pidRegistry?: ServicePidRegistry
92
95
  }
93
96
 
94
97
  export class ServiceExecutor {
@@ -103,6 +106,7 @@ export class ServiceExecutor {
103
106
  private readonly portPool: PortPool
104
107
  private readonly fs: FileSystem
105
108
  private readonly processRunner: ProcessRunner
109
+ private readonly pidRegistry?: ServicePidRegistry
106
110
 
107
111
  /** Optional callback invoked on every service status change */
108
112
  onStatusChanged?: (
@@ -117,6 +121,7 @@ export class ServiceExecutor {
117
121
  this.portPool = portPool
118
122
  this.fs = deps.fs
119
123
  this.processRunner = deps.process
124
+ this.pidRegistry = deps.pidRegistry
120
125
  }
121
126
 
122
127
  private notifyStatusChanged(
@@ -354,6 +359,11 @@ export class ServiceExecutor {
354
359
  // "our process" from "an unrelated process that grabbed this PID after ours died"
355
360
  const pidStartTime = await getProcessStartTime(this.fs, child.pid)
356
361
 
362
+ // Durably record the process so a future agent can reap it if we die before the
363
+ // `close` handler gets to forget it. The session projection cannot serve that
364
+ // purpose — see ServicePidRegistry.
365
+ await this.pidRegistry?.record({ sessionId: String(sessionId), serviceType: config.type, pid: child.pid, pidStartTime, command })
366
+
357
367
  // Emit starting event with PID, port, resolved cwd/command, and start time.
358
368
  this.notifyStatusChanged(sessionId, config.type, 'starting', { port, pid: child.pid, pidStartTime, cwd, command })
359
369
 
@@ -512,6 +522,8 @@ export class ServiceExecutor {
512
522
  // Handle unexpected exit
513
523
  child.on('close', (code) => {
514
524
  clearReadinessTimers()
525
+ // The process is gone, so its durable record has nothing left to reap.
526
+ void this.pidRegistry?.forget(String(sessionId), config.type)
515
527
  // Flush remaining partial lines
516
528
  if (stdoutPartial) {
517
529
  processLine(stdoutPartial)