Skip to content

[3372 by Claude Opus 5.5] 0.9.0 release logic fix: an interrupted agent image build/pull leaves the Redis build slot to a dead holder, and the next start waits up to 45 minutes silently with no agents and no indexing. Check current upstream main first. - #3373

Merged
integry merged 2 commits into
mainfrom
3372/claude-opus-5-5-0-9-0-release-logic-fix-a-20261029-0736-r0w
Oct 29, 2026

Conversation

@propr-dev

@propr-dev propr-dev Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

AI Implementation Summary

Closes #3372

Branch: 3372/claude-opus-5-5-0-9-0-release-logic-fix-a-20261029-0736-r0w
Commits: ✅ Changes committed (f56797c)


AI Processing Completed

Execution Details:

Summary:
I've fixed the issue: a build slot left behind by a dead worker is now reclaimed, a stopping worker releases its own slot, and waiting is no longer silent. Upstream main was the same commit as this branch (ba1e2724d), so nothing there already covered it. All the checks the issue lists pass, but no test ran against a real Redis or a real propr stop. The tests use a fake Redis client and a fake owner probe.

What changed

  1. Holder identity and reclaim (packages/core/src/agents/agentImageBuildLock.ts): the slot now stores the build's token together with its owner (from currentClaimOwner()). When SET NX fails, the waiter reads the holder. If claimOwnerGone proves the holder's container or process is gone, the waiter deletes the slot only if it still holds that exact value, then tries again. It re-checks the same holder every 15 s rather than every 250 ms poll. A live holder is never touched, and the 45-minute lease still applies as a last resort.
  2. Graceful shutdown: the worker's close() now calls the new abortAgentImageBuildsForShutdown() first, before it waits for jobs to finish. That stops any wait for the slot and aborts an in-flight build or pull, because the docker build/pull commands now get an abort signal. It then releases any slot this process holds, giving the build up to 5 s to stop first. Any later attempt to take the slot in that process is refused.
  3. No more silent waits: a warning is logged 10 s into a wait and every 60 s after, naming the holder and the remaining lease. Reclaiming a slot is also logged. When the API finds the image not ready, its status now ends with …has not been prepared by the worker; waiting for build slot held by <host> pid <n>, lease expires in 37m. That text reaches /status warnings, agent health checks and the UI. The lookup uses a short-lived Redis connection that never retries and is cached for 5 s. With Redis down it returned nothing in 8 ms and the process exited cleanly.
  4. Tests:
    • test/agentImageBuildLock.test.ts covers: the owner is recorded; a gone container and a gone pid are both reclaimed; a live holder waits with repeated warnings; three racing reclaimers delete the dead holder once and never overlap; and shutdown aborts the build, releases the slot and refuses new builds.
    • The new test/agentImageSlotStatusHint.test.ts (added to test:unit) covers the status text.
  5. Changelog: added to the existing 0.9.0 Fixed bullet "Agent image recovery".

Checks run

  • npm run lint --workspaces --if-present -- --max-warnings 0: clean.
  • Root lint and tsc --noEmit, plus core lint and typecheck: clean.
  • The lock, preparation, registry, runtime-package and API health/status test files: 191 + 35 passed.
  • npm run test:unit: 746 passed.
  • EXPECTED_VERSION=0.9.0 RELEASE_TAG=v0.9.0 RELEASE_CANDIDATE=true npm run release:verify: passed.

Things to know

  • The install in the report won't be unstuck by this. Its slot was written by the unpatched 0.9.0, so it has no owner recorded. A slot like that can't be proven dead, so it is still only freed when its lease expires. It does now show up in logs and status as "an unidentified holder (token …)". That install needs to wait out the lease or have propr:agent-image-build:slot deleted by hand.
  • src/worker.ts was at the 400-line lint limit. To stay under it, I put the import on one line with closeAgentImageBuildLock and put agentImageWatchdog.close(); await abortAgentImageBuildsForShutdown(); on a single line.
  • This changes status text only, not the UI, so I made no visual preview.

