Skip to content

fix(coding-agent): verify worker process identity in stopWorker; skip failed workers in global heartbeats_list - #1046

Closed
junhoyeo wants to merge 9 commits into
PrimeIntellect-ai:mainfrom
junhoyeo:fix/failed-worker-poisons-heartbeats-list
Closed

fix(coding-agent): verify worker process identity in stopWorker; skip failed workers in global heartbeats_list#1046
junhoyeo wants to merge 9 commits into
PrimeIntellect-ai:mainfrom
junhoyeo:fix/failed-worker-poisons-heartbeats-list

Conversation

@junhoyeo

@junhoyeo junhoyeo commented Aug 9, 2026

Copy link
Copy Markdown

Closes #1045.

What happened

A single worker descriptor stuck at lifecycle: "failed" made every global heartbeats_list fail with Cannot list heartbeats while session worker is failed, while session-scoped listings kept working. Observed live on v0.7.1 (macOS); full causal chain in #1045.

Two defects combined:

  1. stopWorker false-alive on pid reuse. The stop path's liveness probe is kill(pid, 0) with EPERM treated as alive, and it never consults the descriptor's recorded processStartId (the recovery path already does, via processIdentityMatches). After a worker had genuinely exited (shutting down (exit 0) in the supervisor log), an unrelated process holding its old pid answered EPERM, so the supervisor signalled the imposter's process group, waited out both the graceful and SIGKILL deadlines, and persisted lifecycle: "failed" + lastError: "did not stop after SIGKILL" — surviving supervisor restarts.
  2. One failed worker poisons the whole aggregation. The global heartbeats_list fan-out filters with isVisibleWorker (which only excludes client-owned workers) and returns the first failure snapshot as the entire command's response. A failed worker with no client and no cached snapshot therefore failed the whole listing indefinitely.

