Skip to content

fix(observatory): poll no longer reverts an optimistic steer value mid-write#1423

Merged
jaylfc merged 2 commits into
devfrom
fix/observatory-poll-no-clobber
Jun 23, 2026
Merged

fix(observatory): poll no longer reverts an optimistic steer value mid-write#1423
jaylfc merged 2 commits into
devfrom
fix/observatory-poll-no-clobber

Conversation

@jaylfc

@jaylfc jaylfc commented Jun 23, 2026

Copy link
Copy Markdown
Owner

What

Folds the deferred gitar Edge-Case from #1420: the 5s background poll (load() over fleet + throttle) could land while a steer write was in flight and briefly revert the optimistic value (pause, global cap, or per-lane cap) before the POST completed.

Change

  • Track in-flight steer writes with an inFlight ref, incremented/decremented in the shared postSteer.
  • The 5s interval skips a tick while inFlight > 0; postSteer's existing reconcile load() updates authoritatively once the write resolves.
  • Uniform across setScope / setGlobalCap / setLaneCap (all route through postSteer), so the three controls stay consistent.

Tests

  • New ObservatoryApp.test.tsx case: with a pending POST, a 5s tick issues no background fleet fetch; once the POST resolves, the reconcile fetch runs. Plus the existing 13. All 14 green; tsc -b clean.

Verification note

Behaviourally verified via vitest; visual check deferred to a live session, as with the other desktop slices.

Summary by CodeRabbit

  • Bug Fixes

    • Improved Observatory background polling behavior during steer/pause requests so user-facing optimistic updates aren’t overwritten while a write is pending, and state sync resumes once the request completes.
  • Tests

    • Added an automated test that verifies fleet polling is suppressed during an in-flight steer/pause POST and resumes afterward, using mocked requests and fake timers.

…flight

Fold the deferred gitar Edge-Case from #1420: the 5s fleet/throttle poll could
land mid-request and revert an optimistic steer value (pause, global cap, or
lane cap) before the POST completed. Track in-flight steer writes with a ref
incremented in the shared postSteer; the interval skips a tick while any write
is pending, and postSteer's existing reconcile updates authoritatively once the
write lands. Uniform across all three controls. New test asserts the poll is
skipped during an in-flight write and resumes after it resolves.
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cc34df89-2343-4f45-97a8-bb39f1de7cc4

📥 Commits

Reviewing files that changed from the base of the PR and between 7a87c2d and 0d322ff.

📒 Files selected for processing (2)
  • desktop/src/apps/ObservatoryApp.test.tsx
  • desktop/src/apps/ObservatoryApp.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • desktop/src/apps/ObservatoryApp.tsx
  • desktop/src/apps/ObservatoryApp.test.tsx

📝 Walkthrough

Walkthrough

ObservatoryApp gains an inFlight ref that increments when a steer POST starts and decrements in finally. The 5-second background polling loop checks this counter and skips load({ silent: true }) while any write is pending. A new test validates this suppression using fake timers and a manually controlled pause POST promise.

Changes

Poll gating during steer writes

Layer / File(s) Summary
inFlight counter and polling gate
desktop/src/apps/ObservatoryApp.tsx
Adds inFlight via useRef, wraps postSteer with increment before fetch and decrement in finally, updates load to apply server pause/cap state only when inFlight.current === 0, and skips periodic load({ silent: true }) calls when inFlight.current is non-zero.
Polling suppression test
desktop/src/apps/ObservatoryApp.test.tsx
New test uses fake timers and a pending pause POST to assert fleet polling is skipped during the write and resumes after the POST resolves.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • jaylfc/taOS#1421: Modifies the same postSteer write path in ObservatoryApp.tsx and extends ObservatoryApp.test.tsx around /api/observatory/pause behavior, directly intersecting with this PR's polling-suppression changes.

Poem

🐇 A poll tried to sneak in mid-flight,
But inFlight said "not quite right!"
While the POST holds the floor,
No GET sneaks through the door,
Then polling resumes — sheer delight! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly summarizes the main change: preventing background polling from reverting optimistic steer values during write operations, which is the core issue addressed by the PR.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/observatory-poll-no-clobber

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.