Detailed Logs:

  • Conversation: 149 messages
  • Session: dd90a571-8229-4a82-9f91-a61dcfe29d4b

Log files stored at:

  • conversation: /usr/src/app/logs/executions/issue-3372-2026-10-29T07-53-48-035Z-conversation.json
  • output: /usr/src/app/logs/executions/issue-3372-2026-10-29T07-53-48-035Z-output.txt
Latest Conversation Messages
ASSISTANT: [content unavailable]

ASSISTANT: I've fixed the issue: a build slot left behind by a dead worker is now reclaimed, a stopping worker releases its own slot, and waiting is no longer silent. Upstream `main` was the same commit as this ...


This PR was created automatically by ProPR after processing issue #3372.


💡 Need changes?

Comment on this PR to request refinements — the AI agent monitors comments and will update the implementation based on your feedback. Keep iterating until you're satisfied!

…w reclaimed, a stopping worker releases its own slot, and waiting is no longer silent. Upstream `main` was the same commit as this branch (`ba1e2724d`), so nothing there already covered it. All the checks the issue lists pass, but no test ran against a real Redis or a real `propr stop`. The tests use a fake Redis client and a fake owner probe.

What changed
1. Holder identity and reclaim (`packages/core/src/agents/agentImageBuildLock.ts`): the slot now stores the build's token together with its owner (from `currentClaimOwner()`). When `SET NX` fails, the waiter reads the holder. If `claimOwnerGone` proves the holder's container or process is gone, the waiter deletes the slot only if it still holds that exact value, then tries again. It re-checks the same holder every 15 s rather than every 250 ms poll. A live holder is never touched, and the 45-minute lease still applies as a last resort.
2. Graceful shutdown: the worker's `close()` now calls the new `abortAgentImageBuildsForShutdown()` first, before it waits for jobs to finish. That stops any wait for the slot and aborts an in-flight build or pull, because the `docker build`/`pull` commands now get an abort signal. It then releases any slot this process holds, giving the build up to 5 s to stop first. Any later attempt to take the slot in that process is refused.
3. No more silent waits: a warning is logged 10 s into a wait and every 60 s after, naming the holder and the remaining lease. Reclaiming a slot is also logged. When the API finds the image not ready, its status now ends with `…has not been prepared by the worker; waiting for build slot held by <host> pid <n>, lease expires in 37m`. That text reaches `/status` warnings, agent health checks and the UI. The lookup uses a short-lived Redis connection that never retries and is cached for 5 s. With Redis down it returned nothing in 8 ms and the process exited cleanly.
4. Tests:
   - `test/agentImageBuildLock.test.ts` covers: the owner is recorded; a gone container and a gone pid are both reclaimed; a live holder waits with repeated warnings; three racing reclaimers delete the dead holder once and never overlap; and shutdown aborts the build, releases the slot and refuses new builds.
   - The new `test/agentImageSlotStatusHint.test.ts` (added to `test:unit`) covers the status text.
5. Changelog: added to the existing 0.9.0 Fixed bullet "Agent image recovery".

Checks run
- `npm run lint --workspaces --if-present -- --max-warnings 0`: clean.
- Root lint and `tsc --noEmit`, plus core lint and typecheck: clean.
- The lock, preparation, registry, runtime-package and API health/status test files: 191 + 35 passed.
- `npm run test:unit`: 746 passed.
- `EXPECTED_VERSION=0.9.0 RELEASE_TAG=v0.9.0 RELEASE_CANDIDATE=true npm run release:verify`: passed.

Things to know
- The install in the report won't be unstuck by this. Its slot was written by the unpatched 0.9.0, so it has no owner recorded. A slot like that can't be proven dead, so it is still only freed when its lease expires. It does now show up in logs and status as "an unidentified holder (token …)". That install needs to wait out the lease or have `propr:agent-image-build:slot` deleted by hand.
- `src/worker.ts` was at the 400-line lint limit. To stay under it, I put the import on one line with `closeAgentImageBuildLock` and put `agentImageWatchdog.close(); await abortAgentImageBuildsForShutdown();` on a single line.
- This changes status text only, not the UI, so I made no visual preview.
@integry

