@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.
Files changed (92) hide show
  1. package/dist/bootstrap.d.ts +39 -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/agents/agent.d.ts +11 -0
  6. package/dist/core/agents/agent.d.ts.map +1 -1
  7. package/dist/core/agents/agent.js +19 -2
  8. package/dist/core/agents/agent.js.map +1 -1
  9. package/dist/core/preset/index.d.ts +1 -0
  10. package/dist/core/preset/index.d.ts.map +1 -1
  11. package/dist/core/preset/index.js +2 -0
  12. package/dist/core/preset/index.js.map +1 -1
  13. package/dist/core/preset/overrides.d.ts +14 -0
  14. package/dist/core/preset/overrides.d.ts.map +1 -0
  15. package/dist/core/preset/overrides.js +20 -0
  16. package/dist/core/preset/overrides.js.map +1 -0
  17. package/dist/core/sessions/session-manager.d.ts +5 -0
  18. package/dist/core/sessions/session-manager.d.ts.map +1 -1
  19. package/dist/core/sessions/session-manager.js +20 -6
  20. package/dist/core/sessions/session-manager.js.map +1 -1
  21. package/dist/core/sessions/session-overrides.integration.test.d.ts +2 -0
  22. package/dist/core/sessions/session-overrides.integration.test.d.ts.map +1 -0
  23. package/dist/core/sessions/session-overrides.integration.test.js +154 -0
  24. package/dist/core/sessions/session-overrides.integration.test.js.map +1 -0
  25. package/dist/core/sessions/session.d.ts +10 -1
  26. package/dist/core/sessions/session.d.ts.map +1 -1
  27. package/dist/core/sessions/session.js +15 -0
  28. package/dist/core/sessions/session.js.map +1 -1
  29. package/dist/core/sessions/state.d.ts +57 -0
  30. package/dist/core/sessions/state.d.ts.map +1 -1
  31. package/dist/core/sessions/state.js +48 -0
  32. package/dist/core/sessions/state.js.map +1 -1
  33. package/dist/core/system.d.ts +2 -0
  34. package/dist/core/system.d.ts.map +1 -1
  35. package/dist/core/system.js +2 -1
  36. package/dist/core/system.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/plugins/services/pid-registry.d.ts +72 -0
  42. package/dist/plugins/services/pid-registry.d.ts.map +1 -0
  43. package/dist/plugins/services/pid-registry.js +171 -0
  44. package/dist/plugins/services/pid-registry.js.map +1 -0
  45. package/dist/plugins/services/pid-registry.test.d.ts +2 -0
  46. package/dist/plugins/services/pid-registry.test.d.ts.map +1 -0
  47. package/dist/plugins/services/pid-registry.test.js +161 -0
  48. package/dist/plugins/services/pid-registry.test.js.map +1 -0
  49. package/dist/plugins/services/plugin.d.ts +6 -0
  50. package/dist/plugins/services/plugin.d.ts.map +1 -1
  51. package/dist/plugins/services/plugin.js +55 -15
  52. package/dist/plugins/services/plugin.js.map +1 -1
  53. package/dist/plugins/services/prompt.d.ts.map +1 -1
  54. package/dist/plugins/services/prompt.js +8 -0
  55. package/dist/plugins/services/prompt.js.map +1 -1
  56. package/dist/plugins/services/schema.d.ts +33 -0
  57. package/dist/plugins/services/schema.d.ts.map +1 -1
  58. package/dist/plugins/services/service.d.ts +24 -0
  59. package/dist/plugins/services/service.d.ts.map +1 -1
  60. package/dist/plugins/services/service.js +96 -1
  61. package/dist/plugins/services/service.js.map +1 -1
  62. package/dist/plugins/services/services.integration.test.js +254 -0
  63. package/dist/plugins/services/services.integration.test.js.map +1 -1
  64. package/dist/plugins/session-lifecycle/plugin.d.ts +36 -0
  65. package/dist/plugins/session-lifecycle/plugin.d.ts.map +1 -1
  66. package/dist/plugins/session-lifecycle/plugin.js +53 -1
  67. package/dist/plugins/session-lifecycle/plugin.js.map +1 -1
  68. package/dist/testing/test-harness.d.ts +12 -2
  69. package/dist/testing/test-harness.d.ts.map +1 -1
  70. package/dist/testing/test-harness.js +13 -2
  71. package/dist/testing/test-harness.js.map +1 -1
  72. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  73. package/package.json +2 -2
  74. package/src/bootstrap.ts +7 -0
  75. package/src/core/agents/agent.ts +19 -2
  76. package/src/core/preset/index.ts +3 -0
  77. package/src/core/preset/overrides.ts +24 -0
  78. package/src/core/sessions/session-manager.ts +27 -4
  79. package/src/core/sessions/session-overrides.integration.test.ts +210 -0
  80. package/src/core/sessions/session.ts +19 -1
  81. package/src/core/sessions/state.ts +77 -0
  82. package/src/core/system.ts +4 -0
  83. package/src/index.ts +8 -1
  84. package/src/plugins/services/pid-registry.test.ts +180 -0
  85. package/src/plugins/services/pid-registry.ts +191 -0
  86. package/src/plugins/services/plugin.ts +58 -15
  87. package/src/plugins/services/prompt.ts +8 -0
  88. package/src/plugins/services/schema.ts +33 -0
  89. package/src/plugins/services/service.ts +125 -1
  90. package/src/plugins/services/services.integration.test.ts +270 -0
  91. package/src/plugins/session-lifecycle/plugin.ts +59 -1
  92. 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
- if (entry.status === 'starting' || entry.status === 'ready') {
116
- newServices.set(serviceType, {
117
- ...entry,
118
- status: 'stopped',
119
- port: undefined,
120
- pid: undefined,
121
- pidStartTime: undefined,
122
- stoppedAt: event.timestamp,
123
- })
124
- changed = true
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, { fs: ctx.platform.fs, process: ctx.platform.process })
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
- ctx.notify('serviceStatus', { sessionId: String(sessionId), serviceType, status, port: details.port })
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
- if (status === 'ready' || status === 'starting') {
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
  /**