Skip to content

Commit 992d12d

Browse files
author
MoltGus
committed
feat(heartbeat): add local process watchdog telemetry and auto-remediation
1 parent d7ea515 commit 992d12d

8 files changed

Lines changed: 498 additions & 12 deletions

File tree

‎doc/DEVELOPING.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,25 @@ Expected:
261261
- `/api/health` returns `{"status":"ok"}`
262262
- `/api/companies` returns a JSON array
263263

264+
## Local Process Watchdog
265+
266+
Heartbeat recovery includes a local-process watchdog for detached child processes (`claude_local`, `codex_local`, `cursor`, `gemini_local`, `opencode_local`, `pi_local`).
267+
268+
Company snapshot endpoint:
269+
270+
```sh
271+
curl "http://localhost:3100/api/companies/<company-id>/process-watchdog"
272+
```
273+
274+
Main env knobs:
275+
276+
- `HEARTBEAT_PROCESS_WATCHDOG_ENABLED=true|false`
277+
- `HEARTBEAT_PROCESS_WATCHDOG_MAX_LOCAL_PROCESSES=<count>` (default `48`)
278+
- `HEARTBEAT_PROCESS_WATCHDOG_MAX_MEMORY_USAGE_PERCENT=<1-99>` (default `85`)
279+
- `HEARTBEAT_PROCESS_WATCHDOG_DETACHED_IDLE_TIMEOUT_MS=<ms>` (default `21600000` / 6h)
280+
- `HEARTBEAT_PROCESS_WATCHDOG_DETACHED_IDLE_TIMEOUT_UNDER_PRESSURE_MS=<ms>` (default `1200000` / 20m)
281+
- `HEARTBEAT_PROCESS_WATCHDOG_KILL_GRACE_MS=<ms>` (default `1500`)
282+
264283
## Reset Local Dev Database
265284

266285
To wipe local dev data and start fresh:

‎doc/PRODUCT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ ZeroInc’s core identity is a **control plane for autonomous AI companies**, ce
140140
The mental model should not change between local solo use and shared/private or public/cloud deployment.
141141

142142
7. **Safe autonomy**
143-
Auto mode is allowed; hidden token burn is not.
143+
Auto mode is allowed; hidden token burn is not. Local runtime process pressure must be guarded (watchdog + telemetry + auto-remediation for detached stale runs).
144144

145145
8. **Thin core, rich edges**
146146
Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane.

‎doc/SPEC-implementation.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ A lightweight scheduler/worker in the server process handles:
105105

106106
- heartbeat trigger checks
107107
- stuck run detection
108+
- local-process lifecycle watchdog (detached/orphan detection + safe auto-remediation under pressure)
108109
- budget threshold checks
109110

110111
Separate queue infrastructure is not required for V1.
@@ -587,6 +588,7 @@ Behavior:
587588
- stream stdout/stderr to run logs
588589
- mark run status on exit code/timeout
589590
- cancel sends SIGTERM then SIGKILL after grace
591+
- persist process metadata (`process_pid`, `process_started_at`) for watchdog telemetry/recovery
590592

591593
## 11.3 HTTP Adapter
592594

@@ -656,6 +658,20 @@ Scheduler must skip invocation when:
656658
- an existing run is active
657659
- hard budget limit has been hit
658660

661+
## 11.7 Local Process Watchdog
662+
663+
For local child-process adapters (`claude_local`, `codex_local`, `cursor`, `gemini_local`, `opencode_local`, `pi_local`):
664+
665+
- runtime telemetry must include `run_id -> process_pid -> process_started_at -> last_heartbeat_at`
666+
- detached runs (`process_detached`) are monitored with idle thresholds
667+
- when host pressure crosses configured limits (local process count and/or memory usage), stale detached runs are terminated automatically
668+
- max-duration runs are force-failed and their child PID is explicitly signaled (SIGTERM, then SIGKILL)
669+
670+
Operator API surfaces:
671+
672+
- `GET /api/companies/:companyId/live-runs` includes `processPid`, `processStartedAt`, `lastHeartbeatAt`, `errorCode`
673+
- `GET /api/companies/:companyId/process-watchdog` returns watchdog snapshot (pressure + run telemetry)
674+
659675
## 12. Governance and Approval Flows
660676

661677
## 12.1 Hiring