integry commented Oct 29, 2026

Copy link
Copy Markdown
Owner

/review

@propr-dev

propr-dev Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @integry

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR adds owner-aware slot recovery, shutdown cancellation, and visible wait diagnostics. It needs a localized correction before merge: moving callback invocation outside the cleanup block introduces a lock leak on synchronous exceptions.

✅ Ownership-safe reclamation — Reclamation compares the exact stored value before deleting it, protecting a replacement holder from stale cleanup.

✅ Bounded status diagnostics — Status inspection uses a timeout, disables reconnect retries, and caches results without acquiring the slot.

✅ Targeted recovery coverage — Added tests cover dead owners, live holders, legacy tokens, concurrent reclaimers, and cooperative shutdown.

The supplied current-head checks report no failures. This review used static analysis only, as requested. The issue body is explicitly truncated, so assessment of its requirements is limited to the visible objective.

Merge blockers

Every finding below was introduced by this PR and must be resolved before merging.

F1: 🔴 Keep callback invocation inside cleanup

  • Required behavior: Every exit after successful acquisition must stop renewal and release the owned slot; callback failure must not leave an indefinitely renewed lock.

  • Evidence: packages/core/src/agents/agentImageBuildLock.ts:295, withAgentImageBuildSlot

    1. A caller of this exported function supplies a callback that throws synchronously—for example, validation throws before the callback returns its promise. Such a callback is permitted by the declared TypeScript signature.
    2. Acquisition succeeds and the renewal interval starts.
    3. work(shutdownController.signal) throws before execution enters try, and before the slot is added to activeSlots.
    4. The returned promise rejects, but the renewal interval remains active and the Redis slot remains held. While the process stays alive, renewal can prevent expiry indefinitely, blocking subsequent image preparation.

    The owner probe sees a live process, so it cannot reclaim this slot. Shutdown's active-slot tracking also misses it. The previous implementation invoked work() inside try, ensuring cleanup even for synchronous exceptions.

    static trace: verified against the supplied before/after implementation and exported callback contract. Existing supplied production callbacks are asynchronous; the regression affects the public wrapper's valid synchronous-throw path. proposed regression: invoke the wrapper with () => { throw new Error('validation failed'); }, assert rejection and immediate slot release, then verify another acquisition succeeds.

  • Minimum fix: Invoke the callback inside the protected cleanup scope, or defer it through Promise.resolve().then(() => work(signal)) so synchronous exceptions become tracked promise rejections. Ensure cleanup covers invocation and active-slot registration failures.

Suggestions

These are optional follow-ups and are not sent to /fix.

S1: 🟢 Exercise real shutdown and takeover

The new shutdown test uses a callback that rejects immediately on abort. A follow-up integration test with Redis and an actual Docker operation would strengthen confidence in the five-second fallback release, especially when cancellation settles slowly and another worker is waiting. This is optional validation; the supplied excerpts do not establish that an actual Docker build remains running after that fallback.

S2: 🟢 Clarify legacy recovery limits

Legacy bare-token holders are deliberately preserved until expiry, as the new test verifies. Qualifying the changelog would help operators understand that an already stranded slot from an older release can still require waiting. Preserving an unidentified holder is conservative and does not itself constitute a new regression.

Score

The implementation is focused and current checks pass, but the newly introduced cleanup regression needs correction before merge.

Score: 6/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 27s
  • Tokens: 53,955 (52,844 in / 1,111 out)
  • Cost: $0.47

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix F1 S1 S2

🤖 Review by ProPR

@integry

integry commented Oct 29, 2026

Copy link
Copy Markdown
Owner

/fix F1 S2

