@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.
- package/dist/bootstrap.d.ts +3 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +5 -0
- package/dist/bootstrap.js.map +1 -1
- package/dist/core/sessions/session-manager.d.ts +3 -0
- package/dist/core/sessions/session-manager.d.ts.map +1 -1
- package/dist/core/sessions/session-manager.js +3 -1
- package/dist/core/sessions/session-manager.js.map +1 -1
- package/dist/core/system.d.ts +2 -0
- package/dist/core/system.d.ts.map +1 -1
- package/dist/core/system.js +2 -1
- package/dist/core/system.js.map +1 -1
- package/dist/plugins/services/pid-registry.d.ts +72 -0
- package/dist/plugins/services/pid-registry.d.ts.map +1 -0
- package/dist/plugins/services/pid-registry.js +171 -0
- package/dist/plugins/services/pid-registry.js.map +1 -0
- package/dist/plugins/services/pid-registry.test.d.ts +2 -0
- package/dist/plugins/services/pid-registry.test.d.ts.map +1 -0
- package/dist/plugins/services/pid-registry.test.js +161 -0
- package/dist/plugins/services/pid-registry.test.js.map +1 -0
- package/dist/plugins/services/plugin.d.ts +3 -0
- package/dist/plugins/services/plugin.d.ts.map +1 -1
- package/dist/plugins/services/plugin.js +5 -1
- package/dist/plugins/services/plugin.js.map +1 -1
- package/dist/plugins/services/service.d.ts +4 -0
- package/dist/plugins/services/service.d.ts.map +1 -1
- package/dist/plugins/services/service.js +8 -0
- package/dist/plugins/services/service.js.map +1 -1
- package/dist/transport/http/middleware/error-handler.d.ts +1 -1
- package/package.json +2 -2
- package/src/bootstrap.ts +7 -0
- package/src/core/sessions/session-manager.ts +5 -1
- package/src/core/system.ts +4 -0
- package/src/plugins/services/pid-registry.test.ts +180 -0
- package/src/plugins/services/pid-registry.ts +191 -0
- package/src/plugins/services/plugin.ts +8 -1
- package/src/plugins/services/service.ts +12 -0
package/src/core/system.ts
CHANGED
|
@@ -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, {
|
|
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)
|