mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(recovery): judge an orphaned run's pid only in the PID space that recorded it (#19871)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The orphan reaper (`terminalizeOrphanedRunningRun`) ends a `running` run whose recorded process is gone > - It judged "gone" with `kill(pid, 0)` on the local host, wherever the process actually ran: another host, another PID namespace, or a sandbox > - So a run whose process is alive elsewhere could be terminalized while it still works > - #19868 fixed the same gap for native coordinator takeover and the runtime-control sweep. This pull request finishes finding 2 for the reaper > - The benefit is that a dead local pid never proves an owner on another host is gone ## Linked Issues or Issue Description **Bug description** The Phase 0 ownership inventory (finding 2) names the orphan reaper `terminalizeOrphanedRunningRun` (`recovery/service.ts`) as a proof that accepts "the pid is dead here" for a process anywhere. `heartbeat_runs` recorded `process_pid` with no PID space, and a sandbox pid could collide with a local one. **Steps to reproduce** 1. A run records `process_pid` on host A, or in a sandbox. 2. On host B, that pid number is not alive. 3. B's stale-lock sweep terminalizes the run as `orphaned_running_run`. **Expected behavior** The process-death authority applies only in the PID space that recorded the pid. Issue-terminal authority is unchanged. Refs #19846, follows #19868 ## What Changed - Migration `0442`: add nullable `heartbeat_runs.process_host_hash`. - `heartbeat/run-lifecycle.ts` `persistHeartbeatRunProcessMetadata`, the one writer of `process_pid`, stores `currentProcessSpaceHash()` for a local pid and `"remote"` for a sandbox pid. - `recovery/service.ts`: the process-death authority runs only when `process_host_hash` is null (rows saved before this column, for one release) or `sameProcessSpace` matches. - Tests: the reaper leaves a dead pid alone for another host, a sandbox, and an unreadable space, and terminalizes it in this space. The writer stamps local and remote pids. ## Verification - `npx vitest run src/__tests__/recovery-stale-issue-lock-sweep.test.ts`: 46 passed. - Red before the fix: with the old `recovery/service.ts`, the three cross-space cases fail (the run is terminalized). Green after. - `pnpm db:generate` produced `0442_low_salo.sql` (one `ADD COLUMN`), and the snapshot set was pruned to five. `pnpm --filter @paperclipai/db check:migrations` passed. Server `tsc --noEmit` shows no errors in the changed files. ## Risks - Previous-release story: runs saved with `process_host_hash = null` keep the previous local proof for this release. That matches #19868, because stranding in-flight runs at upgrade would be a restart failure. New rows are strict. - A run whose process really lived on another host now stays `running` until issue-terminal authority, its controller lease, or another owner settles it. - No new I/O before `turn.start`. The hash is computed at module load, and the stamp rides the existing process-metadata update. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
00e627a23e
commit
5a20fca9f7
11 files changed
+533
-8
No files matched your search
@@ -0,0 +1 @@
|
||||
ALTER TABLE "heartbeat_runs" ADD COLUMN "process_host_hash" text;
|
||||
+463
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "2c0cb580-67fc-45d3-9ca5-cce4148dd1a4",
|
||||
"prevId": "a80d63fe-5111-49e8-a39a-7a5589b3b31a",
|
||||
"id": "b3f3d712-65da-4b5a-bbf5-3c22fe8aae40",
|
||||
"prevId": "a2ec96c6-3cf1-4c51-8ee8-b35f090bfd42",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -34880,6 +34880,18 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"process_host_hash": {
|
||||
"name": "process_host_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"legacy_process_settlement": {
|
||||
"name": "legacy_process_settlement",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_output_at": {
|
||||
"name": "last_output_at",
|
||||
"type": "timestamp with time zone",
|
||||
@@ -44457,6 +44469,256 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.native_provider_artifact_references": {
|
||||
"name": "native_provider_artifact_references",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"artifact_id": {
|
||||
"name": "artifact_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"backup_id": {
|
||||
"name": "backup_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"native_provider_artifact_references_artifact_idx": {
|
||||
"name": "native_provider_artifact_references_artifact_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "artifact_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"native_provider_artifact_references_run_idx": {
|
||||
"name": "native_provider_artifact_references_run_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "artifact_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"native_provider_artifact_references\".\"backup_id\" is null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"native_provider_artifact_references_backup_idx": {
|
||||
"name": "native_provider_artifact_references_backup_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "artifact_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "backup_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"native_provider_artifact_references\".\"backup_id\" is not null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"native_provider_artifact_references_artifact_id_native_provider_artifacts_id_fk": {
|
||||
"name": "native_provider_artifact_references_artifact_id_native_provider_artifacts_id_fk",
|
||||
"tableFrom": "native_provider_artifact_references",
|
||||
"tableTo": "native_provider_artifacts",
|
||||
"columnsFrom": [
|
||||
"artifact_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "restrict",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"native_provider_artifact_references_backup_id_native_harness_backups_id_fk": {
|
||||
"name": "native_provider_artifact_references_backup_id_native_harness_backups_id_fk",
|
||||
"tableFrom": "native_provider_artifact_references",
|
||||
"tableTo": "native_harness_backups",
|
||||
"columnsFrom": [
|
||||
"backup_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.native_provider_artifacts": {
|
||||
"name": "native_provider_artifacts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"closure_digest": {
|
||||
"name": "closure_digest",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"upload_token": {
|
||||
"name": "upload_token",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"descriptor": {
|
||||
"name": "descriptor",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reference": {
|
||||
"name": "reference",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"native_provider_artifacts_company_digest_idx": {
|
||||
"name": "native_provider_artifacts_company_digest_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "closure_digest",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"native_provider_artifacts\".\"status\" = 'ready'",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.native_run_finalizations": {
|
||||
"name": "native_run_finalizations",
|
||||
"schema": "",
|
||||
@@ -44516,6 +44778,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"controller_host_hash": {
|
||||
"name": "controller_host_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"controller_process_started_at": {
|
||||
"name": "controller_process_started_at",
|
||||
"type": "timestamp with time zone",
|
||||
@@ -64075,6 +64343,139 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mcp_oauth_client_changes": {
|
||||
"name": "mcp_oauth_client_changes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"revision": {
|
||||
"name": "revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"operation": {
|
||||
"name": "operation",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"mcp_client_changes_revision_idx": {
|
||||
"name": "mcp_client_changes_revision_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "revision",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "client_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"mcp_client_change_revision": {
|
||||
"name": "mcp_client_change_revision",
|
||||
"value": "\"mcp_oauth_client_changes\".\"revision\" >= 1"
|
||||
},
|
||||
"mcp_client_change_operation": {
|
||||
"name": "mcp_client_change_operation",
|
||||
"value": "\"mcp_oauth_client_changes\".\"operation\" IN ('upsert','delete')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mcp_oauth_client_mirror_state": {
|
||||
"name": "mcp_oauth_client_mirror_state",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"remote_responsibility": {
|
||||
"name": "remote_responsibility",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"device_cutover_stack_id": {
|
||||
"name": "device_cutover_stack_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"device_cutover_at": {
|
||||
"name": "device_cutover_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revision": {
|
||||
"name": "revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"receipt": {
|
||||
"name": "receipt",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"mcp_client_mirror_singleton": {
|
||||
"name": "mcp_client_mirror_singleton",
|
||||
"value": "\"mcp_oauth_client_mirror_state\".\"id\" = 'public'"
|
||||
},
|
||||
"mcp_client_mirror_revision": {
|
||||
"name": "mcp_client_mirror_revision",
|
||||
"value": "\"mcp_oauth_client_mirror_state\".\"revision\" >= 0"
|
||||
},
|
||||
"mcp_device_cutover_identity": {
|
||||
"name": "mcp_device_cutover_identity",
|
||||
"value": "(\"mcp_oauth_client_mirror_state\".\"device_cutover_at\" IS NULL AND \"mcp_oauth_client_mirror_state\".\"device_cutover_stack_id\" IS NULL) OR (\"mcp_oauth_client_mirror_state\".\"device_cutover_at\" IS NOT NULL AND \"mcp_oauth_client_mirror_state\".\"device_cutover_stack_id\" IS NOT NULL)"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mcp_oauth_clients": {
|
||||
"name": "mcp_oauth_clients",
|
||||
"schema": "",
|
||||
@@ -64143,6 +64544,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ingress_owner": {
|
||||
"name": "ingress_owner",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'tenant'"
|
||||
},
|
||||
"device_code_hash": {
|
||||
"name": "device_code_hash",
|
||||
"type": "text",
|
||||
@@ -64298,7 +64706,12 @@
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"checkConstraints": {
|
||||
"mcp_device_ingress_owner": {
|
||||
"name": "mcp_device_ingress_owner",
|
||||
"value": "\"mcp_oauth_device_requests\".\"ingress_owner\" IN ('tenant','cloud')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.mcp_oauth_grants": {
|
||||
@@ -64361,6 +64774,19 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revocation_revision": {
|
||||
"name": "revocation_revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"revocation_index_receipt": {
|
||||
"name": "revocation_index_receipt",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
@@ -64691,6 +65117,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revocation_revision": {
|
||||
"name": "revocation_revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
@@ -64736,6 +65169,33 @@
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"mcp_oauth_tokens_revocation_revision_idx": {
|
||||
"name": "mcp_oauth_tokens_revocation_revision_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "grant_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "revocation_revision",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"mcp_oauth_tokens_expiry_idx": {
|
||||
"name": "mcp_oauth_tokens_expiry_idx",
|
||||
"columns": [
|
||||
+7
@@ -3081,6 +3081,13 @@
|
||||
"when": 1794069983192,
|
||||
"tag": "0441_lucky_chameleon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 442,
|
||||
"version": "7",
|
||||
"when": 1794071961340,
|
||||
"tag": "0442_low_salo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -96,6 +96,9 @@ export const heartbeatRuns = pgTable(
|
||||
processPid: integer("process_pid"),
|
||||
processGroupId: integer("process_group_id"),
|
||||
processStartedAt: timestamp("process_started_at", { withTimezone: true }),
|
||||
// PID space of processPid (controller-process-identity hostHash), or
|
||||
// "remote" for a sandbox PID. Null on rows saved before it existed.
|
||||
processHostHash: text("process_host_hash"),
|
||||
legacyProcessSettlement: jsonb("legacy_process_settlement").$type<Record<string, unknown>>(),
|
||||
lastOutputAt: timestamp("last_output_at", { withTimezone: true }),
|
||||
lastOutputSeq: integer("last_output_seq").notNull().default(0),
|
||||
|
||||
@@ -512,7 +512,7 @@ describe("residual recovery production matrix", () => {
|
||||
} finally { db.$client.options.debug = prior; }
|
||||
expect(await getRun(run.id)).toMatchObject({ processPid: pid, processStartedAt: null });
|
||||
await quiet();
|
||||
await persistHeartbeatRunProcessMetadata(db, run.id, { pid, processGroupId: null, startedAt: startedAt.toISOString() });
|
||||
await persistHeartbeatRunProcessMetadata(db, run.id, { pid, processGroupId: null, startedAt: startedAt.toISOString(), targetKind: "local" });
|
||||
await advance(1_000);
|
||||
await vi.waitFor(async () => expect((await getRun(run.id)).processPid).toBeNull());
|
||||
expect(await db.select().from(heartbeatRunEvents).where(and(eq(heartbeatRunEvents.runId, run.id),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { persistHeartbeatRunProcessMetadata } from "../services/heartbeat/run-lifecycle.js";
|
||||
import { currentProcessSpaceHash } from "../services/controller-process-identity.js";
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
@@ -394,6 +396,34 @@ describeEmbeddedPostgres("recovery sweepStaleIssueLocks", () => {
|
||||
expect(second.cleared).toBe(0);
|
||||
});
|
||||
|
||||
it.each([["local", true], ["remote", false]] as const)("stamps a %s process pid with its PID space", async (targetKind, local) => {
|
||||
const { runningRunId } = await seed();
|
||||
await persistHeartbeatRunProcessMetadata(db, runningRunId, { pid: process.pid, processGroupId: null,
|
||||
startedAt: new Date().toISOString(), targetKind });
|
||||
const [run] = await db.select({ processHostHash: heartbeatRuns.processHostHash }).from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runningRunId));
|
||||
expect(run?.processHostHash).toBe(local ? currentProcessSpaceHash() : "remote");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["another host", "another-host-pid-space", false],
|
||||
["a sandbox", "remote", false],
|
||||
["an unreadable PID space", "unknown", false],
|
||||
["this PID space", "current", true],
|
||||
] as const)("judges a dead recorded pid only in its own PID space (%s)", async (_name, space, terminalized) => {
|
||||
const { companyId, agentId, runningRunId } = await seed();
|
||||
await db.update(heartbeatRuns).set({ processPid: 2_000_000_000,
|
||||
processHostHash: space === "current" ? currentProcessSpaceHash() : space })
|
||||
.where(eq(heartbeatRuns.id, runningRunId));
|
||||
await db.insert(issues).values({ id: randomUUID(), companyId, title: "Cross-host orphan", status: "in_progress",
|
||||
priority: "high", assigneeAgentId: agentId, checkoutRunId: runningRunId, executionRunId: runningRunId,
|
||||
executionLockedAt: new Date() });
|
||||
const result = await heartbeatService(db).sweepStaleIssueLocks();
|
||||
expect(result.terminalizedRunIds.includes(runningRunId)).toBe(terminalized);
|
||||
const [run] = await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runningRunId));
|
||||
expect(run?.status).toBe(terminalized ? "interrupted" : "running");
|
||||
});
|
||||
|
||||
it("terminalizes an orphaned running run whose process is gone, then clears the lock", async () => {
|
||||
const { companyId, agentId, runningRunId } = await seed();
|
||||
// The run recorded a pid, but the process and its sandbox are gone. A pid
|
||||
|
||||
@@ -1772,7 +1772,7 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes
|
||||
expect(await hasNativeLocalProcessStop(db, f.companyId, source.id)).toBe(true);
|
||||
expect(await hasNativeLocalProcessStop(db, randomUUID(), source.id)).toBe(false);
|
||||
expect(await admit(f, true)).toMatchObject({ previousRunId: source.id });
|
||||
await persistHeartbeatRunProcessMetadata(db, source.id, { pid: 999999999, processGroupId: null, startedAt: new Date().toISOString() });
|
||||
await persistHeartbeatRunProcessMetadata(db, source.id, { pid: 999999999, processGroupId: null, startedAt: new Date().toISOString(), targetKind: "local" });
|
||||
await db.update(heartbeatRuns).set({ processPid: null }).where(eq(heartbeatRuns.id, source.id));
|
||||
expect(await admit(f, true)).toBeNull();
|
||||
expect(await db.transaction(tx => recordNativeLocalProcessStop(tx as unknown as typeof db, source))).toBe(true);
|
||||
|
||||
@@ -197,6 +197,7 @@ import {
|
||||
setHeartbeatRunRuntimeStatus,
|
||||
} from "../heartbeat-run-runtime-status.js";
|
||||
import { readProcessStartedAt } from "../hot-restart.js";
|
||||
import { currentProcessSpaceHash } from "../controller-process-identity.js";
|
||||
|
||||
import type { createHeartbeatRunState } from "./run-state.js";
|
||||
import type { createHeartbeatRunPreparation } from "./run-preparation.js";
|
||||
@@ -578,7 +579,7 @@ function buildRunEventRuntimeProgress(input: {
|
||||
export async function persistHeartbeatRunProcessMetadata(
|
||||
db: Db,
|
||||
runId: string,
|
||||
meta: { pid: number; processGroupId: number | null; startedAt: string; targetKind?: "local" | "remote" },
|
||||
meta: { pid: number; processGroupId: number | null; startedAt: string; targetKind: "local" | "remote" },
|
||||
) {
|
||||
// A sandbox PID belongs to a different host and may collide with a local PID.
|
||||
const observedStartedAt = meta.targetKind === "remote" ? null : await readProcessStartedAt(meta.pid).catch(
|
||||
@@ -592,6 +593,8 @@ export async function persistHeartbeatRunProcessMetadata(
|
||||
legacyProcessSettlement: null,
|
||||
processPid: meta.pid,
|
||||
processGroupId: meta.processGroupId,
|
||||
// A sandbox PID never names a process in this PID space.
|
||||
processHostHash: meta.targetKind === "remote" ? "remote" : currentProcessSpaceHash(),
|
||||
processStartedAt: Number.isNaN(startedAt.getTime())
|
||||
? new Date()
|
||||
: startedAt,
|
||||
@@ -2240,7 +2243,7 @@ export function createHeartbeatLifecycle(db: Db, dependencies: HeartbeatLifecycl
|
||||
|
||||
async function persistRunProcessMetadata(
|
||||
runId: string,
|
||||
meta: { pid: number; processGroupId: number | null; startedAt: string; targetKind?: "local" | "remote" },
|
||||
meta: { pid: number; processGroupId: number | null; startedAt: string; targetKind: "local" | "remote" },
|
||||
) {
|
||||
return persistHeartbeatRunProcessMetadata(db, runId, meta);
|
||||
}
|
||||
|
||||
@@ -444,6 +444,19 @@ describe.skipIf(!support.supported)("heartbeat runtime execution boundary", () =
|
||||
expectCleanup(input);
|
||||
});
|
||||
|
||||
it.each([["local", null], ["remote", { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" }]] as const)(
|
||||
"passes the spawn origin through the legacy adapter callback (%s)", async (expected, target) => {
|
||||
const { input, adapter } = await fixture();
|
||||
if (target) input.workspace.executionTarget = target as typeof input.workspace.executionTarget;
|
||||
adapter.execute.mockImplementationOnce(async ctx => {
|
||||
await ctx.onSpawn?.({ pid: 4242, processGroupId: 4242, startedAt: new Date().toISOString() });
|
||||
return { exitCode: 0, signal: null, timedOut: false, provider: "test" };
|
||||
});
|
||||
await executeHeartbeatRuntime(db, input).catch(() => undefined);
|
||||
expect(input.services.persistRunProcessMetadata).toHaveBeenCalledWith(expect.any(String),
|
||||
expect.objectContaining({ pid: 4242, targetKind: expected }));
|
||||
});
|
||||
|
||||
it("preserves Stop metadata and marks no-work accounting without manufacturing a receipt", async () => {
|
||||
const { input, adapter, run } = await fixture();
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled", resultJson: { stoppedByUser: true } }).where(eq(heartbeatRuns.id, run.id));
|
||||
|
||||
@@ -924,6 +924,8 @@ export async function executeHeartbeatRuntime(db: Db, input: HeartbeatRuntimeExe
|
||||
? meta.processGroupId
|
||||
: null,
|
||||
startedAt: meta.startedAt,
|
||||
// A sandbox runner's spawn metadata names a sandbox PID.
|
||||
targetKind: executionTarget?.kind ?? "local",
|
||||
});
|
||||
},
|
||||
authToken: authToken ?? undefined,
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
isPidAlive,
|
||||
isProcessGroupAlive,
|
||||
} from "../local-service-supervisor.js";
|
||||
import { sameProcessSpace } from "../controller-process-identity.js";
|
||||
import { redactSensitiveText } from "../../redaction.js";
|
||||
import { isUniqueViolation } from "../../db-errors.js";
|
||||
import {
|
||||
@@ -6180,7 +6181,12 @@ export function recoveryService(
|
||||
let processGone = false;
|
||||
const hasLiveExecution =
|
||||
deps.liveRunExecutions?.has(run.id) ?? runningProcesses.has(run.id);
|
||||
if (!hasLiveExecution) {
|
||||
// A recorded PID is judged only in the PID space that recorded it: a dead
|
||||
// local PID never proves that a process on another host, in another
|
||||
// namespace, or in a sandbox ("remote") is gone. Rows saved before the
|
||||
// space was recorded keep the previous local proof for one release.
|
||||
const processSpaceJudgeable = run.processHostHash == null || sameProcessSpace(run.processHostHash);
|
||||
if (!hasLiveExecution && processSpaceJudgeable) {
|
||||
if (typeof pid === "number" || typeof processGroupId === "number") {
|
||||
const processAlive =
|
||||
(typeof pid === "number" && isPidAlive(pid)) ||
|
||||
|
||||
Reference in new issue
Block a user