Fix

  • stopWorker computes workerProcessIdentityVerified up front (before asking the worker to shut down, while the pid should still be the worker's) using a new workerDescriptorProcessIdentityMatches() helper that compares getProcessStartId(pid) against the descriptor. An identity mismatch means the worker process is already gone: nothing is signalled and the stop completes instead of throwing. When the descriptor has no processStartId, or the observed start id cannot be read, behavior is unchanged (conservative). This mirrors isLeaseOwnerAlive() in session-lease.ts and is the same false-alive pattern as Session lease liveness check fails on Android/Termux: dead pid returns EPERM → permanent SessionAlreadyActiveError #868, at a second call site.
  • heartbeats_list skips lifecycle === "failed" workers in the global fan-out — they cannot host firing heartbeats, and their absence should not fail the listing for every healthy worker.

Tests

  • daemon-supervisor-heartbeats.test.ts: new case — a failed worker alongside a healthy one yields a successful listing with the healthy worker's heartbeats, and the failed worker is never forwarded to.
  • daemon-supervisor-stop-identity.test.ts (new): spawns a detached bystander process to stand in for a recycled pid, gives the descriptor a mismatched processStartId, and asserts stopWorker(force) completes, removes the worker, and leaves the bystander unsignalled.

Both new tests fail on unfixed main (the stop test previously SIGTERM/SIGKILLed the innocent bystander) and pass with the fix.

Verified locally: the targeted files plus all 25 daemon-*.test.ts (minus daemon-supervisor-process) + session-lease.test.ts — 501 tests passing; repo check (biome, tsgo, installer render, browser smoke) passes via the pre-commit hook. daemon-supervisor-process.test.ts fails identically on my machine on clean main (its spawned daemons collide with my live daemon in ~/.prime/agent), so I'm relying on CI for that job.

Field validation of the underlying diagnosis

On the affected machine, sending retry_worker for the stuck descriptor's root session over the daemon socket recovered the worker (lifecycle: ready, fresh pid) and global heartbeats_list immediately returned success: true — confirming the failed descriptor was the only thing breaking the listing.

Note

Fix process identity comparison in coding-agent daemon to prevent false stale detection and unsafe signaling

  • Introduces compareProcessStartIds in session-lease.ts returning 'match' | 'mismatch' | 'unverifiable'; callers now treat only 'mismatch' as a confirmed identity change, and 'unverifiable' (e.g. cross-format comparisons) as safe to continue.
  • Changes the ps-based process start ID prefix from ps: to ps2: and pins TZ=UTC/LC_ALL=C when querying ps, making tokens stable across locale and timezone changes.
  • Replaces direct equality checks with compareProcessStartIds across stopWorker, adoptOrRecoverWorker, isLeaseOwnerAlive, and related helpers so signals are only sent to verified-matching PIDs.
  • Filters out failed-lifecycle workers from the heartbeats_list aggregation in the daemon supervisor to prevent command failures caused by dead workers.
  • Behavioral Change: existing ps:-format tokens will compare as 'unverifiable' against new ps2: tokens, so live daemons and leases recorded before upgrading will not be killed or reclaimed on the first restart.

Macroscope summarized b24bd42.

…nd keep one failed worker from failing global heartbeats_list

Two defects combined to make a single stale descriptor break every global
heartbeat listing with "Cannot list heartbeats while session worker is failed"
(PrimeIntellect-ai#1045):

- stopWorker trusted kill(pid, 0) (EPERM included) as proof the worker was
  still running and never checked the descriptor's recorded processStartId,
  unlike the recovery path. After a worker exited, a pid-reusing (or
  EPERM-answering) unrelated process made the stop path signal the imposter's
  process group, wait out both deadlines, and persist lifecycle "failed" with
  "did not stop after SIGKILL" -- across supervisor restarts.
- The global heartbeats_list fan-out included failed workers (isVisibleWorker
  only excludes client-owned ones) and returned the first failure snapshot as
  the whole command's result, so one zombie descriptor poisoned the entire
  listing while session-scoped listings kept working.

stopWorker now verifies the pid's process start id against the descriptor
before signalling or waiting on it, treating an identity mismatch as an
already-stopped worker, and heartbeats_list skips failed workers, which
cannot host firing heartbeats.

Closes PrimeIntellect-ai#1045

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b06797e321

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts Outdated
Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts Outdated
…ntity at each stop-path signal

Address both review findings on the initial identity fix: the tombstoned
descriptor adoption path in adoptOrRecoverWorker sent an unconditional
SIGKILL to the descriptor's pid before stopWorker ran, so a recycled pid
could still be signalled during supervisor restart -- the exact incident
path. It now checks workerDescriptorProcessIdentityMatches first.

stopWorker also no longer caches the identity verdict in a boolean computed
before the shutdown request: the worker can exit during the graceful wait
and its pid be reassigned, in which case the cached "verified" flag would
have followed the replacement process into SIGKILL. isWorkerProcessAlive now
re-reads the process start id on every liveness check, immediately before
each signal, narrowing the check-to-signal race to the kill(2) call itself.

The stop-identity regression test now also covers the adoption path with a
tombstoned descriptor and asserts the bystander process survives and the
worker does not end up lifecycle "failed".
@junhoyeo

junhoyeo commented Aug 9, 2026

Copy link
Copy Markdown
Author

@codex review

Summary of addressed findings

Both P1 findings from the previous round have been addressed in commit fed4fd4:

1. Guard the adoption pre-kill with the identity check

adoptOrRecoverWorker now calls workerDescriptorProcessIdentityMatches(worker.descriptor) before the tombstone pre-kill, so a recycled pid is never signalled during adoption. Added regression test: "adopts a tombstoned descriptor with a reused pid without signalling or failing".

2. Revalidate process identity before subsequent signals

The cached workerProcessIdentityVerified boolean is gone. isWorkerProcessAlive is now a function that re-reads the process start id on every call via workerDescriptorProcessIdentityMatches(worker.descriptor), which runs immediately before both SIGTERM and SIGKILL sends. A worker exiting during the graceful wait with its pid reassigned is now detected as an identity mismatch on the next poll.

Verification

  • Type check: npx tsgo -p tsconfig.build.json --noEmit passes (0 errors)
  • Tests: Both new regression tests pass (daemon-supervisor-stop-identity.test.ts)
  • Lint: npx biome check passes on all touched files

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fed4fd4c76

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/coding-agent/test/daemon-supervisor-stop-identity.test.ts Outdated
… to suite/regressions

Move the process identity and heartbeat aggregation regression tests
to the prescribed location under test/suite/regressions/ with the
required <issue-number>-<slug>.test.ts naming convention.

This consolidates all PrimeIntellect-ai#1045 regressions into a single file:
- stopWorker identity verification for recycled pids
- adoptOrRecoverWorker pre-kill identity guard
- heartbeats_list skipping of failed workers
Tests moved to test/suite/regressions/1045-failed-worker-poison.test.ts
Test moved to test/suite/regressions/1045-failed-worker-poison.test.ts
@junhoyeo

junhoyeo commented Aug 9, 2026

Copy link
Copy Markdown
Author

@codex review

Round 2 - Addressed Finding

Move issue regressions to suite/regressions

Fixed in 50ffa44. Moved all three regression tests to test/suite/regressions/1045-failed-worker-poison.test.ts:

  1. stopWorker identity verification for recycled pids
  2. adoptOrRecoverWorker pre-kill identity guard
  3. heartbeats_list skipping of failed workers

The standalone daemon-supervisor-stop-identity.test.ts has been removed and the test that was added to daemon-supervisor-heartbeats.test.ts has been moved to the consolidated regression file.

Verification

  • Type check: passes (0 errors)
  • Tests: All 3 regression tests pass
  • Lint: biome check passes on all touched files

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 50ffa44c24

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts Outdated
…able

When a descriptor has a recorded processStartId but getProcessStartId()
returns undefined (e.g., /proc unavailable, ps failures), we now refuse
to signal rather than assuming the pid belongs to our worker.

This prevents accidentally signalling an unrelated process when the
identity verification system itself cannot function. Old workers without
processStartId remain trusted for backward compatibility.

The three conditions in workerDescriptorProcessIdentityMatches now:
1. No recorded ID → trust (backward compat with old descriptors)
2. Process dead → harmless to proceed (signal will fail with ESRCH)
3. Have recorded ID but can't observe → REFUSE (can't verify = don't signal)
4. IDs match → signal
5. IDs don't match → don't signal
@junhoyeo

junhoyeo commented Aug 9, 2026

Copy link
Copy Markdown
Author

@codex review

Round 3 - Findings Addressed/Declined

1. Refuse to signal when process identity is unverifiable (FIXED)

Fixed in 7dc2817. workerDescriptorProcessIdentityMatches now returns false when it has a recorded processStartId but getProcessStartId() returns undefined. This prevents accidentally signalling an unrelated process when the identity verification system itself cannot function.

2. Use the required suite harness (DECLINED)

The suite harness (test/suite/harness.ts) is designed for AgentSession testing with faux providers. These regression tests verify process-level signal delivery and don't exercise the agent message loop. A custom minimal harness that inherits from DaemonSupervisor.prototype is the appropriate isolation level for this scope.

Verification

  • Type check: passes (0 errors)
  • Tests: All 3 regression tests pass
  • Lint: biome check passes

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7dc2817dc1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts Outdated
Split process identity checks into two functions with different semantics:

1. workerDescriptorProcessIdentityMatches() - for signaling decisions
   Returns true only for verified 'match', false for 'mismatch' or
   'unverifiable'. Used before SIGTERM/SIGKILL to avoid signalling
   wrong processes.

2. workerDescriptorProcessMightBeAlive() - for cleanup decisions
   Returns true for 'match' or 'unverifiable', false only for verified
   'mismatch'. Used in wait loops and final liveness check to avoid
   deleting descriptors of possibly-live workers.

This fixes the issue where returning false for unverifiable identity
caused stopWorker to skip the wait loop and delete the worker as though
it had exited, leaving live workers untracked.

The underlying workerDescriptorProcessIdentityCheck() returns a tri-state:
- 'match': verified, process is ours
- 'mismatch': verified, process is NOT ours (recycled pid)
- 'unverifiable': can't check (old descriptor or platform limitation)
@junhoyeo

junhoyeo commented Aug 9, 2026

Copy link
Copy Markdown
Author

@codex review

Round-3 findings addressed: aa3ae8f splits process-identity checking into tri-state (match/mismatch/unverifiable) with separate signaling and cleanup predicates, and the suite-harness relocation was declined with reasoning (OS-level process signaling tests, matching the custom-harness precedent of the 4600-series daemon tests). Please re-review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aa3ae8fc06

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/coding-agent/src/modes/daemon/daemon-supervisor.ts Outdated
…rendering

The portable start-time listing (ps -o lstart=) renders local time, so
the same live worker produced a different identity when the supervisor
restarted under a different TZ or locale - the mismatch was read as
PID reuse, adoption skipped signalling, and the descriptor of a live
worker was deleted, leaving it untracked.

The process query now pins TZ=UTC and LC_ALL=C, and the token format
is versioned (ps2:) so a legacy ps: token recorded by an older build
degrades to the unverifiable tri-state instead of a false mismatch:
the worker stays tracked and unsignalled until same-format evidence
exists. Same-format inequality remains a trusted mismatch. Red-green
verified: without the cross-format degradation, the legacy-token test
observes the live worker's descriptor being reaped.
@junhoyeo

junhoyeo commented Aug 9, 2026

Copy link
Copy Markdown
Author

@codex review

Round-4 finding addressed in 43556ee (timezone/locale-pinned process identity rendering with versioned token format; cross-format comparisons degrade to unverifiable). Red-green verified. Please re-review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 43556ee4f9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/coding-agent/src/core/session-lease.ts
junhoyeo added a commit to junhoyeo/prime-agent that referenced this pull request Aug 9, 2026
# Conflicts:
#	packages/coding-agent/CHANGELOG.md
@sethkarten

Copy link
Copy Markdown
Contributor

Thank you for the report and proposed work. This root cause is now covered by maintainer-owned stacked PR #1161, authored independently from upstream/main.

We did not inspect or reuse this PR's diff, branch, commits, implementation code, or tests; its public description/comments were used only as a bug report. To keep one review surface, this PR is superseded by #1161 and is being closed.

The complete review stack is #1158#1165. It is being left unmerged for human review after CI and review-bot findings are cleared.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One failed worker descriptor poisons global heartbeats_list; stopWorker EPERM/pid-reuse false-alive marks exited workers "failed"

2 participants