Skip to content

feat: repair a box startup whose job completion was lost - #1091

Merged
DorianZheng merged 3 commits into
mainfrom
fix/usage_failure_lt
Aug 10, 2026
Merged

feat: repair a box startup whose job completion was lost#1091
DorianZheng merged 3 commits into
mainfrom
fix/usage_failure_lt

Conversation

@ltstriker

@ltstriker ltstriker commented Jul 30, 2026

Copy link
Copy Markdown
Member

Summary

Repair boxes left in CREATING or STARTING when the runner starts the container successfully but its job-completion callback never reaches the API.

Persist lifecycle-scoped Container.Start evidence in BoxLite, use it to reconcile only the matching stalled startup job through the normal completion path, and prevent concurrent workers from claiming or finalizing the same job row from stale state.

Call graph

Startup recovery

Before (origin/main at 48b49977)

Executor.Execute
  (apps/runner/pkg/runner/v2/executor/executor.go:58)
  ├─ executeJob
  │    (executor.go:108)
  │    └─ createBox / startBox
  │         (apps/runner/pkg/runner/v2/executor/box.go:18,38)
  │         └─ Client.Create / Client.Start
  │              (apps/runner/pkg/boxlite/client.go:219,312)
  │              └─ Box.Start
  │                   (sdks/go/box_handle.go:43)
  │                   └─ … C ABI …
  │                        └─ BoxImpl::start
  │                             (src/boxlite/src/litebox/box_impl.rs:269)
  │                             └─ ensure_container_started
  │                                  (box_impl.rs:979)
  │                                  └─ Container.Start succeeds
  │                                       — no durable success evidence is recorded
  └─ updateJobStatus
       (executor.go:161)
       ← BUG: exhausted completion-callback retries can leave the API job IN_PROGRESS

BoxSyncService.PerformSync
  (apps/runner/pkg/services/box_sync.go:101)
  ├─ GetRemoteBoxStates
  │    (box_sync.go:65)
  │    └─ fetch only STARTED boxes
  │         ← BUG: excludes the stuck CREATING/STARTING box
  └─ SyncBoxState
       (box_sync.go:90)
       — never reached for an excluded box

BoxService.updateState
  (apps/api/src/box/services/box.service.ts:1259)
  ← BUG: rejects runner reports while the API box is CREATING or STARTING

After (035a13b2)

Executor.Execute
  (apps/runner/pkg/runner/v2/executor/executor.go:58)
  ├─ executeJob
  │    (executor.go:108)
  │    └─ createBox / startBox
  │         (apps/runner/pkg/runner/v2/executor/box.go:18,38)
  │         └─ Client.Create / Client.Start
  │              (apps/runner/pkg/boxlite/client.go:219,320)
  │              └─ Box.Start
  │                   (sdks/go/box_handle.go:43)
  │                   └─ … C ABI …
  │                        └─ BoxImpl::start
  │                             (src/boxlite/src/litebox/box_impl.rs:269)
  │                             └─ ensure_container_started
  │                                  (box_impl.rs:985)
  │                                  └─ record_started
  │                                       (box_impl.rs:1023)
  │                                       └─ mark_started + save_box
  │                                            (state.rs:362; manager.rs:157)
  │                                            — persists state, PID, and started_at together
  └─ updateJobStatus
       (executor.go:161)
       — remains the normal completion path

BoxSyncService.PerformSync
  (apps/runner/pkg/services/box_sync.go:170)
  ├─ GetLocalContainerStates
  │    (box_sync.go:57)
  │    └─ Client.ListInfo
  │         (apps/runner/pkg/boxlite/client.go:490)
  │         └─ Go Runtime.ListInfo
  │              (sdks/go/info.go:94)
  │              └─ … C ABI …
  │                   └─ RuntimeImpl::list_info
  │                        (src/boxlite/src/runtime/rt_impl.rs:636)
  │                        └─ BoxInfo::new
  │                             (src/boxlite/src/runtime/types.rs:421)
  │                             ↩ state and started_at return from one snapshot
  ├─ GetRemoteBoxStates
  │    (box_sync.go:95)
  │    └─ fetchRunnerBoxes
  │         (box_sync.go:129)
  │         — fetches STARTED plus CREATING/STARTING candidates
  ├─ canReport
  │    (box_sync.go:226)
  │    — a transitional box requires local STARTED and non-nil started_at
  └─ SyncBoxState
       (box_sync.go:159; only when canReport returns true)
       └─ BoxController.updateBoxState
            (apps/api/src/box/controllers/box.controller.ts:381)
            └─ BoxService.updateState
                 (apps/api/src/box/services/box.service.ts:1313)
                 ├─ findStalledStartupJob
                 │    (box.service.ts:582)
                 │    — matches the claimed CREATE_BOX or START_BOX job
                 └─ JobService.updateJobStatus
                      (apps/api/src/box/services/job.service.ts:232)
                      └─ JobStateHandlerService.handleJobCompletion
                           (job-state-handler.service.ts:37; after transaction commit)
                           └─ handleCreateBoxJobCompletion /
                              handleStartBoxJobCompletion
                                (job-state-handler.service.ts:84,126)
                                — publishes STARTED and releases the existing lock

Job-row concurrency

Before

JobService.pollJobs
  (apps/api/src/box/services/job.service.ts:118)
  └─ claimPendingJobs
       (job.service.ts:455)
       └─ Repository.save
            (job.service.ts:483)
            ← BUG: concurrent pollers can both claim the same PENDING row

JobService.updateJobStatus
  (job.service.ts:232)
  └─ findOne → validate → save
       (job.service.ts:238–264)
       ← BUG: concurrent terminal writers can validate stale state and overwrite it

After

JobService.pollJobs
  (apps/api/src/box/services/job.service.ts:118)
  └─ claimPendingJobs
       (job.service.ts:460)
       └─ UPDATE … WHERE status = PENDING RETURNING *
            (job.service.ts:493)
            — only the poller with affected = 1 receives the job

JobService.updateJobStatus
  (job.service.ts:232)
  └─ transaction
       └─ findOne(pessimistic_write) → validate → save
            (job.service.ts:238)
            — serializes terminal transitions before completion effects run

Fixes #

Changes

  • Persist started_at after a successful guest Container.Start in the same BoxLite state row as lifecycle state and PID.
    • Preserve it as evidence for the most recently ended lifecycle.
    • Clear it when a new lifecycle publishes a PID or recovery adopts a different shim PID.
  • Expose started_at through Rust BoxInfo, the C ABI, Go, Node.js, Python, and CLI inspection output.
    • REST-backed metadata leaves the field unset because the control plane does not provide this evidence.
  • Read the runner’s local state and startup evidence from one ListInfo snapshot.
  • Query CREATING and STARTING boxes as reconciliation candidates without disrupting the existing STARTED reconciliation path.
  • Report a transitional box as STARTED only when BoxLite reports both local STARTED state and durable start evidence.
  • After the configurable stall window—60 seconds by default—complete the matching in-progress CREATE_BOX or START_BOX job through the existing completion handler.
  • Claim pending jobs with a conditional UPDATE … WHERE status = PENDING RETURNING *.
  • Serialize job status transitions with a transaction and pessimistic row lock.
  • Add core, SDK, runner, API, CLI, compatibility, lifecycle, and concurrency coverage.
  • Update the SDK and CLI reference documentation for the new timestamp.