@propr-dev

propr-dev Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Applied the requested follow-up changes in commit 180a280

Addressed 1 AI review comment (IDs: 6491793210)

Addressed finding F1 · suggestion S2

Summary of Changes

I fixed F1 and S2. All checks pass, including npm run test:unit (747 passed, 0 failed).

F1: a callback that throws immediately no longer leaves the slot held (packages/core/src/agents/agentImageBuildLock.ts)

  • withAgentImageBuildSlot now calls work inside the try block, through Promise.resolve().then(...). An immediate throw becomes a rejected promise, and the finally cleanup handles it.
  • finally stops the renewal timer and releases the Redis slot on every exit after the slot is taken. It only removes the slot from the shutdown tracking set if it was added, so a failure before that point can't break cleanup.
  • Other callers: runAgentImageBuild, agentRuntimeImageBuilder.ts and dockerImageBuilder.ts all pass async callbacks, so they always returned a promise and weren't affected. The shutdown function abortAgentImageBuildsForShutdown only sees slots once they're tracked, which is the same as before.
  • New test in test/agentImageBuildLock.test.ts: a callback that throws validation failed immediately. It checks that the call rejects, the slot is released straight away, no renewal fires after one renewal interval, and a second build can then take the slot. I also ran this test against the previous version of the file, and it failed there as expected.

S2: changelog (CHANGELOG.md, the "Agent image recovery" entry)

  • It now says that a slot taken by an earlier release records no holder. Such a slot can't be proven abandoned, so it is only freed when its lease expires, up to 45 minutes. Status shows it as "an unidentified holder".
  • It also gives the manual fix: stop ProPR and delete the propr:agent-image-build:slot Redis key.

Other checks: core lint (zero warnings) plus core and root tsc --noEmit all pass, as do the 12 lock tests. These tests still use the fake Redis client, not a real Redis.

I found nothing else outside these two items. There's no visual change, so I made no preview.


🤖 Implementation Details

  • Model: Claude Opus 5.5 (medium)
  • Requested By: @integry
  • Time: 5m 41s
  • Tokens: 500,849 (496,303 in / 4,546 out)
  • Cost: $0.38

Undo Changes • View Task Execution


💡 ProPR Slash Commands
Command Description Example
/merge Merge target branch into this PR and resolve conflicts /merge
/review Request an AI code review /review or /review claude-sonnet
/fix Address review blockers, or blockers and suggestions with /fix all /fix, /fix all, or /fix F3 S5
/switch Change the AI model for this PR /switch claude-opus
/use Override the model for a single follow-up run /use claude-sonnet
/ultrafix Loop review→fix cycles until score goal is met /ultrafix or /ultrafix goal=8 max=10

🤖 Generated by ProPR
Processing comment ID: 6491802212✓