‎doc/plans/2026-03-25-zeroinc-companies-study.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ Arquivo: `server/src/services/heartbeat.ts`
4444

4545
## 3. Recomendações priorizadas
4646

47+
## 3.0 Status de execução (2026-03-25)
48+
49+
1. P0.1 Outcome Ledger + gates de entregável: implementado.
50+
2. P0.2 Discovery Engine + estoque por pilar: implementado.
51+
3. P0.3 Contrato explícito de bloqueio humano + inbox SLA: implementado.
52+
4. P0.4 Guardião de ciclo de vida de processos locais: implementado (watchdog com limiares de pressão, auto-remediação de detached stale e telemetria por run/pid).
53+
4754
## P0 (executar agora)
4855

4956
1. Outcome Ledger (entregável real como fonte de verdade)
@@ -124,4 +131,3 @@ Arquivo: `server/src/services/heartbeat.ts`
124131
## 6. Conclusão
125132

126133
A direção correta não é aumentar quantidade de agentes; é endurecer o sistema de geração e validação de trabalho real. O ZeroInc já tem bons blocos de governança. O próximo salto é transformar esses blocos em um ciclo contínuo de entrega verificável, com autonomia alta e escalonamento humano explícito.
127-

‎server/src/__tests__/heartbeat-process-recovery.test.ts‎

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,25 @@ function spawnAliveProcess() {
9494
});
9595
}
9696