How to verify

make fmt:check
make lint
make test:unit:core
make test:unit:sdk
make test:apps

# Requires KVM on Linux or Hypervisor.framework on macOS
make test:integration:rust NEXTEST_FILTER_EXPR='binary(started_at)'

Risks / rollout

  • No database migration is required. started_at is optional and serde-defaulted in the existing BoxLite state row; legacy rows therefore load without evidence until a later successful start.

  • CBoxInfo gains a tail field. Existing field offsets remain unchanged, but native libraries and generated SDK bindings should be rebuilt and released together.

  • No strict API/runner deployment order is required:

    Runner Old API New API
    Old runner Existing reconciliation only Existing reconciliation only
    New runner Existing STARTED reconciliation remains; lost-callback confirmation is skipped Full startup repair
  • The default 60-second stall window delays recovery intentionally so it does not race a completion callback that is still progressing. It is configurable through BOX_SYNC_START_CONFIRMATION_STALL_SECONDS.

  • Any failure of the additional transitional-box query is treated as “confirmation unavailable this cycle.” This preserves ordinary reconciliation but delays startup recovery until a later successful cycle.

  • Recording started_at holds the BoxLite lifecycle-state lock while synchronously saving to SQLite, so database contention can delay other state readers.

  • The PostgreSQL conditional-claim and pessimistic-lock paths are covered by mock/stub unit tests in this PR, not by a real PostgreSQL integration test.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

BoxLite now records container start evidence and exposes it through SDKs. The runner uses this evidence during transitional-state synchronization. The API reconciles stalled startup jobs, while JobService uses transactional updates and atomic pending-job claims.

Changes

Lifecycle reconciliation

Layer / File(s) Summary
Container start evidence across BoxLite and SDKs
src/boxlite/..., sdks/c/..., sdks/go/..., sdks/node/..., sdks/python/..., src/cli/...
BoxLite records lifecycle start timestamps and preserves them across recovery. SDK metadata exposes the timestamp. Fixtures and tests cover lifecycle behavior and compatibility.
Runner evidence-based synchronization
apps/runner/pkg/boxlite/..., apps/runner/pkg/services/box_sync.*
BoxSyncService reads startup evidence, queries transitional remote states, and reports local states only when evidence permits it.
API stalled-start reconciliation
apps/api/src/box/services/..., apps/api/src/config/configuration.ts
BoxService finds stalled startup jobs and completes them when runners report STARTED. Transitional state filtering and constructor fixtures are updated.
Runner start-order invariant
apps/runner/pkg/boxlite/client.go, apps/runner/pkg/boxlite/create_invariant_test.go
Documentation and AST tests enforce that bx.Start is the final fallible operation in Client.Create.

Job atomicity

Layer / File(s) Summary
Transactional job status updates
apps/api/src/box/services/job.service.*
updateJobStatus uses a transaction and pessimistic write lock. Completion handling runs after commit.
Conditional pending-job claims
apps/api/src/box/services/job.service.*
claimPendingJobs uses a conditional PENDING status update and returns only jobs claimed by the current poll.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BoxLite
  participant BoxSyncService
  participant BoxService
  participant JobService
  BoxLite->>BoxSyncService: provide local state and start evidence
  BoxSyncService->>BoxService: report transitional or started state
  BoxService->>JobService: complete stalled startup job
  JobService-->>BoxService: commit job update
Loading

Possibly related PRs

Suggested labels: e2e-local

Suggested reviewers: dorianzheng, copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: repairing box startup when job completion is lost.
Description check ✅ Passed The description includes all required sections and clearly explains the startup-repair and concurrency changes, though the issue placeholder remains unresolved.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/usage_failure_lt
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/usage_failure_lt

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (2)
src/boxlite/src/runtime/layout.rs (1)

31-37: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider an atomic write (temp file + rename) for the started record.

