Skip to content

fix(daemon): stop provider lease releases after shutdown closes the releaser - #4134

Merged
thymikee merged 4 commits into
mainfrom
fix/sm5-lease-expiry-closed
Nov 1, 2026
Merged

thymikee merged 4 commits into
mainfrom
fix/sm5-lease-expiry-closed

Conversation

@thymikee

@thymikee thymikee commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Summary

createExpiredProviderLeaseReleaser spread shutdown state over flags, timers and maps, so nothing owned the transition: a release outliving drain()'s budget survived shutdown() and plugin dispose, and its failure re-armed a provider call on a disposed runtime. The module now owns one phase — running / draining / closed — advanced only by advancePhase. closed starts no provider attempt, arms no timer, and admits no pending work: release() answers a typed outcome (released / retrying / not-required / refused + reason + durable), which both callers report. A recoverable lease still gets its record written for the next daemon; ADR 0007 records that boundary.

Validation

Lease suites, disposable checkout: base f7879cf2d2 6 failed | 17 passed; reviewed head 74e46b9eb2 5 failed | 18 passed, failing for the review's reason; head 45787dd057 green. Unit 16752 and daemon + host-kit 4334 pass. Gate check:affected --run --head 45787dd057: passed, exactCommit=true, receipt run-hPwNaG.

Live: daemon plus a BrowserStack session whose DELETE a proxy held past the 2s drain budget, then SIGTERM → one provider call, none in the 22s after close. That route's A/B needs limrun (API unresolvable) or revyl (no plugin), so it shows fixed behaviour only. Round 2 changed no device route: no new live run.

Open blocker, cause unproven: Apple smoke failed twice on stop screen recording / xcrun timed out after 30000ms (runs 41291091023, 41295528868 attempt 2); its first attempt failed elsewhere, and each scenario passed in the other attempt. No lease code there.

…eleaser

A provider release that outlived the drain budget kept a retry timer that called the
provider after its plugin had been disposed, and a lease that expired after shutdown
could still be released. The releaser now owns an explicit phase: running, draining,
closed. A closed releaser starts no provider attempt and arms no timer, and keeps the
recoverable lease durable so the next daemon recovers it.
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.67 MB 5.67 MB +1.4 kB
Package (unpacked) 5.67 MB 5.67 MB +1.4 kB
Package (download) 1.72 MB 1.72 MB +412 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 39.8 ms 40.8 ms +1.0 ms
CLI --help 87.9 ms 89.1 ms +1.2 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No issues found across 3 files

View guided diff | Turn on auto-fix | Re-trigger cubic

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Independent review of f7879cf2d266ee1dd476d0342b2819bdeceb647b..74e46b9eb2240f760c50ace8db48362790602b90 against the coordinator's shutdown contract.

Verdict: needs-changes.

[P2] Refuse release admission explicitly once closed — packages/daemon/src/server/provider-lease-expiry.ts:308-324 (closed guards at :260 and :275; return contract at :56).
After beginShutdown() → a budget-limited drain() → shutdown(), another release(lease) still inserts the lease into a pending map. The attempt helper then returns undefined because the phase is closed, and the public Promise<void> resolves normally. Thus this blocks provider execution but does not enforce the required admission boundary or return the required typed refusal. For a live-only provider such as BrowserStack, the new pending entry has no durable recovery record and cannot be processed; it also arrived after the drain snapshot. The new post-shutdown test exercises this path but accepts the void result. The production expiry callback (http/daemon-runtime.ts:381-383) discards the result, and http/daemon-lease-finalizer.ts:20-25,53-56 treats a resolved promise as completion of the bounded call. Add the closed admission branch to the public release boundary, retain the required durable handoff for recoverable leases, return a typed closed/deferred outcome, and handle that outcome in both callers instead of silently accepting the release. Add a regression assertion for the outcome and caller handling.

Other shutdown paths checked: scheduleRetry and journal retry cannot re-arm after close; the attempt-finish callback reaches the guarded scheduler; both attempt constructors refuse provider execution; recoverInherited/retryPending cannot bypass those constructors. A recoverable attempt is durable before execution, so a late failure retains its record. A late success still removes its pending entry and synchronously persists the remaining record. The normal runtime order remains valid: begin draining → session teardown/finalization → bounded drain → close releaser → shut down owners/plugins. The phase advances only through advancePhase and cannot move backward.