const steerSeq = useRef(0);
const postSteer = useCallback(
async (url: string, body: object, failMsg: string) => {
inFlight.current += 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: inFlight.current += 1 runs before the try block, so the matching decrement in finally will not execute if anything between this line and try { ever throws. Today the only statement in between (const seq = ++steerSeq.current;) cannot throw, so it is safe in practice, but the ordering is fragile — a future edit (e.g., adding input validation) could leave inFlight permanently positive and silently disable the background poll for the lifetime of the component. Move the increment inside the try, or wrap the full body in a top-level try { ... } finally { inFlight.current -= 1; }.

const id = setInterval(() => {
// Skip the poll while a steer write is in flight so it cannot revert an
// optimistic value mid-request; postSteer reconciles after the write.
if (inFlight.current === 0) load({ silent: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: When the 5s tick fires while inFlight > 0, the tick is silently dropped and the next fire is still ~5s away. If a steer write (or a backlog of overlapping writes) keeps inFlight non-zero for longer than the interval, the poll effectively halts; the only state refresh becomes the per-write reconcile load() in finally. That is acceptable for short writes, but a stuck or long-lived POST would let fleet/throttle state drift. Consider rescheduling a tick immediately when inFlight returns to 0, or shortening the interval's drift budget when a tick is skipped.

render(<ObservatoryApp windowId="w1" />);
await act(async () => { await Promise.resolve(); });

// Start a pause write whose POST stays pending so inFlight stays non-zero.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: The PR description claims uniformity across setScope / setGlobalCap / setLaneCap, but this new case only exercises the setScope (pause) path — the global cap stepper and per-lane cap stepper are not asserted to skip-then-recover under the same conditions. Since all three route through the same postSteer, one path is arguably enough for the inFlight mechanism itself, but the symmetry claim in the description is not backed by these tests. Consider mirroring the assertion against Raise concurrency cap and Raise @taOS-dev-kilo-owl-alpha limit (with their POSTs held pending).

Comment on lines 160 to 163
} finally {
await load({ silent: true });
inFlight.current -= 1;
}

@gitar-bot gitar-bot Bot Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Edge Case: Reconcile load() can still revert a concurrent in-flight steer write

The new inFlight guard only suppresses the 5s background poll. However, postSteer's finally block unconditionally calls await load({ silent: true }) regardless of whether other steer writes are still pending. Because the controls are intentionally not serialized (they use different busy scopes — global, cap, cap:<handle> — per the comment at lines 140-145), two writes can overlap: e.g. a lane-cap write A is still in flight (optimistic value set) while a pause write B resolves first. B's reconcile load() fetches authoritative state from the server, which does not yet reflect A's not-yet-completed POST, so A's optimistic value is briefly reverted — exactly the class of flicker this PR aims to eliminate, now triggered by a sibling write rather than the interval.

This is a narrow case (requires rapid operation of two different controls), hence minor, but it undercuts the PR's "uniform across setScope/setGlobalCap/setLaneCap" claim. Consider only running the reconcile load() when it is the last write to settle (e.g. skip the reconcile fetch while inFlight.current > 1, letting the final write reconcile), or gating the reconcile on seq === steerSeq.current.

Skip the reconcile fetch while other steer writes are still in flight; the final write performs the authoritative reconcile.:

} finally {
  // Only the last settling write reconciles, so an earlier resolving
  // write cannot revert a still-pending sibling's optimistic value.
  if (inFlight.current === 1) await load({ silent: true });
  inFlight.current -= 1;
}

Was this helpful? React with 👍 / 👎

@kilo-code-bot

kilo-code-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 5 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 5
Issue Details (click to expand)

SUGGESTION

File Line Issue
desktop/src/apps/ObservatoryApp.tsx 157 inFlight.current += 1 is outside the try; a future throw between it and try { would skip the finally decrement and silently disable the poll
desktop/src/apps/ObservatoryApp.tsx 143 Skipped 5s ticks are dropped (not rescheduled); a write held open longer than the interval effectively halts background polling
desktop/src/apps/ObservatoryApp.tsx 117 Gating on inFlight === 0 prevents cross-write reverts but the last write's reconcile still adopts server steer-state that may not yet reflect its own POST (read-after-write lag), reverting its own optimistic value
desktop/src/apps/ObservatoryApp.test.tsx 347 New test only exercises setScope (pause); the PR's "uniform across setScope / setGlobalCap / setLaneCap" claim is not asserted for the two cap stepper paths
desktop/src/apps/ObservatoryApp.test.tsx 410 New concurrent test only asserts cap survives while pause is pending; it does not assert the symmetric pause-survives-while-cap-pending case, nor does it flush the pause reconcile after resolvePause() to verify the final adoption
Files Reviewed (2 files)
  • desktop/src/apps/ObservatoryApp.tsx - 3 issues
  • desktop/src/apps/ObservatoryApp.test.tsx - 2 issues

Fix these issues in Kilo Cloud

Previous Review Summary (commit 7a87c2d)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 7a87c2d)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 3
Issue Details (click to expand)

SUGGESTION

File Line Issue
desktop/src/apps/ObservatoryApp.tsx 149 inFlight.current += 1 is outside the try; a future throw between it and try { would skip the finally decrement and silently disable the poll
desktop/src/apps/ObservatoryApp.tsx 135 Skipped 5s ticks are dropped (not rescheduled); a write held open longer than the interval effectively halts background polling
desktop/src/apps/ObservatoryApp.test.tsx 347 New test only exercises setScope (pause); the PR's "uniform across setScope / setGlobalCap / setLaneCap" claim is not asserted for the two cap stepper paths
Files Reviewed (2 files)
  • desktop/src/apps/ObservatoryApp.tsx - 2 issues
  • desktop/src/apps/ObservatoryApp.test.tsx - 1 issue

Fix these issues in Kilo Cloud


Reviewed by minimax-m3 · Input: 57.5K · Output: 7.1K · Cached: 325.5K

@gitar-bot

gitar-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

Note

Your trial team has used its Gitar budget, so automatic reviews are paused. Upgrade now to unlock full capacity. Comment "Gitar review" to trigger a review manually.
Learn more about usage limits

Code Review 👍 Approved with suggestions 0 resolved / 1 findings

Prevents the 5s background poll from clobbering optimistic steer values by tracking in-flight writes. Consider extending the inFlight guard to the reconcile load() call to ensure concurrent writes are not reverted during the reconciliation process.

💡 Edge Case: Reconcile load() can still revert a concurrent in-flight steer write

📄 desktop/src/apps/ObservatoryApp.tsx:160-163 📄 desktop/src/apps/ObservatoryApp.tsx:140-145

The new inFlight guard only suppresses the 5s background poll. However, postSteer's finally block unconditionally calls await load({ silent: true }) regardless of whether other steer writes are still pending. Because the controls are intentionally not serialized (they use different busy scopes — global, cap, cap:<handle> — per the comment at lines 140-145), two writes can overlap: e.g. a lane-cap write A is still in flight (optimistic value set) while a pause write B resolves first. B's reconcile load() fetches authoritative state from the server, which does not yet reflect A's not-yet-completed POST, so A's optimistic value is briefly reverted — exactly the class of flicker this PR aims to eliminate, now triggered by a sibling write rather than the interval.

This is a narrow case (requires rapid operation of two different controls), hence minor, but it undercuts the PR's "uniform across setScope/setGlobalCap/setLaneCap" claim. Consider only running the reconcile load() when it is the last write to settle (e.g. skip the reconcile fetch while inFlight.current > 1, letting the final write reconcile), or gating the reconcile on seq === steerSeq.current.

Skip the reconcile fetch while other steer writes are still in flight; the final write performs the authoritative reconcile.
} finally {
  // Only the last settling write reconciles, so an earlier resolving
  // write cannot revert a still-pending sibling's optimistic value.
  if (inFlight.current === 1) await load({ silent: true });
  inFlight.current -= 1;
}
🤖 Prompt for agents
Code Review: Prevents the 5s background poll from clobbering optimistic steer values by tracking in-flight writes. Consider extending the `inFlight` guard to the reconcile `load()` call to ensure concurrent writes are not reverted during the reconciliation process.

1. 💡 Edge Case: Reconcile load() can still revert a concurrent in-flight steer write
   Files: desktop/src/apps/ObservatoryApp.tsx:160-163, desktop/src/apps/ObservatoryApp.tsx:140-145

   The new `inFlight` guard only suppresses the 5s *background* poll. However, `postSteer`'s `finally` block unconditionally calls `await load({ silent: true })` regardless of whether other steer writes are still pending. Because the controls are intentionally not serialized (they use different `busy` scopes — `global`, `cap`, `cap:<handle>` — per the comment at lines 140-145), two writes can overlap: e.g. a lane-cap write A is still in flight (optimistic value set) while a pause write B resolves first. B's reconcile `load()` fetches authoritative state from the server, which does not yet reflect A's not-yet-completed POST, so A's optimistic value is briefly reverted — exactly the class of flicker this PR aims to eliminate, now triggered by a sibling write rather than the interval.
   
   This is a narrow case (requires rapid operation of two different controls), hence minor, but it undercuts the PR's "uniform across setScope/setGlobalCap/setLaneCap" claim. Consider only running the reconcile `load()` when it is the last write to settle (e.g. skip the reconcile fetch while `inFlight.current > 1`, letting the final write reconcile), or gating the reconcile on `seq === steerSeq.current`.

   Fix (Skip the reconcile fetch while other steer writes are still in flight; the final write performs the authoritative reconcile.):
   } finally {
     // Only the last settling write reconciles, so an earlier resolving
     // write cannot revert a still-pending sibling's optimistic value.
     if (inFlight.current === 1) await load({ silent: true });
     inFlight.current -= 1;
   }

Options

Display: compact → Showing less information.

Comment with these commands to change:

Compact
gitar display:verbose         

Important

Your trial ends in 3 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar

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

Actionable comments posted: 1

🤖 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 `@desktop/src/apps/ObservatoryApp.test.tsx`:
- Around line 359-365: The test validates that `postSteer` reconcile fetches the
fleet but does not verify that the 5-second polling interval actually resumes
after POST resolution. The current
`expect(fleetCalls()).toBeGreaterThan(before)` assertion passes because
`postSteer` itself calls `load()` in its finally block, not because polling
resumed. After the existing `Promise.resolve()` calls within the act block, add
another 5-second timer advancement (using jest fake timer methods), then add a
second assertion that fleetCalls() increases again to confirm the polling
interval has resumed independent of the reconcile operation.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e2f425a-6478-4e6d-b017-fc723ea4f544

📥 Commits

Reviewing files that changed from the base of the PR and between e75a2f1 and 7a87c2d.

📒 Files selected for processing (2)
  • desktop/src/apps/ObservatoryApp.test.tsx
  • desktop/src/apps/ObservatoryApp.tsx

Comment on lines +359 to +365
// Once the write resolves, postSteer's reconcile fetches the fleet.
await act(async () => {
resolvePost?.();
await Promise.resolve();
await Promise.resolve();
});
expect(fleetCalls()).toBeGreaterThan(before);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test currently validates reconcile fetch, not poll resumption.

expect(fleetCalls()).toBeGreaterThan(before) can pass even if the 5s interval never resumes, because postSteer itself calls load() in finally. Add one more 5s tick after resolving the POST and assert fleet calls increase again.

Suggested assertion tightening
     await act(async () => {
       resolvePost?.();
       await Promise.resolve();
       await Promise.resolve();
     });
-    expect(fleetCalls()).toBeGreaterThan(before);
+    const afterReconcile = fleetCalls();
+    expect(afterReconcile).toBeGreaterThan(before);
+
+    await act(async () => {
+      vi.advanceTimersByTime(5000);
+      await Promise.resolve();
+    });
+    expect(fleetCalls()).toBeGreaterThan(afterReconcile);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Once the write resolves, postSteer's reconcile fetches the fleet.
await act(async () => {
resolvePost?.();
await Promise.resolve();
await Promise.resolve();
});
expect(fleetCalls()).toBeGreaterThan(before);
// Once the write resolves, postSteer's reconcile fetches the fleet.
await act(async () => {
resolvePost?.();
await Promise.resolve();
await Promise.resolve();
});
const afterReconcile = fleetCalls();
expect(afterReconcile).toBeGreaterThan(before);
await act(async () => {
vi.advanceTimersByTime(5000);
await Promise.resolve();
});
expect(fleetCalls()).toBeGreaterThan(afterReconcile);
🤖 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 `@desktop/src/apps/ObservatoryApp.test.tsx` around lines 359 - 365, The test
validates that `postSteer` reconcile fetches the fleet but does not verify that
the 5-second polling interval actually resumes after POST resolution. The
current `expect(fleetCalls()).toBeGreaterThan(before)` assertion passes because
`postSteer` itself calls `load()` in its finally block, not because polling
resumed. After the existing `Promise.resolve()` calls within the act block, add
another 5-second timer advancement (using jest fake timer methods), then add a
second assertion that fleetCalls() increases again to confirm the polling
interval has resumed independent of the reconcile operation.

…n-flight one

Fold the gitar Edge-Case on the first version: the inFlight guard suppressed the
background poll, but postSteer's reconcile load() still ran and, with two writes
in flight, the first to finish read server state lacking the second and reverted
its optimistic value. Move the steer-state adoption (pause/caps) inside load()
behind inFlight === 0 so neither the poll nor a concurrent reconcile clobbers an
optimistic value; decrement inFlight before the reconcile so only the last
write's reconcile adopts. The fleet list still refreshes every time. New test
drives a pause + cap concurrently and asserts the cap survives the pause's
reconcile (the test also exercises both controls through the shared path,
covering the kilo uniformity note). Deferred kilo nits: nil-risk increment
ordering, and the dropped-tick which the reconcile already covers.
@jaylfc jaylfc enabled auto-merge (squash) June 23, 2026 23:21
const data = await throttleRes.json();
setCap(coerceCap(data?.global));
setLaneCaps(coerceLaneCaps(data?.lanes));
if (inFlight.current === 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: The fix correctly prevents an earlier-resolving concurrent write from reverting a still-pending sibling's optimistic value, but the gating only fires while inFlight > 0. When the last write resolves, inFlight drops to 0 and its own reconcile runs adoption -- and if the server's fleet/throttle read has not yet observed that write (read-after-write lag, or the fleet endpoint aggregating from a different source than the POST target), the optimistic value is reverted by the very reconcile this PR was supposed to make safe. The new test only covers the cap-survives-while-pause-pending direction; the symmetric case (e.g., pause resolving last against a laggy fleet endpoint that still reports paused.global: false) is not asserted. Consider also gating adoption on the resolving write's seq (already tracked in steerSeq.current), or skipping adoption when the latest write was the one driving the reconcile.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


// The cap write's reconcile read says "no cap", but the pause write is still
// in flight, so the optimistic cap must be preserved (not reverted to 0).
expect(screen.getByLabelText(/concurrency cap value/i).textContent).toBe("1");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: The new test only asserts the cap survives while the pause POST is still pending (line 410). It does not assert the symmetric case -- pause surviving while the cap POST is still pending -- nor does it assert what happens after the pause POST resolves: the trailing await act(async () => { resolvePause?.(); await Promise.resolve(); }) at line 412 only awaits one microtask, so the pause's reconcile load() (which performs its own fleet + throttle fetches before adopting steer-state) is not flushed. The test therefore does not verify that the final reconcile correctly adopts the new pause state, and it leaves a real concern uncovered: if the server's fleet read has not yet propagated paused.global: true when the pause reconcile runs, the optimistic pause would be silently reverted back to false -- the exact class of flicker this PR is meant to eliminate.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@jaylfc jaylfc merged commit a79c4ac into dev Jun 23, 2026
8 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in TinyAgentOS Roadmap Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

1 participant