write() truncates then writes in a separate step, so a reader (the runner's box_sync poll) racing a lifecycle's record replacement could momentarily see an empty/partial file. Given readStartedRecord on the Go side treats any parse failure as "no evidence" and just retries next cycle, this self-heals — but a temp-write + rename would remove the window entirely for negligible cost.

♻️ Proposed fix
     pub fn write(&self, path: &Path) -> std::io::Result<()> {
         let json = serde_json::to_string(self).expect("two scalar fields are infallible");
-        std::fs::write(path, json)
+        let tmp = path.with_extension("tmp");
+        std::fs::write(&tmp, json)?;
+        std::fs::rename(&tmp, path)
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/boxlite/src/runtime/layout.rs` around lines 31 - 37, Update
StartedRecord::write to write the serialized record to a temporary file in the
same directory, then atomically rename it over the target path. Preserve the
existing serialization and std::io::Result behavior while ensuring readers never
observe a truncated or partially written record.
apps/api/src/box/services/box.service.ts (1)

576-596: 🚀 Performance & Scalability | 🔵 Trivial

Confirm indexing supports this lookup pattern.

findStalledStartupJob queries on runnerId + resourceType + resourceId + type + status ordered by createdAt, and can run once per stalled-box sync tick across many runners. If the jobs table doesn't already have a composite index covering these columns, this could become a sequential-scan hot spot as job history grows. Worth a quick check if it isn't already indexed for existing job queries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/box/services/box.service.ts` around lines 576 - 596, Verify that
the jobs table has a composite index supporting the predicates used by
findStalledStartupJob—runnerId, resourceType, resourceId, type, and status—with
createdAt included for the descending ordering. If no suitable existing index
covers this lookup, add the minimal database/entity index needed and update the
migration accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/api/src/box/services/box.service.ts`:
- Around line 576-596: Verify that the jobs table has a composite index
supporting the predicates used by findStalledStartupJob—runnerId, resourceType,
resourceId, type, and status—with createdAt included for the descending
ordering. If no suitable existing index covers this lookup, add the minimal
database/entity index needed and update the migration accordingly.

In `@src/boxlite/src/runtime/layout.rs`:
- Around line 31-37: Update StartedRecord::write to write the serialized record
to a temporary file in the same directory, then atomically rename it over the
target path. Preserve the existing serialization and std::io::Result behavior
while ensuring readers never observe a truncated or partially written record.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 47e29eea-8029-425c-ab87-15caa3e2e25f

📥 Commits

Reviewing files that changed from the base of the PR and between 8d58793 and 8be89b3.

📒 Files selected for processing (14)
  • apps/api/src/box/services/box.service.spec.ts
  • apps/api/src/box/services/box.service.start-reconciliation.spec.ts
  • apps/api/src/box/services/box.service.ts
  • apps/api/src/box/services/job.service.claim.spec.ts
  • apps/api/src/box/services/job.service.transaction.spec.ts
  • apps/api/src/box/services/job.service.ts
  • apps/api/src/config/configuration.ts
  • apps/runner/pkg/boxlite/client.go
  • apps/runner/pkg/boxlite/create_invariant_test.go
  • apps/runner/pkg/services/box_sync.go
  • apps/runner/pkg/services/box_sync_test.go
  • src/boxlite/src/litebox/box_impl.rs
  • src/boxlite/src/runtime/layout.rs
  • src/boxlite/tests/container_start_record.rs

@ltstriker
ltstriker marked this pull request as ready for review July 31, 2026 05:56
@ltstriker
ltstriker requested a review from a team as a code owner July 31, 2026 05:56
@boxlite-agent

boxlite-agent Bot commented Jul 31, 2026

Copy link
Copy Markdown

📦 BoxLite review — couldn't complete

claude exited 1

stdout:
{"is_error":true,"duration_api_ms":0,"num_turns":1,"stop_reason":"stop_sequence","session_id":"52ed04f5-1213-4373-949b-14b15287ac31","total_cost_usd":0,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subtype":"success","api_error_status":403,"result":"Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access","type":"result","duration_ms":256,"uuid":"6289e0be-14c7-4568-9161-af20550df249"}

stderr:
<empty>

powered by BoxLite

Comment thread src/boxlite/src/runtime/layout.rs Outdated
@ltstriker

Copy link
Copy Markdown
Member Author

Follow-up stacked on this branch: #1104fix/usage_failure_lt_v2.0fix/usage_failure_lt.

It fixes usage-period drift (the daily roll-over copying stale resources forward, and boxes that never got a period at all). The dependency is branch-level only: it touches six files under apps/api/src/usage/, disjoint from the fourteen here, and calls nothing this PR adds. Merge this one first and GitHub will retarget #1104 to main.

@DorianZheng

Copy link
Copy Markdown
Member

e2e, simplified

Before

runner   bx.Start ✓  (box is now genuinely running)
         └─ POST /job/{id}/status COMPLETED ──✗ lost
                                              (4xx → non-retryable, gives up at once;
                                               or retry budget exhausted)

API      job stays IN_PROGRESS
         boxInfo → inferStateFromJob → CREATING / STARTING
                   ↑ state is *derived from the job*, so it can never read STARTED
         box-start.action → checkTimeoutError: 5 min (STARTING) / 15 min (CREATING)
         └─ box = ERROR, recoverable: false      ← a healthy, running box marked broken

After

runner   bx.Start ✓
         ├─ writes {box_dir}/started {pid}
         └─ POST … COMPLETED ──✗ lost

         BoxSync (every 10s): box is CREATING/STARTING
                              AND started-record pid == live shim pid
         └─ PUT /box/{id}/state STARTED

API      startup job claimed and stalled ≥ 60s?
         └─ complete the job → handleJobCompletion → box = STARTED ✓

Two notes on the framing, from tracing the pre-PR paths:

  • "stays in CREATING or STARTING forever" doesn't match main. The box does leave the transitional state — box-start.action.ts:141/151 ERRORs it after 5 min (STARTING) or 15 min (CREATING), and handleStaleJobs independently fails the job at 10 min. So the bug being fixed is resolves to the wrong answer (a running box marked ERROR, recoverable: false), not never resolves. That's arguably the stronger motivation, since it can tear down a working VM.
  • One lost-callback case already self-heals: if the runner restarts, poller.go:53-66 replays its IN_PROGRESS jobs before the poll loop, and CREATE_BOX is idempotent via GetOrCreate. The gap this PR actually closes is the runner keeps running case — that startup replay never fires again, so nothing revisits the job.

Neither changes the verdict on the mechanism; they'd just make the problem statement match what main does.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment thread src/boxlite/src/litebox/state.rs Outdated

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/boxlite/src/litebox/box_impl.rs (1)

1004-1044: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

record_container_started can lose a concurrent state update.

record_container_started clones BoxState under a write lock, releases the lock, then calls save_box with the stale clone. If a concurrent operation (for example stop(), triggered by a fast health-check failure or explicit cancellation) acquires the lock, mutates state, and persists it first, this function's later save_box call overwrites the database with the older clone. That reverts the concurrent update (status, exit_code, health_status) and pairs container_started_at with a state row a concurrent writer had already superseded — breaking the documented invariant that the timestamp and PID "are written and read as one row" (see state.rs lines 244-248).

stop() and init_live_state() avoid this by holding the write-lock guard through their save_box call. record_container_started should do the same.

🔒 Proposed fix: hold the lock through the save
     fn record_container_started(&self) {
-        let snapshot = {
-            let mut state = self.state.write();
-            state.mark_container_started();
-            state.clone()
-        };
-
-        if let Err(error) = self
-            .runtime
-            .box_manager
-            .save_box(&self.config.id, &snapshot)
-        {
-            tracing::error!(
-                box_id = %self.config.id,
-                pid = ?snapshot.pid,
-                error = %error,
-                "Container.Start succeeded but the start could not be recorded"
-            );
-        }
+        let mut state = self.state.write();
+        state.mark_container_started();
+
+        if let Err(error) = self.runtime.box_manager.save_box(&self.config.id, &state) {
+            tracing::error!(
+                box_id = %self.config.id,
+                pid = ?state.pid,
+                error = %error,
+                "Container.Start succeeded but the start could not be recorded"
+            );
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/boxlite/src/litebox/box_impl.rs` around lines 1004 - 1044, Update
record_container_started to retain the state write-lock guard through the
save_box call, rather than cloning state and releasing the lock before
persistence. Pass the guarded current state directly to save_box while
preserving the existing error logging and mark_container_started behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/boxlite/src/litebox/box_impl.rs`:
- Around line 1004-1044: Update record_container_started to retain the state
write-lock guard through the save_box call, rather than cloning state and
releasing the lock before persistence. Pass the guarded current state directly
to save_box while preserving the existing error logging and
mark_container_started behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bd5a87d7-e2aa-4d42-a254-dad1ac207244

📥 Commits

Reviewing files that changed from the base of the PR and between 8be89b3 and ad1314a.

📒 Files selected for processing (14)
  • apps/runner/pkg/boxlite/client.go
  • apps/runner/pkg/boxlite/create_invariant_test.go
  • apps/runner/pkg/services/box_sync.go
  • apps/runner/pkg/services/box_sync_test.go
  • sdks/c/include/boxlite.h
  • sdks/c/src/event_queue.rs
  • sdks/c/src/info.rs
  • sdks/go/info.go
  • src/boxlite/src/litebox/box_impl.rs
  • src/boxlite/src/litebox/state.rs
  • src/boxlite/src/rest/types.rs
  • src/boxlite/src/runtime/rt_impl.rs
  • src/boxlite/src/runtime/types.rs
  • src/boxlite/tests/container_started_at.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/runner/pkg/boxlite/create_invariant_test.go

Comment thread src/boxlite/src/litebox/state.rs
Comment thread sdks/c/include/boxlite.h Outdated
Comment thread apps/api/src/box/services/job.service.ts
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt branch from ad1314a to 85bafad Compare July 31, 2026 10:17

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/runner/pkg/boxlite/create_invariant_test.go (1)

37-67: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Traverse the enclosing block after the exact bx.Start call.

containsCall recursively finds bx.Start, so Line 39 matches the outer if !skipStart statement. The slice at Line 48 starts after that outer if. It therefore skips any fallible operation or non-nil return added later in the same block. Client.Create currently uses this exact nesting in apps/runner/pkg/boxlite/client.go Lines 219-317, so the guard does not enforce its stated invariant. Find the enclosing block and exact call position, then inspect post-start control-flow paths while exempting only the return that handles bx.Start itself. Add a regression case for a post-start failure inside the same block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/runner/pkg/boxlite/create_invariant_test.go` around lines 37 - 67,
Update the invariant test around the Client.Create AST traversal: locate the
exact bx.Start call and its enclosing block rather than matching the outer
statement containing it, then inspect all subsequent control-flow paths in that
block for non-nil error returns while exempting only bx.Start’s own failure
return. Add a regression case covering a fallible operation and failure return
after bx.Start within the same block.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sdks/c/include/boxlite.h`:
- Around line 319-323: Move the started_at_unix_ms member in the public CBoxInfo
declaration to after the existing network member so all pre-existing member
offsets remain unchanged. Keep the Rust CBoxInfo order consistent with the
updated header, and only introduce an explicit ABI version break if preserving
the layout is impossible.

---

Outside diff comments:
In `@apps/runner/pkg/boxlite/create_invariant_test.go`:
- Around line 37-67: Update the invariant test around the Client.Create AST
traversal: locate the exact bx.Start call and its enclosing block rather than
matching the outer statement containing it, then inspect all subsequent
control-flow paths in that block for non-nil error returns while exempting only
bx.Start’s own failure return. Add a regression case covering a fallible
operation and failure return after bx.Start within the same block.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 103e95fe-a09c-4ee2-a232-874306c76c2c

📥 Commits

Reviewing files that changed from the base of the PR and between ad1314a and 85bafad.

⛔ Files ignored due to path filters (1)
  • apps/go.work.sum is excluded by !**/*.sum
📒 Files selected for processing (14)
  • apps/runner/pkg/boxlite/client.go
  • apps/runner/pkg/boxlite/create_invariant_test.go
  • apps/runner/pkg/services/box_sync.go
  • apps/runner/pkg/services/box_sync_test.go
  • sdks/c/include/boxlite.h
  • sdks/c/src/event_queue.rs
  • sdks/c/src/info.rs
  • sdks/go/info.go
  • src/boxlite/src/litebox/box_impl.rs
  • src/boxlite/src/litebox/state.rs
  • src/boxlite/src/rest/types.rs
  • src/boxlite/src/runtime/rt_impl.rs
  • src/boxlite/src/runtime/types.rs
  • src/boxlite/tests/started_at.rs
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/boxlite/src/runtime/rt_impl.rs
  • src/boxlite/src/rest/types.rs
  • apps/runner/pkg/boxlite/client.go
  • src/boxlite/src/litebox/box_impl.rs
  • sdks/c/src/event_queue.rs
  • apps/runner/pkg/services/box_sync.go
  • sdks/c/src/info.rs

Comment thread sdks/c/include/boxlite.h Outdated
@DorianZheng

Copy link
Copy Markdown
Member

boxStateReader: the second method can go — and dropping it closes a hole

Line numbers are 85bafad (post-refactor).

Today

PerformSync                                   box_sync.go:176
└─ GetLocalContainerStates                    box_sync.go:57
   ├─ ListInfo(ctx)                           box_sync.go:58   → 1 FFI call
   │    returns []BoxInfo{ ID, Name, State, PID, StartedAt }   ← State is already here
   │
   └─ for each box:                           box_sync.go:64
      ├─ GetBoxState(ctx, boxId)              box_sync.go:70   → +1 FFI call per box
      │    └─ client.go:372
      │        ├─ getOrFetchBox → runtime.Get
      │        ├─ bx.Info(ctx)                ← re-fetches the State we already had
      │        └─ switch info.State → enums.BoxState   (pure mapping, no I/O)
      │
      └─ boxStartedAt(box)                    box_sync.go:78,93  ← taken from the ListInfo snapshot

So state and startedAt are read at two different moments, then combined in one decision:

canReport   box_sync.go:231
└─ return local.state == enums.BoxStateStarted && local.startedAt != nil     :234
              ↑ from GetBoxState, at T1        ↑ from ListInfo, at T0

Why that matters now specifically. The refactor's own comment (box_sync.go:88-91) says the reader "has nothing to cross-check", because BoxLite voids StartedAt in the same write that publishes a new lifecycle's PID. That holds — but only for values read together. Since state comes from a later, separate call, there's still a window: read StartedAt at T0, the box restarts, then GetBoxState at T1 reports the new lifecycle as running. Stale evidence + fresh state ⇒ canReport says yes for a lifecycle whose init hasn't launched. The old on-disk version caught this with the PID cross-check; the new one relies on atomicity that the split read gives away.

Suggested change — take state from the same BoxInfo:

GetLocalContainerStates
└─ ListInfo(ctx)                        1 FFI call, total
   └─ for each box:
      ├─ boxStateFromInfo(box.State)    pure func, no I/O
      └─ boxStartedAt(box)              same snapshot ⇒ atomic by construction

which reduces the interface to the one method that actually needs the runtime:

type boxStateReader interface {
    ListInfo(ctx context.Context) ([]sdkboxlite.BoxInfo, error)
}

Three things fall out: 1+N FFI round-trips become 1; (state, StartedAt, PID) become one consistent snapshot, so the atomicity the comment claims is real end-to-end; and the GetBoxState error branch at :71-74 disappears, along with the case where a box that vanished mid-loop is silently skipped.

Client.GetBoxState itself stays — services/box.go:28 and the backend.Backend interface (backend.go:27) still use it. Only this call site goes.

One judgement call if you take this: client.go:372's switch maps configured/running/stopped and sends everything else to Unknown, so StateStopping (sdks/go/info.go:20) currently lands on Unknown even though enums.BoxStateStopping exists. Mirroring that exactly keeps this a pure refactor; fixing it is a separate change.

The narrow interface itself is right — *blclient.Client needs a live libkrun runtime, so without it PerformSync would only be reachable from a VM-booting test. This is just about making it narrower.

@DorianZheng

Copy link
Copy Markdown
Member

started_at: three clear-sites, two different meanings

Line numbers are 85bafad.

who writes it
  mark_started              state.rs:356          set          ← only writer
  └─ record_started         box_impl.rs:1019      after Container.Start

who clears it
  adopt_recovered_shim      state.rs:370-373      if pid changed
  init_live_state           box_impl.rs:1157-1159 if !adopting_running
  reset_for_reboot          state.rs:454          always

who nulls the pid but keeps it
  mark_stop                 state.rs:422-426      pid = None, started_at kept
  mark_failed               state.rs:436-441      pid = None, started_at kept

1. The doc and the code disagree. state.rs:243-247 says:

the value belongs to the PID in the same row. It is cleared in the one write that publishes a new lifecycle's PID … and in the one recovery branch …

Two sites claimed, three exist — reset_for_reboot is neither of the two named. And after mark_stop / mark_failed there is no PID in the row for it to belong to: pid: None, started_at: Some.

2. Two models are shipping at once. mark_stop/mark_failed keep the timestamp (docker's StartedAt, which the doc explicitly cites — "Survives a stop the way docker keeps StartedAt on an exited container"). reset_for_reboot clears it (liveness evidence). Same event in both cases: this lifecycle's process is gone.

Simplest fix: keep the docker model and delete the reset_for_reboot clear. It is already redundant — a rebooted box is Stopped, so canReport's state == Started fails anyway, and the next boot voids via init_live_state. Whichever way it goes, one of the two behaviours should change so the field has one meaning.

3. One rule, three hand-maintained copies. All three clears are the same predicate — pid changed ⇒ the timestamp is stale. It can live in the setter instead:

pub fn set_pid(&mut self, pid: Option<u32>) {
    if self.pid != pid {
        self.started_at = None;   // it described the lifecycle we're replacing
    }
    self.pid = pid;
    self.last_updated = Utc::now();
}

Then adopt_recovered_shim is just set_pid + set_status (the conditional and most of its comment go), and box_impl.rs:1151-1159 — 9 lines of comment + guard — deletes entirely, because init_live_state already calls set_pid five lines above at :1144, with exactly the right semantics: adopting-running passes the same PID (no clear), a fresh shim passes a new one (clear).

This is safe to centralise: every raw state.pid = … outside state.rs is inside #[cfg(test)] (box_impl.rs:1591/1845, rt_impl.rs:1971; those test modules start at :1481 / :1822). Production only goes through set_pid / adopt_recovered_shim.

If you want the invariant enforced by the compiler rather than by convention, the bigger version is to make the pair one value — Option<Lifecycle { pid, started_at }> — so "belongs to the PID in the same row" stops being a comment. That has serde/persisted-row implications, so probably a follow-up rather than this PR.


Two things I looked at that are not problems, for the record:

  • The repeated PerTestBoxHome + BoxliteRuntime::new setup in tests/started_at.rs matches 20 other test files — house style, not worth changing here.
  • record_started cloning BoxState to drop the lock before save_box (box_impl.rs:1025-1033) deviates from box_impl.rs:1277-1279 and :1294-1296, which hold the write lock across the disk write — but it deviates in the safer direction, at the cost of one clone per successful start. Worth keeping; the older sites are the ones out of step.

@DorianZheng

Copy link
Copy Markdown
Member

Correction to my previous comment — §3 (the set_pid consolidation) is wrong

Apologies, I need to retract the main suggestion in my last comment. I claimed moving the clear into set_pid would be behaviour-preserving because "adopting-running passes the same PID (no clear), a fresh shim passes a new one (clear)". That is not guaranteed, and the refactor as I described it would break the case your comment deliberately protects.

The two values come from different sources:

adopting_running  box_impl.rs:1076   = (state.status == BoxStatus::Running)   ← the row's STATUS
pid               box_impl.rs:1140-41 = PidFileReader::at(&pid_path).read()   ← the PID FILE

They are not tied together, and the codebase says outright that they can disagree — litebox/init/tasks/vmm_attach.rs:6-9:

Identity is read from the canonical PID file (shim.pid) and verified via start-time fingerprint. state.pid from the DB is a cache that could lag (PID reuse, external kill); the file + ProcessIdentity is the trust anchor.

So a box can be Running (⇒ adopting_running == true, and today started_at is correctly kept) while the row's cached pid lags behind the PID file. Under my proposed set_pid, that mismatch would clear started_at — exactly what box_impl.rs:1154-1156 says must not happen:

Adopting a running box is not a new lifecycle — its record still names the live shim and must survive.

state.rs:363-369 makes the same point for the recovery path. So the current explicit if !adopting_running guard is keyed on the right thing and my "deletes entirely" was wrong. Sorry for the noise.

I also had two citation errors in that comment: record_started is at box_impl.rs:1023, not :1019; and set_pid is 13 lines / 3 statements above the guard, not "five lines".

One thing the correction surfaces that is worth keeping: I only audited raw state.pid = … writes, not production set_pid callers. There is a fourth — rt_impl.rs:950 (remove_box, force path) calls set_pid(None) then save_box at :951 — which any setter-based rule would also silently change. That reinforces the conclusion: the rule genuinely does not belong in set_pid.


§1 and §2 of that comment still stand — I re-verified them:

  • state.rs:243-247 claims two clear-sites; there are three, and reset_for_reboot (state.rs:454) is neither of the two named.
  • mark_stop (state.rs:422-426) and mark_failed (state.rs:436-441) leave pid: None, started_at: Some, so the stated invariant "the value belongs to the PID in the same row" does not hold after either.
  • Two models are still shipping at once: docker's survives-death (state.rs:241-242, "Survives a stop the way docker keeps StartedAt on an exited container") and liveness-evidence (reset_for_reboot). Picking one — and dropping the now-redundant reset_for_reboot clear — is still the suggestion.

The Option<Lifecycle { pid, started_at }> idea also still stands, and the PID-file/row divergence above is actually an argument for it: it makes the pairing explicit at the one place the two values are reconciled, instead of leaving it to a status flag and a cached field agreeing.

@DorianZheng

Copy link
Copy Markdown
Member

Blocker: 85bafad does not compile under --all-targets

$ BOXLITE_DEPS_STUB=1 cargo check -p boxlite-cli --all-targets

error[E0063]: missing field `started_at` in initializer of `boxlite::BoxInfo`
   --> src/cli/src/commands/inspect.rs:237:20
    |
237 |         let info = BoxInfo {
    |                    ^^^^^^^ missing `started_at`

error: could not compile `boxlite-cli` (bin "boxlite" test) due to 1 previous error

BoxInfo (runtime/types.rs:349) is #[derive(Debug, Clone, Serialize, Deserialize)] — no Default, no #[non_exhaustive] — so adding a required field breaks every exhaustive struct literal. The #[serde(default)] added alongside it only affects deserialisation; it does nothing for struct literals.

Three literals were missed. All are exhaustive, none uses a .. escape, none mentions started_at:

file:line
src/cli/src/commands/inspect.rs:237 confirmed by the compile above
sdks/python/src/info.rs:374 fn core_info(...) -> BoxInfo
sdks/node/src/info.rs:259 fn core_info(...) -> BoxInfo

All three are #[cfg(test)], which is why a plain cargo build stays green — but make/quality.mk:207,210 runs

cargo clippy --workspace --all-targets --all-features -- -D warnings

and --all-targets compiles test code. The Rust CI jobs had not reported on this head when I checked (only CodeQL and the review bots had), so this is very likely to go red once they do.

Fix is mechanical — started_at: None in each. But greening CI that way leaves the more interesting gap, which is why I'd raise it in the same breath:

PyBoxInfo (sdks/python/src/info.rs:277-303) and JsBoxInfo (sdks/node/src/info.rs:172-213) are separate binding structs, so they compile fine and simply drop the new field — the Python and Node SDKs silently do not expose started_at. Same for src/cli/src/commands/inspect.rs:46-57 InspectStatePresenter, which is deliberately docker-shaped (Status/Running/Pid/ExitCode) — and docker's own State is {Status, Running, Pid, ExitCode, StartedAt, FinishedAt}, so StartedAt is precisely the missing member of the set it is otherwise mirroring. BoxStateInfo (runtime/types.rs:470-482), which both bindings expose as .state, is unextended too.

So the compile error is really flagging three places that each need a decision (expose it, or deliberately don't), not three places that need None pasted in.

@DorianZheng

Copy link
Copy Markdown
Member

Two fixes to the comment above. The compile blocker itself is unaffected — I re-ran all three checks and they reproduce.

  • BoxStateInfo is at runtime/types.rs:484-496, not :470-482. I quoted main's line numbers; the 14-line started_at insertion shifts everything below it, so :470-482 at this head is the tail of impl PartialEq for BoxInfo. The point stands — BoxStateInfo is {status, running, pid, exit_code} and unextended.

  • I overstated docker's State. I wrote it as {Status, Running, Pid, ExitCode, StartedAt, FinishedAt}; that is a subset, not the whole set. Actual (docker 29.4.3, docker inspect --format '{{json .State}}'): Dead, Error, ExitCode, FinishedAt, OOMKilled, Paused, Pid, Restarting, Running, StartedAt, Status. The argument only needs the weaker true form — docker's State does carry StartedAt alongside ExitCode, so StartedAt is the member InspectStatePresenter is missing from the set it is otherwise mirroring.

Copilot AI lite review requested due to automatic review settings August 3, 2026 07:48
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt branch from 85bafad to e0469a6 Compare August 3, 2026 07:48

Copilot AI 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.

Pull request overview

This PR adds a durable “container actually started” timestamp (started_at) to BoxLite’s BoxInfo and wires it through the runner sync + API so a box stuck in CREATING/STARTING (due to a lost job-completion callback) can be safely reconciled to STARTED. It also hardens job claiming/status updates to prevent concurrent writers from racing on the same Job row.

Changes:

  • Add and persist started_at as evidence that Container.Start succeeded for the current lifecycle (and expose it across CLI + SDKs).
  • Extend runner BoxSync to fetch transitional boxes and only report STARTED when local state is STARTED and started_at is present; add tests and an invariant guard around Client.Create.
  • Make API Job updates transactional with row locks and claim pending jobs via conditional UPDATE; allow API-side reconciliation for stalled startup jobs.

Reviewed changes

Copilot reviewed 27 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/cli/src/commands/inspect.rs Updates CLI inspect test fixture to include started_at.
src/boxlite/tests/started_at.rs New integration tests asserting started_at lifecycle semantics.
src/boxlite/src/runtime/types.rs Adds started_at to BoxInfo and wires it from state.
src/boxlite/src/runtime/rt_impl.rs Uses adopt_recovered_shim to keep PID/started_at consistent on recovery.
src/boxlite/src/rest/types.rs Ensures REST conversion sets started_at to None (not known remotely).
src/boxlite/src/litebox/state.rs Adds started_at to persisted BoxState plus invariants and unit tests.
src/boxlite/src/litebox/box_impl.rs Records started_at after successful Container.Start and guards invariants.
sdks/python/src/info.rs Updates Python SDK test fixture to include started_at.
sdks/node/src/info.rs Updates Node SDK test fixture to include started_at.
sdks/go/info.go Adds StartedAt to Go SDK and converts from C milliseconds.
sdks/c/tests/test_info.c Extends C test expectations + ABI layout assert for started_at.
sdks/c/src/info.rs Adds started_at to C FFI struct and populates it.
sdks/c/src/event_queue.rs Updates FFI leak tests to include new fields.
sdks/c/include/boxlite.h Exposes started_at in the public C header.
apps/runner/pkg/services/box_sync.go Reads started_at from ListInfo, fetches transitional boxes, gates reporting.
apps/runner/pkg/services/box_sync_test.go New tests for started-at conversion and transitional reconciliation behavior.
apps/runner/pkg/boxlite/create_invariant_test.go New AST-based guard ensuring no fallible steps after bx.Start in Create.
apps/runner/pkg/boxlite/client.go Documents invariant and exports ToBoxState for single-snapshot reads.
apps/runner/pkg/boxlite/box_state_test.go New unit tests for ToBoxState mapping behavior.
apps/go.work.sum Updates Go workspace sums for new/updated dependencies.
apps/api/src/config/configuration.ts Adds BOX_SYNC_START_CONFIRMATION_STALL_SECONDS configuration.
apps/api/src/box/services/job.service.ts Transactional updateJobStatus + CAS-style claimPendingJobs.
apps/api/src/box/services/job.service.transaction.spec.ts New tests asserting transaction boundary + lock request.
apps/api/src/box/services/job.service.claim.spec.ts New tests asserting conditional claim semantics + error propagation.
apps/api/src/box/services/box.service.ts Allows STARTED reconciliation for stalled startup jobs; transitional state filtering fix.
apps/api/src/box/services/box.service.start-reconciliation.spec.ts New tests for reconciliation and state-filter validation behavior.
apps/api/src/box/services/box.service.spec.ts Updates service construction for new dependencies.
Suppressed comments (1)

apps/runner/pkg/services/box_sync.go:30

  • BoxSyncServiceConfig still hard-codes Boxlite as *blclient.Client, but the service now depends only on the boxStateReader interface. Keeping the concrete type here prevents injecting a stub via the normal constructor path, undermining the stated goal of exercising the sync loop without a live runtime.
type BoxSyncServiceConfig struct {
	Logger   *slog.Logger
	Boxlite  *blclient.Client
	Interval time.Duration
}

Comment thread apps/runner/pkg/services/box_sync.go
Comment thread apps/runner/pkg/services/box_sync_test.go

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/runner/pkg/boxlite/create_invariant_test.go`:
- Around line 147-198: Update fallibleReturnsAfterStart to validate the start
guard’s condition as well as its Init: require a != comparison against nil using
the same error identifier assigned by bx.Start, and treat any mismatch as the
existing malformed-shape error. Add a TestFallibleReturnsAfterStart case with an
err == nil guard and a fallible step in its body, verifying the mismatch is
reported rather than exempting that body.

In `@sdks/node/src/info.rs`:
- Line 276: Expose the BoxInfo.started_at value through JsBoxInfo::from in
sdks/node/src/info.rs:276-276 and PyBoxInfo::from in
sdks/python/src/info.rs:391-391, adding tests that verify nonzero timestamp
conversion; update InspectPresenter::from in
src/cli/src/commands/inspect.rs:254-254 to include started_at in inspect output,
or explicitly document its intentional omission.

In `@src/boxlite/src/litebox/state.rs`:
- Around line 235-257: Add the missing started_at field to the BoxInfo literal
in the CLI inspect command, initializing it to None, or use BoxInfo::new(...) if
that constructor produces the same intended value. Ensure the public BoxInfo
construction compiles while preserving the existing test behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 99cbe6c8-afe4-4a20-b52d-42ee3c0348ac

📥 Commits

Reviewing files that changed from the base of the PR and between 85bafad and e0469a6.

⛔ Files ignored due to path filters (1)
  • apps/go.work.sum is excluded by !**/*.sum
📒 Files selected for processing (19)
  • apps/runner/pkg/boxlite/box_state_test.go
  • apps/runner/pkg/boxlite/client.go
  • apps/runner/pkg/boxlite/create_invariant_test.go
  • apps/runner/pkg/services/box_sync.go
  • apps/runner/pkg/services/box_sync_test.go
  • sdks/c/include/boxlite.h
  • sdks/c/src/event_queue.rs
  • sdks/c/src/info.rs
  • sdks/c/tests/test_info.c
  • sdks/go/info.go
  • sdks/node/src/info.rs
  • sdks/python/src/info.rs
  • src/boxlite/src/litebox/box_impl.rs
  • src/boxlite/src/litebox/state.rs
  • src/boxlite/src/rest/types.rs
  • src/boxlite/src/runtime/rt_impl.rs
  • src/boxlite/src/runtime/types.rs
  • src/boxlite/tests/started_at.rs
  • src/cli/src/commands/inspect.rs
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/boxlite/src/rest/types.rs
  • sdks/c/src/event_queue.rs
  • src/boxlite/src/litebox/box_impl.rs
  • src/boxlite/src/runtime/rt_impl.rs
  • src/boxlite/src/runtime/types.rs
  • src/boxlite/tests/started_at.rs
  • sdks/go/info.go
  • apps/runner/pkg/services/box_sync.go

Comment thread apps/runner/pkg/boxlite/create_invariant_test.go
Comment thread sdks/node/src/info.rs
Comment thread src/boxlite/src/litebox/state.rs
Copilot AI review requested due to automatic review settings August 3, 2026 11:58
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt branch from e0469a6 to d6825b4 Compare August 3, 2026 11:58
Comment thread src/boxlite/src/litebox/box_impl.rs
Copilot AI review requested due to automatic review settings August 4, 2026 03:35
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt branch from 035a13b to f983773 Compare August 4, 2026 03:35

Copilot AI 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.

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 10, 2026 03:14
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt branch from f983773 to 2227901 Compare August 10, 2026 03:14

Copilot AI 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.

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated no new comments.

Suppressed comments (1)

apps/runner/pkg/services/box_sync.go:233

  • canReport currently blocks all state updates when the API box is CREATING/STARTING unless the local state is STARTED and startedAt is present. That means if the local box is actually STOPPED/ERROR (or any non-STARTED state), the runner will skip reconciliation and the API can remain stuck in a transitional state indefinitely.

The gating should only apply when the runner is about to report STARTED for a transitional remote state; other local states should still be reported normally.

func (s *BoxSyncService) canReport(local localContainerState, remoteState apiclient.BoxState) bool {
	switch remoteState {
	case apiclient.BOXSTATE_CREATING, apiclient.BOXSTATE_STARTING:
		return local.state == enums.BoxStateStarted && local.startedAt != nil
	default:
		return true
	}

DorianZheng
DorianZheng previously approved these changes Aug 10, 2026

@DorianZheng DorianZheng left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Five findings from an adversarial review pass, verified against this branch. Ordered by severity: the first is a hard blocker on the normal polling path.

A sixth finding (appending started_at to CBoxInfo changes sizeof for old C list clients) was checked and dropped — no SONAME/dylib versioning, the Go SDK links statically against a version-pinned header+lib pair, and #1009/#1031 set the same precedent already. Not raising it.

Comment thread apps/api/src/box/services/job.service.ts Outdated
Comment thread apps/api/src/box/services/job.service.ts Outdated
Comment thread src/boxlite/src/litebox/box_impl.rs
Comment thread apps/runner/pkg/services/box_sync.go
Comment thread apps/api/src/box/services/box.service.ts
Copilot AI review requested due to automatic review settings August 10, 2026 13:41
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt branch from 2227901 to a98687e Compare August 10, 2026 13:41
ltstriker and others added 3 commits August 10, 2026 21:42
Two paths mutated a Job with an unguarded read-modify-write, both
relying on protection that TypeORM does not provide: `save()` never
checks the @VersionColumn, because optimistic locking is only enforced
when a row is read with `lock: {mode: 'optimistic'}`, and its UPDATE
predicate is the id alone.

updateJobStatus read the job, validated the transition, and saved it back
over three separate statements. Two writers could both read the same
status, both judge their transition valid, and the second save would
overwrite the first with no conflict raised. It now runs in one
transaction holding a pessimistic_write lock on the row, so a concurrent
update blocks and then re-reads the committed status. The completion
handler still runs after the transaction commits, keeping its side
effects outside the lock.

claimPendingJobs had the same shape and a comment asserting the version
column protected it. Two overlapping polls could therefore claim one
PENDING job and hand the same work out twice, which also moves the
`startedAt` clock that stale-job handling reads. The selected candidates
are now claimed in one conditional UPDATE with both `id IN (...)` and
`status = PENDING` predicates. Each row can be won by only one poller,
while a database error rolls back the entire statement instead of leaving
an earlier subset committed. `RETURNING id` identifies the rows won by
this poll, and real database errors propagate rather than being treated as
lost races.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A box whose CREATE_BOX or START_BOX job finished on the runner but whose
completion callback never reached the API stays in CREATING or STARTING
forever, even though it is running. Nothing reconciled it: BoxSync only
fetched boxes the control plane already believed were STARTED, and
updateState refused to touch a box in a transitional state.

Extend the existing BoxSync loop to cover it, using local evidence that
the box's init was actually launched.

BoxLite now writes {box_dir}/started after the guest's Container.Start
returns success, and clears it when the next lifecycle boots. This is the
positive counterpart of the shim's exit file and follows the same
per-lifecycle discipline: one write point, one removal point. The record
names the shim it belongs to, so a reader pairs it with the box's live
PID and cannot mistake a leftover for current evidence. BoxStatus::Running
alone could not serve here — booting publishes Running before the separate
Container.Start RPC runs.

The runner reads that file directly, so nothing new crosses the C ABI.
BoxSync asks for transitional boxes through the existing for-runner
endpoint and reports its local view through the existing state endpoint;
no new API surface, and the generated client is unchanged. The
transitional query degrades to "no candidates this cycle" when the API
rejects it, so an older API cannot cost us the STARTED reconciliation
that has worked on its own for far longer.

The API decides when to believe the runner, because the API owns the job:
it completes the startup job only once that job has been claimed and has
then stopped making progress for BOX_SYNC_START_CONFIRMATION_STALL_SECONDS
(60s by default). Completing the job is what moves the box, so the
existing completion handler keeps ownership of the STARTED transition, the
pending flag, the activity stamp, the state event, and the Redis unlock.
Before the job stalls, a runner reporting STARTED is answered with a
silent no-op rather than a 400 — it is telling the truth, just early.

Container.Start's success is only usable as evidence of a successful job
body while bx.Start stays the last fallible step of Client.Create.
Nothing enforced that, so TestCreateHasNoFallibleStepAfterStart asserts
it against the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keep recoverable lifecycle evidence consistent across state persistence, SDKs, and CLI output. Expose started_at through public metadata APIs and cover recovery, transitions, and conversions with regression tests.
@ltstriker
ltstriker force-pushed the fix/usage_failure_lt branch from a98687e to 5fa3c43 Compare August 10, 2026 13:43

Copilot AI 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.

Pull request overview

Copilot reviewed 32 out of 32 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

apps/runner/pkg/services/box_sync.go:69

  • GetLocalContainerStates uses box.Name as the primary map key, but remoteStates is keyed by API box.Id and SyncBoxState passes this key as the {boxId} path parameter. If a box has a user-assigned name that differs from its id, reconciliation will silently skip it (no remote match) and/or attempt to update by name instead of id.
		boxId := box.Name
		if boxId == "" {
			boxId = box.ID
		}

Comment thread apps/api/src/box/services/job.service.ts
Copilot AI review requested due to automatic review settings August 10, 2026 13:49

Copilot AI 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.

Pull request overview

Copilot reviewed 32 out of 32 changed files in this pull request and generated no new comments.

Suppressed comments (2)

apps/api/src/box/services/job.service.ts:492

  • Repository.update() is being called with a third argument ({ returning: [...] }), but TypeORM’s Repository.update signature only accepts (criteria, partialEntity). This will fail type-checking/compilation and also won’t reliably give you raw rows. Use a query builder with .returning() (Postgres) to atomically claim and collect the claimed IDs.
      },
      { returning: ['id'] },
    )

sdks/c/include/boxlite.h:325

  • The C header comment includes Rustdoc-style markup ([`Self::pid`]), which is confusing/noisy in generated C docs. Use plain field naming (e.g., pid) in C-facing comments.
  // Unix milliseconds of the most recently recorded successful guest
  // `Container.Start`; `0` when none was recorded. Preserved after stop or
  // reboot; when [`Self::pid`] is nonzero, the timestamp describes that live
  // PID. Milliseconds — not `created_at`'s seconds — preserve sub-second
  // ordering against a job's timeline.

@DorianZheng
DorianZheng enabled auto-merge August 10, 2026 14:14

@DorianZheng DorianZheng left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@DorianZheng
DorianZheng added this pull request to the merge queue Aug 10, 2026
Merged via the queue into main with commit 67c0d56 Aug 10, 2026
65 checks passed
@DorianZheng
DorianZheng deleted the fix/usage_failure_lt branch August 10, 2026 14:27
DorianZheng pushed a commit that referenced this pull request Aug 13, 2026
> **Stacked on #1091.** Base branch is `fix/usage_failure_lt`, not
`main`, so the
> diff here is only the six usage files. **Merge #1091 first**; GitHub
will then
> retarget this PR to `main` on its own. Reviewing it against `main`
before that
> shows #1091's fourteen files as well.
>
> The dependency is branch-level, not code-level: nothing here calls
anything
> #1091 adds, and the two touch disjoint files. It is stacked because it
was
> written on top of that branch, so rebasing it onto `main` today would
be a
> clean no-op — it just has not been done, to keep the diff reviewable.

## Problem

A box's open usage period is maintained by in-process events, which are
fire-and-forget. A handler that throws, or whose process dies
mid-transition,
leaves the box and its period disagreeing and nothing notices. Two gaps
let that
disagreement persist indefinitely:

**The daily roll-over preserved drift instead of correcting it.** It
copied the
closing period's resources into the replacement, so a period charging no
cpu for
a running box was re-copied every day, forever. A disk resize that
landed while
the box was stopped never reached the ledger at all.

**Nothing ever scanned from the box side.** The roll-over walks the
*period*
table, so a box that never got a period is invisible to it — there is no
row to
find. Its one-day cutoff also lets a wrong period bill for a further
day.

## Approach

`expectedOpenPeriod(box)` becomes the single source of truth for the
state → period rule — full compute while running, disk alone once
stopped,
nothing otherwise — and the event handler, the roll-over and the new
reconcile
pass all answer the question the same way.

**Roll-over** re-derives resources from the box rather than copying them
forward. A box that is gone or terminal yields no shape and so is not
reopened,
which is what stops a deleted box from accruing.

**Reconcile pass** (every 5 min) scans from the box side, which is why
both are
needed — their blind spots are opposite. The roll-over is the only thing
that
can see a period whose box row was deleted outright; this is the only
thing that
can see a box that never got a period.

- Sharded by runner so each scan rides `box_runnerid_idx` rather than
reading
the box table end to end. Measured on 20k boxes across 50 runners:
bitmap
index scan, ~400 rows rechecked, ~1.7 ms. The trailing `runnerId IS
NULL`
shard is not optional — a box that reached DESTROYED or ARCHIVED has had
its
runnerId cleared, and those are exactly the periods that must be closed.
- Two-minute grace window before a box is eligible. The per-box lock's
TTL is
60 s, so a handler can legitimately take a full minute to reach the
ledger;
  reconciling inside that window would race it and collide on the
  one-open-period index. Anything under 60 s is provably too short.
- Each candidate is re-checked under the per-box lock against
`expectedOpenPeriod`, so one the event handler fixed in the meantime is
left
  alone. The SQL is a deliberately wide filter, not the authority.
- Repairs are counted on
`usage_period_drift_repaired{kind=missing|orphan|stale_shape}`.

## Decisions worth challenging

- **Billing deliberately diverges from quota.**
`BOX_STATES_CONSUMING_COMPUTE`
  counts CREATING and STARTING because the runner has already pinned the
resources; billing does not charge for a box the tenant cannot use yet.
Divergence here is a pricing decision, not a bug —
`expected-usage-period.spec.ts`
  asserts it so nobody "fixes" it by accident.
- **Corrections start now and are never backdated.** The window a box
spent
mis-billed cannot be reconstructed (its `updatedAt` has moved on for
unrelated
reasons), and guessing it would replace a known gap with an invented
charge.
- **The resource comparison is qualified by state.** A bare `p.cpu <>
b.cpu` is
permanently true for every stopped box — a stopped period *should*
charge no
cpu — and those false positives would fill each page and starve real
drift out
  of the batch forever.
- **A float tolerance is required, not cosmetic.** cpu/gpu/mem/disk are
double
precision on both sides; without `RESOURCE_EPSILON` the pass would
rewrite an
already-correct period on every run, fragmenting the ledger into
unbillable
  slivers.
- ARCHIVING bills nothing because no code in this repository assigns it.
Pricing a state the product does not have yet would be inventing a rule.

No database migration, no API surface change, no client regeneration.

## Verification

Ran against a real Postgres 16 + Redis (`DB_*`/`REDIS_*` pointed at a
disposable
database; the integration spec builds its schema by running the
migrations, so
it exercises the DDL that ships, and skips when no database is
reachable).

| | Result |
|---|---|
| usage suites (unit + integration) | 3 suites / **82 tests** pass |
| full api suite | 58 suites / **321 tests** pass |
| `tsc -p api/tsconfig.spec.json --noEmit` | exit 0 |
| `eslint api/src/usage --max-warnings=0` | clean |
| `prettier --check` | clean |

**Two-side verified.** With the roll-over hunk reverted and everything
else
intact, three roll-over assertions fail on the bug itself — a running
box's
rolled-over period comes back `cpu: 0, gpu: 0, mem: 0` instead of
`2/1/4`:

```
● the daily roll-over on its own › carries the resources the box has now,
  not the ones the closing period held
    - ObjectContaining { "cpu": 2, "gpu": 1, "mem": 4, }
    + BoxUsagePeriod  { "cpu": 0, "gpu": 0, "mem": 0, ... }
```

Restoring the hunk turns all three green. A full revert of every
production file
is red too, but only as a compile error, since the reconcile pass is new
surface
its specs cannot load without — so the roll-over hunk is the isolation
that
carries real signal. Stated plainly: the reconcile tests demonstrate new
behaviour, they do not reproduce a prior bug.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved usage billing across box states, including compute,
disk-only, inactive, and terminal states.
* Added automatic reconciliation to repair missing, orphaned, or
outdated usage periods.
* Updated rollover processing to reflect current resource usage and
avoid terminal boxes.
* Added tolerance for minor resource-value differences when validating
usage periods.

* **Tests**
* Expanded coverage for billing rules, reconciliation, rollover
behavior, resource drift, and idempotency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

3 participants