97+
function isPidAlive(pid: number | null | undefined) {
98+
if (typeof pid !== "number" || pid <= 0) return false;
99+
try {
100+
process.kill(pid, 0);
101+
return true;
102+
} catch {
103+
return false;
104+
}
105+
}
106+
107+
async function waitForPidExit(pid: number, timeoutMs = 3000) {
108+
const start = Date.now();
109+
while (Date.now() - start < timeoutMs) {
110+
if (!isPidAlive(pid)) return true;
111+
await new Promise((resolve) => setTimeout(resolve, 50));
112+
}
113+
return !isPidAlive(pid);
114+
}
115+
97116
describe("heartbeat orphaned process recovery", () => {
98117
let db!: ReturnType<typeof createDb>;
99118
let instance: EmbeddedPostgresInstance | null = null;
@@ -141,13 +160,17 @@ describe("heartbeat orphaned process recovery", () => {
141160
includeIssue?: boolean;
142161
runErrorCode?: string | null;
143162
runError?: string | null;
163+
startedAt?: Date;
164+
updatedAt?: Date;
144165
}) {
145166
const companyId = randomUUID();
146167
const agentId = randomUUID();
147168
const runId = randomUUID();
148169
const wakeupRequestId = randomUUID();
149170
const issueId = randomUUID();
150171
const now = new Date();
172+
const startedAt = input?.startedAt ?? now;
173+
const updatedAt = input?.updatedAt ?? startedAt;
151174
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
152175

153176
await db.insert(companies).values({
@@ -195,8 +218,9 @@ describe("heartbeat orphaned process recovery", () => {
195218
processLossRetryCount: input?.processLossRetryCount ?? 0,
196219
errorCode: input?.runErrorCode ?? null,
197220
error: input?.runError ?? null,
198-
startedAt: now,
199-
updatedAt: now,
221+
startedAt,
222+
processStartedAt: startedAt,
223+
updatedAt,
200224
});
201225

202226
if (input?.includeIssue !== false) {
@@ -320,4 +344,61 @@ describe("heartbeat orphaned process recovery", () => {
320344
expect(run?.errorCode).toBeNull();
321345
expect(run?.error).toBeNull();
322346
});
347+
348+
it("terminates detached local processes when watchdog idle timeout is exceeded", async () => {
349+
const child = spawnAliveProcess();
350+
childProcesses.add(child);
351+
expect(child.pid).toBeTypeOf("number");
352+
353+
const detachedAt = new Date(Date.now() - 5 * 60 * 1000);
354+
const { runId } = await seedRunFixture({
355+
processPid: child.pid ?? null,
356+
includeIssue: false,
357+
runErrorCode: "process_detached",
358+
runError: `Lost in-memory process handle, but child pid ${child.pid} is still alive`,
359+
startedAt: detachedAt,
360+
updatedAt: detachedAt,
361+
});
362+
const heartbeat = heartbeatService(db);
363+
364+
const result = await heartbeat.reapOrphanedRuns({
365+
watchdog: {
366+
detachedIdleTimeoutMs: 1_000,
367+
detachedIdleTimeoutUnderPressureMs: 1_000,
368+
killGraceMs: 100,
369+
},
370+
});
371+
372+
expect(result.reaped).toBe(1);
373+
expect(result.runIds).toEqual([runId]);
374+
375+
const run = await heartbeat.getRun(runId);
376+
expect(run?.status).toBe("failed");
377+
expect(run?.errorCode).toBe("process_detached_timeout");
378+
expect(await waitForPidExit(child.pid ?? -1)).toBe(true);
379+
});
380+
381+
it("kills the child pid when max run duration is exceeded", async () => {
382+
const child = spawnAliveProcess();
383+
childProcesses.add(child);
384+
expect(child.pid).toBeTypeOf("number");
385+
386+
const startedAt = new Date(Date.now() - 3 * 60 * 60 * 1000);
387+
const { runId } = await seedRunFixture({
388+
processPid: child.pid ?? null,
389+
includeIssue: false,
390+
startedAt,
391+
updatedAt: startedAt,
392+
});
393+
const heartbeat = heartbeatService(db);
394+
395+
const result = await heartbeat.reapOrphanedRuns();
396+
expect(result.reaped).toBe(1);
397+
expect(result.runIds).toEqual([runId]);
398+
399+
const run = await heartbeat.getRun(runId);
400+
expect(run?.status).toBe("failed");
401+
expect(run?.errorCode).toBe("max_duration_exceeded");
402+
expect(await waitForPidExit(child.pid ?? -1)).toBe(true);
403+
});
323404
});

‎server/src/routes/agents.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2150,10 +2150,14 @@ export function agentRoutes(db: Db) {
21502150
triggerDetail: heartbeatRuns.triggerDetail,
21512151
startedAt: heartbeatRuns.startedAt,
21522152
finishedAt: heartbeatRuns.finishedAt,
2153+
lastHeartbeatAt: heartbeatRuns.updatedAt,
21532154
createdAt: heartbeatRuns.createdAt,
21542155
agentId: heartbeatRuns.agentId,
21552156
agentName: agentsTable.name,
21562157
adapterType: agentsTable.adapterType,
2158+
processPid: heartbeatRuns.processPid,
2159+
processStartedAt: heartbeatRuns.processStartedAt,
2160+
errorCode: heartbeatRuns.errorCode,
21572161
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
21582162
};
21592163

@@ -2192,6 +2196,16 @@ export function agentRoutes(db: Db) {
21922196
res.json(liveRuns);
21932197
});
21942198

2199+
router.get("/companies/:companyId/process-watchdog", async (req, res) => {
2200+
const companyId = req.params.companyId as string;
2201+
assertCompanyAccess(req, companyId);
2202+
2203+
const limitParam = req.query.limit as string | undefined;
2204+
const limit = limitParam ? Math.max(1, Math.min(500, parseInt(limitParam, 10) || 200)) : 200;
2205+
const snapshot = await heartbeat.getLocalProcessTelemetry(companyId, { limit });
2206+
res.json(snapshot);
2207+
});
2208+
21952209
router.get("/heartbeat-runs/:runId", async (req, res) => {
21962210
const runId = req.params.runId as string;
21972211
const run = await heartbeat.getRun(runId);
@@ -2317,10 +2331,14 @@ export function agentRoutes(db: Db) {
23172331
triggerDetail: heartbeatRuns.triggerDetail,
23182332
startedAt: heartbeatRuns.startedAt,
23192333
finishedAt: heartbeatRuns.finishedAt,
2334+
lastHeartbeatAt: heartbeatRuns.updatedAt,
23202335
createdAt: heartbeatRuns.createdAt,
23212336
agentId: heartbeatRuns.agentId,
23222337
agentName: agentsTable.name,
23232338
adapterType: agentsTable.adapterType,
2339+
processPid: heartbeatRuns.processPid,
2340+
processStartedAt: heartbeatRuns.processStartedAt,
2341+
errorCode: heartbeatRuns.errorCode,
23242342
})
23252343
.from(heartbeatRuns)
23262344
.innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))

0 commit comments

Comments
 (0)