ADR sentence review (docs/adr/0007-remote-device-leases.md:95-96): the final journal write matches shutdown() when a journal retry is pending; the synchronous-write statement matches both persistence paths; the timer prohibition matches the closed guards. These additions preserve the surrounding recovery/durability claims. A narrow amendment is warranted to qualify the preceding unqualified promise that failed journal writes are retried; this owner-local fix does not warrant a new ADR. For the requested ASD-STE100 check, put the condition first in the first added sentence and split its two actions, for example: “If a journal retry is pending, daemon shutdown makes one final journal write. It does not schedule another journal retry.” This is a nonblocking wording correction.

Regression evidence: one focused Vitest invocation in a disposable checkout at base f7879cf2d266ee1dd476d0342b2819bdeceb647b, with the head's test file copied unchanged under an r4134- scratch name, after frozen installation and build: 3 failed, 13 passed. All three added tests fail for the intended reason: the late rejection produces a second provider call, direct post-close release calls the live provider, and post-close expiry calls recovery. Command: pnpm exec vitest run --project unit-core packages/daemon/src/server/__tests__/r4134-provider-lease-expiry.test.ts. No head test run or CI verdict is claimed. The disposable checkout was removed; the reviewed head and source status remain unchanged.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

The code looks good at 74e46b9, and I found no blocking problems. There are no conflicts. The failing check is the iOS phase of Smoke Tests, which looks unrelated: local iOS simulator runs allocate no lease provider, so the changed code runs on empty state there, and macOS smoke passed on the same shutdown path. Please re-run the iOS phase to confirm, and let Integration Tests and Coverage finish green.

Not blocking, and you can take or leave these: in provider-lease-expiry.ts, once the phase is closed, nothing that runs after an await should write lease records, so the two attempt continuations (:265-268 and :278-281) should skip persistPendingLeases when closed and leave the lease in the durable record for the next daemon, or a test with a held release that resolves after shutdown() should assert both record files stay unchanged; the ADR sentence about timers could also name this case. A test that makes the final shutdown journal write fail and then checks vi.getTimerCount() is 0 would cover the closed guard at :185. A live-only lease that reaches release() after close lands in pendingLiveLeases and is never released, so could it return early at :326 with one warn diagnostic? In the ADR, "If a journal retry is owed, daemon shutdown makes one final journal write. It schedules no further retry." fits the style better, with :94 qualified by "while the daemon runs".

I did not run the tests or the BrowserStack live run, and the CI excerpt did not include the iOS smoke log, so I could not read its failure cause.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Nov 1, 2026
@vercel

vercel Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agent-device-website Ready Ready Preview Nov 1, 2026 11:07am UTC

Request Review

A release that arrived after the releaser closed still admitted pending work, started no
provider attempt, and resolved as if it had completed, so both callers read it as a
release the provider had ended. `release` now answers with what it did: released,
retrying, not-required, or refused with its reason and whether a lease record names the
lease for the next daemon. A closed releaser refuses without admitting pending work, and
still writes the expired-lease record of a recoverable lease. The shutdown finalizer and
the lease-expiry callback report every refusal, so a device no daemon will end is named
in the daemon log instead of resolving quietly.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 10 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/daemon/src/server/http/daemon-runtime-lease-expiry.test.ts">

<violation number="1" location="packages/daemon/src/server/http/daemon-runtime-lease-expiry.test.ts:118">
P2: This test allocates the lease only after `runtime.close()` and manually dispatches expiry, so it does not verify an abandoned lease that existed before shutdown and expired across the shutdown boundary. Exercise the real pre-shutdown lease/race (or explicitly arrange the in-flight shutdown interleaving) so regressions in that lifecycle path cannot pass.</violation>

<violation number="2" location="packages/daemon/src/server/http/daemon-runtime-lease-expiry.test.ts:152">
P3: Remove this per-test `rmSync`; `mkdtempForTestSync` directories are removed by Vitest global teardown, and repository guidance specifically avoids deleting them from individual tests.