…:unit` (747 passed, 0 failed).

I fixed F1 and S2. All checks pass, including `npm run test:unit` (747 passed, 0 failed).

**F1: a callback that throws immediately no longer leaves the slot held** (`packages/core/src/agents/agentImageBuildLock.ts`)
- `withAgentImageBuildSlot` now calls `work` inside the `try` block, through `Promise.resolve().then(...)`. An immediate throw becomes a rejected promise, and the `finally` cleanup handles it.
- `finally` stops the renewal timer and releases the Redis slot on every exit after the slot is taken. It only removes the slot from the shutdown tracking set if it was added, so a failure before that point can't break cleanup.
- Other callers: `runAgentImageBuild`, `agentRuntimeImageBuilder.ts` and `dockerImageBuilder.ts` all pass `async` callbacks, so they always returned a promise and weren't affected. The shutdown function `abortAgentImageBuildsForShutdown` only sees slots once they're tracked, which is the same as before.
- New test in `test/agentImageBuildLock.test.ts`: a callback that throws `validation failed` immediately. It checks that the call rejects, the slot is released straight away, no renewal fires after one renewal interval, and a second build can then take the slot. I also ran this test against the previous version of the file, and it failed there as expected.

**S2: changelog** (`CHANGELOG.md`, the "Agent image recovery" entry)
- It now says that a slot taken by an earlier release records no holder. Such a slot can't be proven abandoned, so it is only freed when its lease expires, up to 45 minutes. Status shows it as "an unidentified holder".
- It also gives the manual fix: stop ProPR and delete the `propr:agent-image-build:slot` Redis key.

**Other checks:** core lint (zero warnings) plus core and root `tsc --noEmit` all pass, as do the 12 lock tests. These tests still use the fake Redis client, not a real Redis.

I found nothing else outside these two items. There's no visual change, so I made no preview.

PR: #3373
Comment by: @integry (ID: 6491802212)
Model: Claude Opus 5.5
@integry

integry commented Oct 29, 2026

Copy link
Copy Markdown
Owner

/review

@propr-dev

propr-dev Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ AI Code Review Complete requested by @integry

Posted 1 review:

View Task Details

@propr-dev

propr-dev Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

🔍 AI Code Review — codex:gpt-6-astra

Overall Evaluation

The PR adds owner-aware slot recovery, shutdown cancellation, and visible waiting diagnostics. The supplied code supports merging within the visible scope, conditional on pending checks completing successfully.

✅ Safe conditional reclamation — Recovery requires evidence that the recorded owner is gone and compares the exact stored value before deletion, protecting a replacement holder.

✅ Cleanup on callback failure — Deferred callback invocation keeps synchronous exceptions inside the cleanup path; the added regression test checks immediate release and stopped renewal.

✅ Bounded status diagnostics — Status inspection uses a timeout, disables reconnection retries, disconnects its Redis client, and caches results.

This was a static review; no commands or tests were run. The supplied issue body explicitly truncates its remaining requirements, so unseen acceptance criteria could not be assessed.

Merge blockers

No merge blockers.

Suggestions

These are optional follow-ups and are not sent to /fix.

S3: 🟢 Exercise actual shutdown teardown

Add an integration test using real Redis and Docker that interrupts preparation and verifies prompt reacquisition after restart. The unit tests establish cancellation and release with cooperative callbacks, but do not establish how actual Docker teardown interacts with the five-second forced-release deadline in abortAgentImageBuildsForShutdown. This is additional validation, not a demonstrated defect.

S4: 🟢 Cover shutdown acquisition interleaving

Consider a regression test that pauses the Redis SET NX response while shutdown begins, then completes acquisition. Shutdown snapshots only registered active slots, while an acquisition can still be awaiting Redis. The subsequent callback receives an already-aborted signal, which the changed Docker callers propagate; testing this ordering would strengthen coverage without implying a verified failure.

S5: 🟢 Broaden status hint coverage

Runtime-package image resolution still reports a plain unavailable-image error when the base image exists but its runtime image needs preparation. Extending the holder hint to that path would make diagnostics more consistent. This is an optional extension beyond the reproduced missing-bundle path.

Score

The implementation provides conservative recovery and useful focused tests, with no verified blocker in the supplied code. Current checks show no failures, with nine still pending; real stop/restart behavior remains untested in this review.

Score: 8/10


🤖 Review Details

  • Model: GPT-6 Astra
  • Time: 1m 1s
  • Tokens: 53,883 (53,172 in / 711 out)
  • Cost: $0.46

View Task


💡 Next step: Comment /fix to address every F# merge blocker, /fix all for every pending blocker and suggestion, or name records explicitly, as in /fix F3 S5.
F# and S# IDs increment across review comments and remain permanent, so selectors such as /fix F3 F5 stay unambiguous across cycles. S# suggestions stay optional: they are implemented only when you name them or request /fix all, and they never relax a merge blocker.

Copy and edit to choose records from this review:

/fix S3 S4 S5

🤖 Review by ProPR

@integry
integry merged commit b76607a into main Oct 29, 2026
42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant