@roj-ai/sdk 0.1.33 → 0.1.35
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 +39 -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/agents/agent.d.ts +11 -0
- package/dist/core/agents/agent.d.ts.map +1 -1
- package/dist/core/agents/agent.js +19 -2
- package/dist/core/agents/agent.js.map +1 -1
- package/dist/core/preset/index.d.ts +1 -0
- package/dist/core/preset/index.d.ts.map +1 -1
- package/dist/core/preset/index.js +2 -0
- package/dist/core/preset/index.js.map +1 -1
- package/dist/core/preset/overrides.d.ts +14 -0
- package/dist/core/preset/overrides.d.ts.map +1 -0
- package/dist/core/preset/overrides.js +20 -0
- package/dist/core/preset/overrides.js.map +1 -0
- package/dist/core/sessions/session-manager.d.ts +5 -0
- package/dist/core/sessions/session-manager.d.ts.map +1 -1
- package/dist/core/sessions/session-manager.js +20 -6
- package/dist/core/sessions/session-manager.js.map +1 -1
- package/dist/core/sessions/session-overrides.integration.test.d.ts +2 -0
- package/dist/core/sessions/session-overrides.integration.test.d.ts.map +1 -0
- package/dist/core/sessions/session-overrides.integration.test.js +154 -0
- package/dist/core/sessions/session-overrides.integration.test.js.map +1 -0
- package/dist/core/sessions/session.d.ts +10 -1
- package/dist/core/sessions/session.d.ts.map +1 -1
- package/dist/core/sessions/session.js +15 -0
- package/dist/core/sessions/session.js.map +1 -1
- package/dist/core/sessions/state.d.ts +57 -0
- package/dist/core/sessions/state.d.ts.map +1 -1
- package/dist/core/sessions/state.js +48 -0
- package/dist/core/sessions/state.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/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.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 +6 -0
- package/dist/plugins/services/plugin.d.ts.map +1 -1
- package/dist/plugins/services/plugin.js +55 -15
- package/dist/plugins/services/plugin.js.map +1 -1
- package/dist/plugins/services/prompt.d.ts.map +1 -1
- package/dist/plugins/services/prompt.js +8 -0
- package/dist/plugins/services/prompt.js.map +1 -1
- package/dist/plugins/services/schema.d.ts +33 -0
- package/dist/plugins/services/schema.d.ts.map +1 -1
- package/dist/plugins/services/service.d.ts +24 -0
- package/dist/plugins/services/service.d.ts.map +1 -1
- package/dist/plugins/services/service.js +96 -1
- package/dist/plugins/services/service.js.map +1 -1
- package/dist/plugins/services/services.integration.test.js +254 -0
- package/dist/plugins/services/services.integration.test.js.map +1 -1
- package/dist/plugins/session-lifecycle/plugin.d.ts +36 -0
- package/dist/plugins/session-lifecycle/plugin.d.ts.map +1 -1
- package/dist/plugins/session-lifecycle/plugin.js +53 -1
- package/dist/plugins/session-lifecycle/plugin.js.map +1 -1
- package/dist/testing/test-harness.d.ts +12 -2
- package/dist/testing/test-harness.d.ts.map +1 -1
- package/dist/testing/test-harness.js +13 -2
- package/dist/testing/test-harness.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/agents/agent.ts +19 -2
- package/src/core/preset/index.ts +3 -0
- package/src/core/preset/overrides.ts +24 -0
- package/src/core/sessions/session-manager.ts +27 -4
- package/src/core/sessions/session-overrides.integration.test.ts +210 -0
- package/src/core/sessions/session.ts +19 -1
- package/src/core/sessions/state.ts +77 -0
- package/src/core/system.ts +4 -0
- package/src/index.ts +8 -1
- 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 +58 -15
- package/src/plugins/services/prompt.ts +8 -0
- package/src/plugins/services/schema.ts +33 -0
- package/src/plugins/services/service.ts +125 -1
- package/src/plugins/services/services.integration.test.ts +270 -0
- package/src/plugins/session-lifecycle/plugin.ts +59 -1
- package/src/testing/test-harness.ts +22 -3
|
@@ -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({
|
|
@@ -21,6 +22,14 @@ export const serviceEvents = createEventsFactory({
|
|
|
21
22
|
command: z.string().optional(),
|
|
22
23
|
pid: z.number().optional(),
|
|
23
24
|
pidStartTime: z.number().optional(),
|
|
25
|
+
/**
|
|
26
|
+
* Set on a `failed` change when the `restartPolicy` has already queued a
|
|
27
|
+
* revival, so that no consumer — agent prompt, SPA, platform — mistakes it
|
|
28
|
+
* for a terminal failure. Cleared by the next status change.
|
|
29
|
+
*/
|
|
30
|
+
restartAt: z.number().optional(),
|
|
31
|
+
restartAttempt: z.number().optional(),
|
|
32
|
+
restartMaxRetries: z.number().optional(),
|
|
24
33
|
}),
|
|
25
34
|
},
|
|
26
35
|
})
|
|
@@ -33,6 +42,8 @@ export type ServiceStatusChangedEvent = (typeof serviceEvents)['Events']['servic
|
|
|
33
42
|
export interface ServicePluginConfig {
|
|
34
43
|
services: ServiceConfig[]
|
|
35
44
|
portPool: PortPool
|
|
45
|
+
/** Durable pid record swept at agent boot. Absent for embedders without a data dir. */
|
|
46
|
+
pidRegistry?: ServicePidRegistry
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
/**
|
|
@@ -70,6 +81,10 @@ export const servicePlugin = definePlugin('services')
|
|
|
70
81
|
const updated: ServiceEntry = {
|
|
71
82
|
...existing,
|
|
72
83
|
status: event.toStatus,
|
|
84
|
+
// A queued revival lives only until the next status change.
|
|
85
|
+
restartAt: event.restartAt,
|
|
86
|
+
restartAttempt: event.restartAttempt,
|
|
87
|
+
restartMaxRetries: event.restartMaxRetries,
|
|
73
88
|
}
|
|
74
89
|
if (event.toStatus === 'starting') {
|
|
75
90
|
updated.startedAt = event.timestamp
|
|
@@ -112,17 +127,25 @@ export const servicePlugin = definePlugin('services')
|
|
|
112
127
|
let changed = false
|
|
113
128
|
const newServices = new Map(services)
|
|
114
129
|
for (const [serviceType, entry] of services) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
130
|
+
const running = entry.status === 'starting' || entry.status === 'ready'
|
|
131
|
+
// Any queued revival died with the runtime that held its timer.
|
|
132
|
+
if (!running && entry.restartAt === undefined) continue
|
|
133
|
+
|
|
134
|
+
const updated: ServiceEntry = {
|
|
135
|
+
...entry,
|
|
136
|
+
restartAt: undefined,
|
|
137
|
+
restartAttempt: undefined,
|
|
138
|
+
restartMaxRetries: undefined,
|
|
139
|
+
}
|
|
140
|
+
if (running) {
|
|
141
|
+
updated.status = 'stopped'
|
|
142
|
+
updated.port = undefined
|
|
143
|
+
updated.pid = undefined
|
|
144
|
+
updated.pidStartTime = undefined
|
|
145
|
+
updated.stoppedAt = event.timestamp
|
|
125
146
|
}
|
|
147
|
+
newServices.set(serviceType, updated)
|
|
148
|
+
changed = true
|
|
126
149
|
}
|
|
127
150
|
return changed ? newServices : services
|
|
128
151
|
}
|
|
@@ -133,7 +156,11 @@ export const servicePlugin = definePlugin('services')
|
|
|
133
156
|
},
|
|
134
157
|
})
|
|
135
158
|
.context(async (ctx, pluginConfig) => {
|
|
136
|
-
const executor = new ServiceExecutor(ctx.logger, pluginConfig.portPool, {
|
|
159
|
+
const executor = new ServiceExecutor(ctx.logger, pluginConfig.portPool, {
|
|
160
|
+
fs: ctx.platform.fs,
|
|
161
|
+
process: ctx.platform.process,
|
|
162
|
+
pidRegistry: pluginConfig.pidRegistry,
|
|
163
|
+
})
|
|
137
164
|
executor.onStatusChanged = (sessionId, serviceType, status, details) => {
|
|
138
165
|
void ctx.emitEvent(serviceEvents.create('service_status_changed', {
|
|
139
166
|
serviceType,
|
|
@@ -144,9 +171,22 @@ export const servicePlugin = definePlugin('services')
|
|
|
144
171
|
command: details.command,
|
|
145
172
|
pid: details.pid,
|
|
146
173
|
pidStartTime: details.pidStartTime,
|
|
174
|
+
restartAt: details.restartAt,
|
|
175
|
+
restartAttempt: details.restartAttempt,
|
|
176
|
+
restartMaxRetries: details.restartMaxRetries,
|
|
147
177
|
}))
|
|
148
|
-
// Broadcast service status to connected clients (DO → SPA) via WS
|
|
149
|
-
|
|
178
|
+
// Broadcast service status to connected clients (DO → SPA) via WS.
|
|
179
|
+
// restartAt rides along so a consumer can hold on to a preview URL it
|
|
180
|
+
// would otherwise tear down on `failed`.
|
|
181
|
+
ctx.notify('serviceStatus', {
|
|
182
|
+
sessionId: String(sessionId),
|
|
183
|
+
serviceType,
|
|
184
|
+
status,
|
|
185
|
+
port: details.port,
|
|
186
|
+
restartAt: details.restartAt,
|
|
187
|
+
restartAttempt: details.restartAttempt,
|
|
188
|
+
restartMaxRetries: details.restartMaxRetries,
|
|
189
|
+
})
|
|
150
190
|
}
|
|
151
191
|
return { executor }
|
|
152
192
|
})
|
|
@@ -371,7 +411,10 @@ export const servicePlugin = definePlugin('services')
|
|
|
371
411
|
.sessionHook('onSessionClose', async (ctx) => {
|
|
372
412
|
for (const svcConfig of ctx.pluginConfig.services) {
|
|
373
413
|
const status = ctx.pluginContext.executor.getStatus(svcConfig.type)
|
|
374
|
-
|
|
414
|
+
// A queued revival outlives the session otherwise: its timer holds the
|
|
415
|
+
// executor alive and spawns a process into a session that is already gone.
|
|
416
|
+
const revivalQueued = ctx.pluginContext.executor.hasScheduledRestart(svcConfig.type)
|
|
417
|
+
if (status === 'ready' || status === 'starting' || revivalQueued) {
|
|
375
418
|
await ctx.pluginContext.executor.stop(svcConfig.type, ctx.sessionId)
|
|
376
419
|
}
|
|
377
420
|
}
|
|
@@ -388,7 +431,7 @@ export const servicePlugin = definePlugin('services')
|
|
|
388
431
|
createTool({
|
|
389
432
|
name: 'service_start',
|
|
390
433
|
description:
|
|
391
|
-
`Start a stopped or failed session service. Only call this if the session context shows the service is not running — if it is already "ready", you do not need to start it. Available services: ${serviceList}`,
|
|
434
|
+
`Start a stopped or failed session service. Only call this if the session context shows the service is not running — if it is already "ready", or marked as restarting automatically, you do not need to start it. Available services: ${serviceList}`,
|
|
392
435
|
input: z.object({
|
|
393
436
|
serviceType: z.string().describe('Service type identifier'),
|
|
394
437
|
}),
|
|
@@ -42,6 +42,14 @@ export function buildServiceStatusMessage(
|
|
|
42
42
|
if (entry?.error) {
|
|
43
43
|
line += ` (error: ${entry.error})`
|
|
44
44
|
}
|
|
45
|
+
// A queued revival means the service comes back on its own — say so, or the
|
|
46
|
+
// agent burns a turn restarting by hand what was about to restart anyway.
|
|
47
|
+
if (entry?.restartAt !== undefined) {
|
|
48
|
+
const attempt = entry.restartAttempt !== undefined && entry.restartMaxRetries !== undefined
|
|
49
|
+
? ` (attempt ${entry.restartAttempt}/${entry.restartMaxRetries})`
|
|
50
|
+
: ''
|
|
51
|
+
line += ` — restarting automatically${attempt}, no action needed`
|
|
52
|
+
}
|
|
45
53
|
// Deliberately no cwd: it is a real host path, while sandboxed agents see the
|
|
46
54
|
// world through virtual roots — leaking it sends them chasing paths that
|
|
47
55
|
// don't exist in their namespace (see webmaster path-identity incident).
|
|
@@ -101,6 +101,28 @@ export interface ServiceConfig {
|
|
|
101
101
|
logBufferSize?: number
|
|
102
102
|
/** Startup timeout in ms — service marked as failed if not ready within this time (default: 30000) */
|
|
103
103
|
startupTimeoutMs?: number
|
|
104
|
+
/**
|
|
105
|
+
* Bring the service back after an unexpected exit, on the port it already
|
|
106
|
+
* holds. Omitted means what has always happened: the service is left
|
|
107
|
+
* `failed` and waits for someone to call restart.
|
|
108
|
+
*
|
|
109
|
+
* For a long-running service nobody is watching — a dev server behind a
|
|
110
|
+
* preview URL — a crash otherwise means the URL stays dead until a human
|
|
111
|
+
* or an agent notices. The retry budget is per consecutive failure and
|
|
112
|
+
* resets once the service has stayed up for `healthyAfterMs`, so a process
|
|
113
|
+
* that dies repeatedly at startup still ends up `failed` instead of
|
|
114
|
+
* looping forever.
|
|
115
|
+
*/
|
|
116
|
+
restartPolicy?: {
|
|
117
|
+
/** Consecutive automatic restarts before giving up (default: 3) */
|
|
118
|
+
maxRetries?: number
|
|
119
|
+
/** Delay before the first retry; doubles per attempt (default: 1000) */
|
|
120
|
+
initialDelayMs?: number
|
|
121
|
+
/** Ceiling for the doubling (default: 30000) */
|
|
122
|
+
maxDelayMs?: number
|
|
123
|
+
/** Uptime that counts as healthy and resets the budget (default: 60000) */
|
|
124
|
+
healthyAfterMs?: number
|
|
125
|
+
}
|
|
104
126
|
/** Auto-pause configuration (interface prepared, not yet implemented) */
|
|
105
127
|
autoPause?: { inactivityMs: number }
|
|
106
128
|
/**
|
|
@@ -133,6 +155,17 @@ export interface ServiceEntry {
|
|
|
133
155
|
startedAt?: number
|
|
134
156
|
readyAt?: number
|
|
135
157
|
stoppedAt?: number
|
|
158
|
+
/**
|
|
159
|
+
* When a `restartPolicy` revival is queued, the epoch ms at which it fires.
|
|
160
|
+
* Present only while the revival is pending — any later status change clears
|
|
161
|
+
* it — so `failed` with `restartAt` set means "down, but coming back on its
|
|
162
|
+
* own" and nobody downstream has to treat it as terminal.
|
|
163
|
+
*/
|
|
164
|
+
restartAt?: number
|
|
165
|
+
/** 1-based attempt number of the queued revival. */
|
|
166
|
+
restartAttempt?: number
|
|
167
|
+
/** Retry budget the queued revival counts against. */
|
|
168
|
+
restartMaxRetries?: number
|
|
136
169
|
/** Process group PID (tracked for orphan cleanup after restart) */
|
|
137
170
|
pid?: number
|
|
138
171
|
/**
|