(Based on your team's feedback about Vitest temporary directory cleanup.)</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/adr/0007-remote-device-leases.md Resolved
@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Review findings addressed (this round: 74e46b9eb2 → 45787dd057, one push).

[P2] Refuse release admission explicitly once closed — accepted.

release now answers ProviderLeaseReleaseOutcome: released, retrying, not-required, or refused with a typed reason and durable.

  • provider-lease-expiry.ts: the closed branch sits at the public release boundary, before any pending-map admission. A closed release awaits nothing and tracks no attempt. For a recoverable lease it still writes the expired-lease record (pendingRecoveryLeases is that record's content), so the handoff stays durable and durable: true says so; a live-only lease refuses with durable: false. The two pre-existing silent returns (leaseProvider absent, no runtime for it) became not-required and refused / provider-runtime-unavailable, so no path resolves without saying what happened. When the releaser closes while a call is in flight, that release answers refused with reason: 'closed' instead of resolving quietly.
  • daemon-lease-finalizer.ts: no longer reads a resolved promise as completion. It emits the refusal per lease and returns { refused }, which daemon-runtime.ts reports as refusedLeaseIds in daemon_shutdown_provider_release_drain; the timeout path keeps its own event.
  • daemon-runtime.ts onLeaseExpired: the result is no longer discarded; a refusal goes to the daemon log (emitDaemonDiagnostic, given an optional level so a refusal keeps its severity — provider_lease_release_refused, error when no record names the lease, warn when it does).
  • leaseReleaseDiagnosticEvent owns that mapping, so one refusal cannot read differently per route.

Regression evidence, disposable checkout at base f7879cf2d2, three lease suites copied unchanged from head: 6 failed | 17 passed. At the reviewed head 74e46b9eb2: 5 failed | 18 passed, the new assertions failing for the finding's reason (no outcome, no report, no caller handling). At 45787dd057: green. Gate pnpm check:affected --run --head 45787dd057 — passed, exactCommit=true, receipt run-hPwNaG. No new live run: no provider or device route changed.

ADR 0007 wording — accepted. The added sentences are now "If a journal retry is pending, daemon shutdown makes one final journal write. It does not schedule another journal retry.", and the preceding promise is qualified ("… is retried until this daemon shuts down"). One further amendment records the admission boundary itself: a shut-down releaser refuses, still writes a recoverable lease's record, and an unrecorded refusal is logged with its lease provider.

New head: 45787dd057dbf98afe97de04f79450730c012714.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

The code at 45787dd looks right, and the earlier review's notes on the post-close release path are addressed. No conflicts are known. One thing remains on the code side, and it is not blocking.

Not blocking: the new finalizer test in packages/daemon/src/server/http/daemon-lease-finalizer.test.ts closes the releaser before it runs finalizeDaemonLeases, but production always finalizes first (daemon-runtime.ts:863-869) and shuts the releaser down after (:895). In production the refused list can only hold provider-runtime-unavailable leases, and no test drives that reason through the finalizer. You could use a lease whose leaseProvider has no runtime in providerRuntimeIds with the releaser still draining, then assert refused == [leaseId] and the provider-runtime-unavailable error row. Take it or leave it.

Smoke Tests fails in its iOS phase, which uses local simulators with no lease provider. On that route the diff only runs finalizeDaemonLeases over an empty registry and adds refusedLeaseIds: [] to the shutdown summary, and every new branch needs leaseProvider set (provider-lease-expiry.ts:360). The same job failed at 74e46b9 while macOS smoke passed on the same shutdown path. I did not read a failure log, so this rests on route overlap. I did not run the lease suites, the base comparison or pnpm check:affected, so the red-at-base and gate claims are unverified on my side.

On the Cubic threads, the summary-level one (#4134 (comment)) still applies, along with two lower-priority threads. Please resolve #4134 (comment) because a post-close release cannot coincide with a next daemon: the sweep is cleared at daemon-runtime.ts:838 and the registry is finalized at :863-869 before releaser.shutdown() at :895. Please also resolve #4134 (comment) because a lease that exists before shutdown cannot expire after close, since the finalizer releases every active lease.

Before merge, Smoke Tests needs to go green, so please re-run the iOS phase, and the three open Cubic threads need an answer.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Nov 1, 2026

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Round-2 review of 45787dd057dbf98afe97de04f79450730c012714:

The round-1 finding is resolved. A release after shutdown() returns { status: 'refused', reason: 'closed', durable } before admitting work; recoverable leases are written into expired-provider-leases.json, which recoverInherited() loads after daemon registration and retries. Refusals that cannot be persisted are logged with lease ID and provider; the shutdown summary includes refused lease IDs.

I found no other concrete regression. emitDaemonDiagnostic defaults to warn, preserving existing callers; the new severity is passed only for lease refusal events. Outcome and diagnostic shapes are typed, and the touched source stays within the daemon lease owner, its direct runtime/finalizer callers, and the host-kit diagnostic type export. ADR 0007's changed statements match the implementation and surrounding recovery claims; no meaning drift found. ASD-STE100 style is acceptable.

The reported Apple smoke timeout is not plausibly caused by this diff: it changes no recording or native video-validator path. Focused daemon tests were not run because this worktree has no vitest executable (pnpm vitest reported Command "vitest" not found).

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Round-2 independent review of 74e46b9eb2240f760c50ace8db48362790602b90..45787dd057dbf98afe97de04f79450730c012714.

Verdict: approve. The round-1 P2 is resolved. No blocking code findings.

  • Closed admission and recovery: provider-lease-expiry.ts:359-361 returns the typed closed refusal before ordinary release admission or provider execution. For recoverable leases, refuseClosedRelease uses the recovery map as record content, synchronously calls persistPendingLeases, and reports its actual success as durable (:329-334). This does not admit an executable attempt: both attempt constructors and the retry scheduler reject the closed phase. The expired record is atomically published by writeLeaseRecord (:523-535). On the next daemon, recoverInherited reads that same file into pendingRecoveryLeases, marks its ids persisted, adopts the live journal, and calls retryPending (:213-228); the recovery constructor invokes the provider recovery and removes the record entry only on success (:294-308). Runtime calls recovery after registration acquisition (daemon-runtime.ts:658,694-703). This establishes recovery across daemon process restart; it does not add an fsync/power-loss guarantee. A failed write returns durable: false, and the refusal diagnostic includes the lease provider.
  • Callers and typing: expiry consumes the outcome and writes its mapped severity to the daemon log; finalization emits the mapped refusal and returns refused ids for daemon_shutdown_provider_release_drain. ProviderLeaseReleaseOutcome is discriminated, refusal reasons are a literal union, and the diagnostic copies the typed reason and boolean without message parsing. These are internal outcomes and diagnostic data, so ADR 0010 does not require turning them into thrown wire AppErrors. All other emitDaemonDiagnostic callers still pass three arguments and retain the previous warn severity. The host-kit change only exports the existing DiagnosticLevel union. Scope remains the lease owner, its direct runtime/finalizer/diagnostic callers, associated tests, and ADR 0007.
  • ADR sentence comparison: qualifying failed journal retries with “until this daemon shuts down” matches the closed timer guard. The condition-first final-write sentence and separate no-retry sentence match shutdown() (:407-417) and address the prior ASD-STE100 note. The synchronous-write/timer statement is unchanged. The added recovery and unrecorded-refusal sentences match persistence and both caller paths, with write failure covered by the following conditional. The surrounding registration-before-read, recovery-without-live-restoration, and uncovered-allocation claims keep their meaning.
  • [P3, nonblocking] docs/adr/0007-remote-device-leases.md:97: qualify “every further release” as “every further provider-backed release”. A post-close release of a lease without leaseProvider returns not-required before the closed guard (provider-lease-expiry.ts:360-361). The current universal wording overstates the implemented boundary. The other changed sentences preserve the surrounding ADR claims and satisfy the requested condition-first/one-instruction-per-sentence style check.

The supplied Apple Smoke failure (record stop: xcrun timed out after 30000ms during cold Swift video-validator compilation), also seen on unrelated #4131, has no plausible causal path in this diff. It changes provider-lease outcomes, shutdown accounting, diagnostics, and prose; it changes neither video validation/compilation nor the xcrun timeout. I did not investigate that failure further or wait for CI.

Validation: static caller/recovery tracing and git diff --check; no tests run in this round. The reviewed checkout remains clean at 45787dd057dbf98afe97de04f79450730c012714.

…eases

A lease with no lease provider is answered before the closed guard, so the universal wording
overstated the boundary the releaser enforces.
@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

P3 accepted.

docs/adr/0007-remote-device-leases.md:97 now reads: "After the daemon shuts its lease releaser down, that releaser refuses every further provider-backed release." The rest of the sentence is unchanged, so the condition-first, one-instruction-per-sentence form is kept. A lease with no leaseProvider is answered not-required before the closed guard, which is the overstatement the finding named.

One-line ADR edit, so no full gate: pnpm format clean, pnpm check:doc-prose passed — 17/17 script tests and "doc prose: 30 files clean." git diff --check clean. No code, test, or other prose changed, and no device route touched, so no live run.

The push restarted CI at the new head, which re-runs Apple smoke on its own; that lane already had its one rerun at the previous head.

New head: 5a5671a33af25385e49cc005d1e11b4b619b8597.

@apex-sentinel-staging

Copy link
Copy Markdown

Apex Sentinel

Verdict: approve

8 finding(s): 2 minor, 6 nit. Verdict: approve. reached max rounds (1).

🟡 Minor (2)

Where Finding
packages/daemon/src/server/http/daemon-lease-finalizer.ts:87 releaseWithinTimeout conflates a resolved undefined with a timeout
packages/daemon/src/server/provider-lease-expiry.ts:329 Closed releaser still writes the shared lease records synchronously, in the same post-registration window the change closes for timers
🔵 Nit (6)
Where Finding
packages/daemon/src/server/http/daemon-lease-finalizer.ts:19 LeaseReleaseSettlement is a three-variant union where each variant re-declares lease: DeviceLease and carries its…
packages/daemon/src/server/http/daemon-runtime.ts:393 Duplicated provider-release refusal diagnostic with diverging severity
packages/daemon/src/server/http/daemon-runtime.ts:882 Shutdown summary omits leases the drain was refused after a timeout, so refused is reported only for the finalizer's share
packages/daemon/src/server/provider-lease-expiry.ts:337 releaseOutcomeFor's released claim rests on map absence, not on a provider confirmation
packages/daemon/src/server/provider-lease-expiry.ts:373 Every expired lease of an unregistered provider now logs an error and counts as refused, although no provider ever held the device
packages/daemon/src/server/provider-lease-expiry.ts:422 LeaseReleaseDiagnosticEvent.phase is typed string, so the one mapping the PR makes responsible for how every refu…

Reply under an inline comment with @apex-sentinel-staging note ... or @apex-sentinel-staging pivot ... to re-check that finding.
Comment @apex-sentinel-staging review on this conversation to run a full review.

@apex-sentinel-staging apex-sentinel-staging 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.

Apex Sentinel: 8 new findings on 5a5671a. The verdict and the full list are in the summary comment.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Thanks for the update. At 5a5671a I found nothing that needs to change before merge, and the docs wording from the earlier review is now fixed (ADR 0007 line 97 says "provider-backed release"). Checks show 19 passing and 0 failing at this commit, the earlier iOS smoke failure is gone, and there are no conflicts. I did not run the lease suites, a base comparison or check:affected, and I did not re-read the CI logs at this commit.

Not blocking, take or leave: the daemon_shutdown_provider_release_drain level at daemon-runtime.ts:883 still depends only on providerReleaseDrain.pending.length. A lease whose provider has no runtime is refused with provider-runtime-unavailable and never enters a pending map, so the summary logs at info while refusedLeaseIds is not empty. The summary level should reflect every lease this daemon did not end, so warn when pending.length > 0 || leaseFinalization.refused.length > 0, plus a test on the provider-runtime-unavailable route.

Of the open inline threads, the P2 on the summary level (#4134 (comment)) still applies, and so do two lower-priority nits. The P3 on the ADR wording (#4134 (comment)) is fixed. These do not apply, so you can resolve them: the P1 on the shutdown sweep (#4134 (comment)) because the sweep and registry are finalized before the releaser closes; the P2 on expiry after close (#4134 (comment)) because the finalizer releases every active lease first; #4134 (comment) because the only caller always gets an outcome object; #4134 (comment) because that write only runs for an abandoned request, which keeps the registration; #4134 (comment) because onLeaseExpired passes the same event level through; #4134 (comment) because a timed-out release stays pending and is reported; #4134 (comment) because removal from the pending map is the confirmation signal by contract; #4134 (comment) because the error row naming the provider is the intended operator signal.

Nothing blocks merge. Before merge, please reply on the summary-level thread: apply the warn change or say why info is intended.

@thymikee

thymikee commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

Thanks, that settles the summary-level thread at 5a5671a. Each refused lease already logs its own row at error when it is not durable, so the summary level is a diagnostics preference, and a follow-up is fine. With that, nothing in the code blocks this PR.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Nov 1, 2026
@thymikee
thymikee merged commit 9341d18 into main Nov 1, 2026
19 checks passed
@thymikee
thymikee deleted the fix/sm5-lease-expiry-closed branch November 1, 2026 14:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant