Skip to content

feat(workspace): show memory and skill-sync state in the sidebar - #1279

Open
sahrizvi wants to merge 8 commits into
feat/workspace-followupsfrom
feat/workspace-sidebar-status
Open

feat(workspace): show memory and skill-sync state in the sidebar#1279
sahrizvi wants to merge 8 commits into
feat/workspace-followupsfrom
feat/workspace-sidebar-status

Conversation

@sahrizvi

@sahrizvi sahrizvi commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Issue for this PR

Part of #1272 — the discoverability half.

Stacked on #1278. Base is feat/workspace-followups, so the diff here is
only the sidebar change. Retarget to main once #1278 merges.

Type of change

  • New feature

What does this PR do?

The workspace pane names the workspace but says nothing about whether local state
has drifted from it. So there is no moment at which a user learns they have memory
the workspace never received, or skills that have not synced this session — which
means /workspace sync has no discoverability problem to solve, because nothing
ever suggests running it.

Two lines under the existing name and manage URL:

Workspace
  Growth (id 42)
  12 memories · 3 not synced
  skills synced 6m ago
  https://…/w/42

Both are status, not affordances. The pane takes no input and none of the five
sidebar plugins does, so nothing here becomes clickable.

allowNetwork: false is load-bearing, not a micro-optimisation. The sidebar
refreshes every 30s, and the memory-enabled cache is deliberately positive-only —
a workspace with memory switched off is never memoized, so switching it on is
picked up immediately. Asking the service on a cache miss would therefore put a
request on the wire every 30 seconds for the lifetime of the session, for exactly
the workspaces whose answer is "no". With the network forbidden, counts show when
the cache already knows and are omitted otherwise.

Unknown is reported as unknown in both places, deliberately. Treating unknown
enablement as enabled shows a backlog on a workspace that has memory off; treating
it as disabled hides a real one. Likewise skillsSyncedAt is null for "unknown",
not "never" — the store is per-process, so a fresh session has not synced yet even
where the on-disk snapshot is current, and the line is hidden rather than rendered
as "never synced".

Supporting changes: skill-sync.lastSuccessfulSyncAt (recentlySynced answers a
boolean against the poll interval, which cannot say "6 minutes ago") and
memory-sync.memoryEnabledCached (the no-network read).

How did you verify your code works?

8 tests in the manage suite, 486 across the workspace and plugin suites.
Typecheck clean.

Mutation-checked: ignoring allowNetwork — the polling regression — and treating
unknown enablement as enabled each fail a test.

Screenshots / recordings

Text-only sidebar lines; the shape is in the code block above.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Known gaps

🤖 Generated with Claude Code


Summary by cubic

Shows memory and skill-sync drift in the workspace sidebar so /workspace sync becomes discoverable before state goes stale, and fixes four sidebar bugs and the sync toast to report what actually happened.

Sidebar

  • Adds two status lines under the workspace name: memory counts with unsynced total, and last successful skill-sync age.
  • The poller resolves memory enablement at most once per 5 minutes per workspace, memoized by tenant and API URL — a full network ban made counts never appear on fresh sessions.
  • Unknown memory enablement and unknown sync time render as omitted lines, not "0 not synced" or "never synced".
  • The tile resolves the binding over the network on a cold cache, keeps the last answer on transient errors, distinguishes "loading" from "read, unlinked", and refreshes immediately on link/unlink/rename instead of waiting for the poll.

Sync toast

  • Fixes the /workspace sync toast reporting fully refused sweeps as "Everything is already in the workspace" — declined blocks are now reported as refused, and transport failures are kept distinct from refusals.

Written for commit 8638e34. Summary will update on new commits.

Review in cubic

The pane names the workspace but says nothing about whether local state has
drifted from it — so there is no moment at which a user learns they have memory
the workspace never received, or skills that have not synced this session.
`/workspace sync` has no discoverability problem to solve if nothing ever
suggests running it.

Adds two lines under the existing name and manage URL:

    12 memories · 3 not synced
    skills synced 6m ago

Both are status, not affordances — the pane takes no input, and none of the five
sidebar plugins does.

`allowNetwork: false` on the status call is load-bearing, not a micro-optimisation.
The sidebar refreshes every 30s, and the memory-enabled cache is deliberately
positive-only, so a workspace with memory switched OFF is never memoized. Asking
the service on a cache miss would therefore put a request on the wire every 30
seconds for the lifetime of the session, for exactly the workspaces whose answer
is "no". With the network forbidden the counts are shown when the cache already
knows and omitted otherwise — unknown reported as unknown, because treating it as
enabled shows a backlog on a workspace that has memory off, and treating it as
disabled hides a real one.

`skillsSyncedAt` is null for "unknown", not "never": the store is per-process, so
a fresh session has not synced yet even where the on-disk snapshot is current.
The line is hidden rather than rendered as "never synced".

Supporting changes:
- `skill-sync`: `lastSuccessfulSyncAt`. `recentlySynced` answers a boolean against
  the poll interval, which cannot say "6 minutes ago" — and a line whose whole job
  is to make staleness visible needs the age, not a threshold. Reads the
  process-global store, so the plugin realm sees the map the sync writes.
- `memory-sync`: `memoryEnabledCached`, the no-network read of the enablement
  cache.

Tests: 8 in the manage suite, 486 across the workspace and plugin suites.
Mutation-checked — ignoring `allowNetwork`, and treating unknown enablement as
enabled, each fail a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 827d0a44-ed33-44be-a8db-2caef1e8d237

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

sahrizvi and others added 4 commits September 9, 2026 13:09
Both were caught by watching the real TUI, not by the suite. Both are now
covered.

**1. The sidebar's counts never appeared.** `allowNetwork: false` was implemented
as "never touch the network", so the counts could only render once something ELSE
warmed the shared enablement cache — a memory write, or opening `/workspace`. On a
session where neither happened they simply never showed, which defeats the point:
the line exists so drift is noticed without being told to look.

The requirement was a BOUND, not a ban. `memoryEnabledForPoller` resolves over the
network at most once per five minutes when the answer is "no", and not at all once
it is "yes" (the shared cache holds positives). `memoryEnabled` itself is untouched
and stays positive-only, so the WRITE path still picks up a newly enabled
workspace immediately — that is the property that matters for not losing memory,
and a poller being a few minutes behind costs nothing.

The counts now render on first paint. A poller also reports the LOCAL block count
even when memory is off: "how many memories do I have" needs no service, and only
"how many are outstanding" depends on the workspace setting.

**2. The sync toast reported a completely failed sweep as success.** On a project
where the service refused every block, `sync` returned
`{sent: 0, failed: 0, skipped: 0, declined: 19}` — and the message only checked
`sent === 0 && failed === 0`, so it said "Everything is already in the workspace."
while nineteen memories had just been turned away.

`declined` is now counted. It means the service said no — quota, permissions, a
workspace setting — which is a different outcome from having nothing to send, and
reporting it as the latter tells the user their memory arrived when none of it
did. The message moved into `syncMessage` with its own tests.

Simplified while there: a redundant `declined === 0` was removed from the
success guard. The branch above already takes every refused sweep with nothing
sent, so it was a second guard that could never be the one that fires — and a
reader has to prove that before trusting either. Both remaining branches are now
load-bearing; mutating either fails a test, which was not true before.

Tests: 493 across the workspace and plugin suites, 5 new for the toast wording.
Mutation-checked — the poller asking on every tick, the poller never asking at
all (the original hole), a stale negative memo surviving re-enablement, ignoring
a fully refused sweep, and dropping partial refusals each fail a test. Also fixed
an order-dependent assertion: counting ALL requests picked up the fire-and-forget
backfill that `recordApprovedBinding` starts, so it passed alone and failed in a
full run; it now counts only calls to the endpoint the poller uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6
…ld bug

The call site's comment justified `allowNetwork: false` as "the memory-enabled
cache never memoizes a no, so asking on a miss would put a request on the wire
every 30 seconds". That was the reasoning behind the bug the previous commit
fixed, not behind the code as it now stands: `status` resolves through
`memoryEnabledForPoller`, which asks at most once every few minutes on a "no"
and never once it is "yes".

Left alone, the next reader either trusts the comment and reintroduces the ban,
or trusts the code and stops trusting the comments. Says bound, not ban, and
points at `Manage.status` where the reasoning lives.

Verified end to end against prod while recording the feature: the sidebar now
renders "sidebar-demo · 6 memories · 6 not synced" on first paint with a cold
cache — the exact case that used to show nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6
…a refusal

Third bug from the same recording, spotted by the user in the video: a sweep of
six blocks reported "The workspace refused all 5 memories — nothing was sent."
Six local, five named. The missing one was a transport failure, and the branch
that claims a total refusal fired on `declined > 0 && sent === 0` without
looking at `failed` at all.

So the sentence was wrong twice. "all" was false — six were attempted, not five
— and the failure itself vanished. That is the worst one to drop: a refusal is
the service's considered no, while a failure is retryable and can mean the
network or the credentials are wrong. The outcome the user could act on was the
one the message swallowed.

`failed === 0` now guards that branch, so a mixed sweep falls through to the
parts list and reports both: "Nothing was sent, 1 failed, 5 refused by the
workspace." The list also leads with "Nothing was sent" instead of "Sent 0
memories" — that path is reachable with `sent === 0` now, and a zero reads as a
statistic when it is the headline.

Same family as the two before it, and the same lesson: the sweep knows exactly
what happened, and every one of these bugs was the message throwing part of it
away. `partitionPending` is shared by the status line and the sweep precisely so
the two can never disagree; that guarantee is worth nothing if the sentence
built from the result drops a field.

Tests: 496 pass, 3 new for the mixed case. Mutation-checked — reverting the
`failed === 0` guard fails two tests, dropping the zero-sent phrasing fails one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6
…oject

Fourth bug from the recordings, and the same shape as the first: the tile read
the binding through `readLocalBinding`, which never touches the network. On a
cold cache — a fresh machine, a new session, anything that has not resolved this
project yet — that returns null, and the tile rendered

    Workspace
    Not linked — run altimate-code link

about a project that IS linked. It corrected itself only when some unrelated
code path happened to warm the cache, so how long the lie lasted depended on
what else the session did. Caught on screen: the sidebar said "Not linked" at
22s and 38s into a session, then showed the workspace at 63s with nothing having
changed but time.

"Not linked" is the worst state to get wrong, because it is the one that tells
the user to go and run a command. The instruction was the false part.

Two changes. The tile now resolves through `resolveBindingOutcome` rather than
reading the cache, which asks the server on a miss and is already safe to poll:
a confirmed "unbound" is memoized for MISS_TTL_MS and a known binding is trusted
for REVALIDATE_MS, so the worst case is one request per five minutes — the same
bound the counts fix uses. And "unknown" (unreachable, 5xx) now leaves the last
answer standing instead of collapsing to null, so a blip cannot downgrade a
working tile to "Not linked"; `resolveBindingOutcome` exists precisely to keep
those two apart and this caller was throwing the distinction away.

The signal also carries a third state: `undefined` for "the first read has not
returned", `null` for "resolved, genuinely unlinked". Starting at `null` meant
the very first paint asserted a state nothing had checked yet.

Verified from a cold state directory against prod: the tile shows the workspace
and its counts on first paint, and the "Not linked" flash is gone. 496 tests
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6
@sahrizvi
sahrizvi marked this pull request as ready for review September 9, 2026 11:50

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@kilo-code-bot

kilo-code-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 10 Issues Found | Recommendation: Address before merge

Overview

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

WARNING

File Line Issue
packages/opencode/src/altimate/workspace/skill-sync.ts 262 lastSuccessfulSyncAt reads a per-thread map: routine per-turn syncs run in the server worker, so the sidebar "skills synced Xm ago" line never sees them — it stays hidden or freezes at link time while the worker re-syncs every 5 min (false staleness). Use a disk-backed signal (e.g. manifest mtime) as this file already does for registry refresh.
packages/opencode/src/altimate/workspace/manage.ts 220 The poller still drips one /datamates request per minute for an enabled workspace: the 5-min pollMemo gates the resolver, but pendingCount re-resolves through the 60s write-path cache (memory-sync.ts:736183-186), and a pollMemo hit does not refresh that cache.
packages/opencode/src/plugin/tui/altimate/workspace.tsx 1662 gated: true also comes from a failed local memory read (manage.ts:177-181) and no-binding (175); the toast then asserts "workspace memory is off for this project" with the success variant — misinformation about the user configuration.

SUGGESTION

File Line Issue
packages/opencode/src/altimate/workspace/memory-sync.ts 666 Tenant-unscoped memoryEnabledCache is checked before the tenant-scoped pollMemo, leaving a 60s cross-tenant inheritance window after an account switch; also redundant with memoryStatus own check.
packages/opencode/src/altimate/workspace/state.ts 446 Dead dropped flag (446/452/471) — assigned, never read; void dropped only silences the lint. Delete all three lines.
packages/opencode/src/altimate/workspace/state.ts 472 On persistent cache-write failure, the unconditional notify chain (394 → 472 → queued refresh → 383 early-return) re-serves the stale on-disk row as "bound", resurrecting the unlinked workspace in the tile.
packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx 130 Dead setManageUrl(null): every path reaching if (!b) already has manageUrl() null (cleared at line 125 on the only falsy transition).
packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx 62 describeAge double-rounds (line 60 then 62), so "1m ago" only covers a ~30s window (89.5s renders "2m ago"). Math.floor once gives each label a full 60s.
packages/opencode/src/altimate/workspace/memory-sync.ts 679 resetPollMemoForTests does not clear memoryEnabledCache, which memoryEnabledForPoller consults first; memory-sync.test.ts (same id 42, memory_enabled: true) leaves positives with no cleanup — the new poller tests pass only under default alphabetical file ordering.
packages/opencode/src/provider/models-snapshot.ts 2 (Summary-only: GitHub rejects inline comments on this file — diff entry too large.) Unrelated auto-generated models.dev catalog refresh (hundreds of entries churned) bundled into this feature PR; conflicts with parallel PRs that regenerate the snapshot. Consider splitting it out.
Files Reviewed (9 files)
  • packages/opencode/src/altimate/workspace/manage.ts - 1 issue
  • packages/opencode/src/altimate/workspace/memory-sync.ts - 2 issues
  • packages/opencode/src/altimate/workspace/skill-sync.ts - 1 issue
  • packages/opencode/src/altimate/workspace/state.ts - 2 issues
  • packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx - 2 issues
  • packages/opencode/src/plugin/tui/altimate/workspace.tsx - 1 issue
  • packages/opencode/src/provider/models-snapshot.ts - 1 issue
  • packages/opencode/test/altimate/plugin/workspace-sync-message.test.ts - clean
  • packages/opencode/test/altimate/workspace/manage.test.ts - covered by memory-sync.ts finding

Fix these issues in Kilo Cloud

Previous Review Summary

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

Previous review

This review did not run. Your provider API key hit its rate limit, so the
request was rejected before the review started. Kilo does not retry
automatically, because the quota is your provider's; push a new commit once it
resets. Any inline comments below are from an earlier review.


Reviewed by glm-5.2 · Input: 120.4K · Output: 45.5K · Cached: 2.4M

Review guidance: REVIEW.md from base branch feat/workspace-followups

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 8 files

Not reviewed (too large): packages/opencode/src/provider/models-snapshot.ts (~2 lines) - if these are generated or fixture files, add them to ignored paths to exclude them from future reviews.

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

Re-trigger cubic

Comment thread packages/opencode/src/altimate/workspace/memory-sync.ts Outdated
Comment thread packages/opencode/src/altimate/workspace/memory-sync.ts Outdated
Comment thread packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx Outdated
Comment thread packages/opencode/src/altimate/workspace/memory-sync.ts Outdated
…king it wait

Unlink took up to thirty seconds to show in the tile. The sidebar polls every
POLL_MS and nothing published a binding change, so the pane kept naming the
workspace long after the server and the local cache had both dropped it. What
the user saw was a green "Unlinked from X" toast sitting beside a panel still
reporting "X · 3 memories" — the UI contradicting itself, with the stale half
looking the more authoritative of the two. The lag is 0-30s depending on where
in the cycle the action lands, so it is also inconsistent between attempts,
which reads as flakiness rather than latency.

`state.ts` now keeps a listener set, notified from the two places that actually
change a binding: `forgetBinding` (which every unlink and every server-says-
unbound resolve funnels through) and `recordApprovedBinding`, the latter only
when `bindingChanged` — a warm cache re-read is not a change, and waking the
tile on every resolve would give back what the poll interval buys.

A plain listener set rather than an event bus: every writer already funnels
through this module, so one hook covers link, unlink and rebind, where a bus
would mean threading a dependency through each writer for a single subscriber.
The interval stays as the backstop — it is what catches a change made by
another process, which no in-process notifier can see.

Two details worth their lines. `notifyBindingChanged` is called OUTSIDE
`forgetBinding`'s try: a listener is a UI refresh, its failure is not a failed
cache drop, and notifying from inside logged a throwing subscriber as "could
not drop a binding" — a misleading line about a write that had already
succeeded. And the tile's in-flight guard now coalesces instead of dropping: a
notification arriving mid-poll used to return early, leaving the tile stale
until the next tick, which is precisely the lag this removes.

Tests: 501 pass, 5 new. Mutation-checked — never notifying on a drop, notifying
on a warm re-record, and rethrowing a listener error each fail a test. One
mutation survives knowingly: notifying even when the cache write failed costs a
redundant refresh and changes nothing a user can see, so it has no test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 3 files (changes from recent commits).

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

Re-trigger cubic

Comment thread packages/opencode/src/altimate/workspace/state.ts Outdated
Comment thread packages/opencode/src/altimate/workspace/state.ts Outdated
Comment thread packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx Outdated
Seven findings, all valid. Two of them correct claims I made in earlier commit
messages, which is worth saying plainly.

**The poller was still hitting the network for enabled workspaces.** I claimed
it asks "not at all once it is yes". True for sixty seconds — `memoryEnabled`'s
positive TTL — after which an ENABLED workspace went back to the wire on every
other tick, a steady drip of `/datamates` for the life of the session. The
poller now keeps its own memo of BOTH answers on a five-minute TTL.

**A transient network error was rendered as "0 not synced".** `memoryStatus` is
deliberately three-way, with a comment saying an unreachable service must not be
reported as "this workspace has no memory" — and then the poller path called
`memoryEnabled`, which folds error into `false`, memoized that for five minutes,
and `memoryCounts` turned it into `unsynced: 0`. A failed request rendered as
"your memory is up to date". `unsynced` is now `number | null`; null means "not
known", and both call sites print the bare count instead of claiming zero. This
is the same defect as the sync toast, one layer down: an error wearing the
costume of a clean answer.

**The poller memo was keyed by workspace id alone.** Ids are tenant-local, so
after an account switch a same-numbered workspace in the new tenant inherited
the old tenant's answer for the whole TTL. Keyed by tenant and API URL now, the
same scoping the binding cache already uses.

**Unlink did not notify when the cache write failed.** I had guarded the
notification on a successful drop and called the difference unobservable when a
mutation survived. That was wrong: the server-side unlink has already happened,
and the resolve path does not depend on this file being rewritten — it drops the
revalidation stamp and records a lookup miss, so the next resolve hears
"unbound" regardless. Guarding on the write meant the pane kept naming a
workspace the project was no longer bound to, in the case where something had
already gone wrong.

**A rename did not wake the sidebar.** `bindingChanged` comes from
`sameBinding`, which compares identity — id, remote, path — because it also
gates the memory seed; widening it would re-seed a workspace on every rename.
The tile renders the name, so the rename is checked separately.

Also: the tile clears counts and the manage URL when the workspace actually
changes, so a rebind cannot show the old numbers under the new name (an
"unknown" outcome still leaves them standing, rather than blanking a working
tile over a blip); and a queued refresh no longer starts after the view is
disposed.

Tests: 504 pass, 3 new. Mutation-checked — reporting unknown as 0, memoizing an
error as "disabled", and ignoring renames each fail a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6

@cubic-dev-ai cubic-dev-ai 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.

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

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/altimate/workspace/manage.ts">

<violation number="1" location="packages/opencode/src/altimate/workspace/manage.ts:216">
P2: When the poller returns `unknown`, this line encodes it only as `unsynced: null`, but both status consumers render that as an ordinary memory count. Expose an explicit unknown state or render `unknown` so an outage is distinguishable from disabled/current, as promised by the status contract.</violation>
</file>

<file name="packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx">

<violation number="1" location="packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx:118">
P2: When credentials switch to a tenant whose workspace reuses the same numeric ID, this comparison treats the binding as unchanged and leaves the old status detail under the new workspace name. Compare the resolved account scope as well as the ID, or clear the detail whenever the binding scope changes.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

// and say nothing about sync.
if (status !== "enabled") {
const blocks = await MemoryStore.listAll({ directory })
return { local: blocks.length, unsynced: status === "disabled" ? 0 : null }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When the poller returns unknown, this line encodes it only as unsynced: null, but both status consumers render that as an ordinary memory count. Expose an explicit unknown state or render unknown so an outage is distinguishable from disabled/current, as promised by the status contract.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/workspace/manage.ts, line 216:

<comment>When the poller returns `unknown`, this line encodes it only as `unsynced: null`, but both status consumers render that as an ordinary memory count. Expose an explicit unknown state or render `unknown` so an outage is distinguishable from disabled/current, as promised by the status contract.</comment>

<file context>
@@ -194,16 +197,24 @@ export async function sync(directory: string): Promise<SyncReport> {
+      // and say nothing about sync.
+      if (status !== "enabled") {
+        const blocks = await MemoryStore.listAll({ directory })
+        return { local: blocks.length, unsynced: status === "disabled" ? 0 : null }
+      }
     }
</file context>

// real change; an "unknown" outcome deliberately leaves everything
// standing rather than blanking a working tile over a blip. (cubic P2
// on #1279.)
if (binding()?.datamateId !== outcome.binding.datamateId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When credentials switch to a tenant whose workspace reuses the same numeric ID, this comparison treats the binding as unchanged and leaves the old status detail under the new workspace name. Compare the resolved account scope as well as the ID, or clear the detail whenever the binding scope changes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx, line 118:

<comment>When credentials switch to a tenant whose workspace reuses the same numeric ID, this comparison treats the binding as unchanged and leaves the old status detail under the new workspace name. Compare the resolved account scope as well as the ID, or clear the detail whenever the binding scope changes.</comment>

<file context>
@@ -107,8 +108,23 @@ function View(props: { api: TuiPluginApi }) {
+        // real change; an "unknown" outcome deliberately leaves everything
+        // standing rather than blanking a working tile over a blip. (cubic P2
+        // on #1279.)
+        if (binding()?.datamateId !== outcome.binding.datamateId) {
+          setDetail(null)
+          setManageUrl(null)
</file context>

* is to make staleness visible needs the age, not a threshold. Reads the
* process-global store, so the TUI plugin realm sees the same map the sync
* writes (see `STORE_KEY` above). */
export function lastSuccessfulSyncAt(directory: string): number | null {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: lastSuccessfulSyncAt reads a per-thread map — the routine skill syncs run in the server worker, so the sidebar "skills synced Xm ago" line never sees them

The TUI (and this sidebar) renders on the main thread (src/cli/cmd/tui.ts:265), while the per-message sync that stamps lastSyncedAt runs inside the server worker (src/session/prompt.ts:393-394, behind the new Worker at tui.ts:171). The file header documents that Symbol.for/globalThis stores do not cross threads ("Threads do NOT share globalThis", lines 114-116; "a bind stamping an in-process map is invisible to the thread that serves the next turn", lines 183-190). So the comment here — "the TUI plugin realm sees the same map the sync writer writes" — only holds for main-thread writers: the bind-time sync (state.ts:651) and the /workspace refresh (manage.ts:125), not the routine POLL_INTERVAL_MS syncs the worker performs per turn.

In a normal TUI session the line therefore either stays hidden forever (no main-thread sync has run) or freezes at link/refresh time and grows without bound while the worker actually re-syncs every 5 minutes — reporting false staleness from the one line whose job is to make staleness visible. Consider a disk-backed signal, e.g. the manifest mtime already read by snapshotFingerprint (lines 193-202), which this file treats as the cross-thread source of truth for exactly this reason.


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

}
}
const blocks = await MemoryStore.listAll({ directory })
return { local: blocks.length, unsynced: await MemorySync.pendingCount(blocks, binding) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: The poller still drips a /datamates request every ~60s for an enabled workspace — the pollMemo fix covers the resolver but not this pendingCount call

When memoryEnabledForPoller answers "enabled" from the 5-minute pollMemo, this line falls through to pendingCount, whose gate at memory-sync.ts:736 re-resolves via memoryEnabledmemoryStatus. That path only short-circuits on the 60-second memoryEnabledCache (memory-sync.ts:183-184), with the network fetch at line 186 — and a pollMemo hit does not refresh memoryEnabledCache. For an enabled workspace with at least one local block, the 30s sidebar poll therefore fires one /datamates request per minute for the life of the session (each hit re-arms the 60s cache): the exact "steady drip of /datamates requests" the comment at memory-sync.ts:627-630 says the memoization eliminated, halved in rate but not removed. The manage-suite tests cannot see it because the stub workspace has memory off. Either write the positive back into memoryEnabledCache on a pollMemo "enabled" hit, or pass the resolved status into pendingCount so it skips the re-check.


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

* its current payload is the ordinary case, and naming it invites the reader to
* think something went wrong. */
function syncMessage(result: Manage.SyncReport): string {
if (result.gated) return "Nothing to sync — workspace memory is off for this project."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: gated has several producers — this message tells users their workspace memory is off when the real cause may be a failed local read

sync() returns gated: true not only when the workspace memory toggle is off, but also when the pilot flag is off (manage.ts:173), when there is no binding (manage.ts:175), and — reachable from this menu — when reading the local memory store fails (manage.ts:177-181). In the read-failure case the toast asserts "workspace memory is off for this project" with the success variant (failed/declined are both 0), which is misinformation about the user configuration rather than a report of what happened — the thing this PR rewrote the toast to guarantee. Consider a neutral "Nothing to sync" for the non-toggle causes, or threading a reason through gated.


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

export async function memoryEnabledForPoller(
binding: CachedBinding,
): Promise<"enabled" | "disabled" | "unknown"> {
const cached = memoryEnabledCache.get(binding.datamateId)

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 tenant-unscoped memoryEnabledCache is consulted before the tenant-scoped pollMemo, leaving a 60s cross-tenant window

The account-switch fix scoped pollMemo by tenant/API URL, but this first check keys only by datamateId, so a positive written under the previous tenant within the last 60s is served to the newly switched account — the same inheritance the comment at lines 633-636 describes, bounded to 60s. It is also redundant: memoryStatus makes the identical check at lines 183-184 on the memo-miss path, so removing this early return loses nothing except the unscoped shortcut and lets pollMemo be the authoritative — and correctly scoped — source.


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

}

function forgetBinding(directory: string, key: { tenant: string; apiUrl: string }): void {
let dropped = false

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: dropped is dead — assigned but never read, with void dropped only silencing the lint

Lines 446/452/471 are leftovers from the earlier if (dropped) guard that the comment itself describes as replaced ("an earlier version of this guarded on dropped"). Notification is now unconditional, so the flag, its assignment, and the void dropped consumption can all be deleted.


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

// (cubic P2 on #1279; an earlier version of this guarded on `dropped` and I
// wrongly called the difference unobservable.)
void dropped
notifyBindingChanged()

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 "notified even when the write failed" chain re-serves the stale row one refresh later

The justification in the comment (the next resolve asks the server, hears unbound, and the tile updates) holds for exactly one resolve when writeCache persistently fails: that resolve re-stamps lastValidatedAt (line 392) and re-enters forgetBinding (line 394 → this notify), which queues another refresh; that follow-up hits the early return at line 383 (stamp still warm) and returns the stale on-disk row as "bound" — so the tile that just cleared re-names the unlinked workspace until the stamp expires, and the cycle repeats every REVALIDATE_MS. The poll backstop would eventually do the same, so this is an exacerbation on an already-broken path (persistent cache-write failure), not a happy-path regression — but the notify makes the wrong state reappear immediately after the correct one. Stamping validation only after the drop actually persisted (or clearing the stamp on a failed drop) would make the notified refresh chain converge on unbound.


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

}
const b = binding()
if (!b) {
setManageUrl(null)

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: setManageUrl(null) here is unreachable-with-effect

Every path that reaches if (!b) already has manageUrl() null: the unbound branch cleared it at line 125, and the unknown-with-falsy-binding paths can only have a null manageUrl (it is set only under a truthy binding, and the only transition back to falsy goes through line 125). Harmless, but it is a third null-write on the unbound pass; either this line or the line-125 clear suffices.


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

function describeAge(at: number): string {
const seconds = Math.max(0, Math.round((Date.now() - at) / 1000))
if (seconds < 60) return "just now"
const minutes = Math.round(seconds / 60)

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: Double rounding narrows each age label window to ~30s — "1m ago" disappears after 89.5s

seconds is already rounded (line 60); rounding seconds / 60 again means elapsed 89.5s → seconds 90 → minutes 2 → "2m ago", so "1m ago" only covers a 30-second window (and 59.5s already shows "1m ago"). Rounding once with Math.floor gives each label a full 60s window and matches the coarse-but-monotone intent of the doc comment.

Suggested change
const minutes = Math.round(seconds / 60)
const minutes = Math.floor(seconds / 60)

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


/** Test seam: the poller memo is process-global and would otherwise leak between
* cases in the same file. */
export function resetPollMemoForTests(): void {

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: resetPollMemoForTests does not clear memoryEnabledCache, which memoryEnabledForPoller consults first — the new poller tests are order-dependent

memoryEnabledForPoller checks memoryEnabledCache (60s TTL, keyed by bare id) before pollMemo, and this seam clears only pollMemo. memory-sync.test.ts uses the same datamateId: 42 with memory_enabled: true (lines 77, 152, 164) and leaves live positives in the shared process-global cache with no afterAll cleanup — so running it before manage.test.ts in one process (explicit path order; bun runs all test files in a single process) makes "a poller resolves the workspace setting once" fail (memoryEnabledForPoller returns "enabled" with zero /datamates requests) and flips the null-vs-0 assertions. Default alphabetical discovery happens to run manage before memory-sync, which is why it passes today. Clearing both maps here (or memoryEnabledCache in the manage suite beforeEach) makes the tests order-independent.


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

#1279 sits on top of #1278, and three commits landed on the base while this
branch moved — the symlink guard, the three unlink defects, and the status/sweep
gating fixes. GitHub had this PR as CONFLICTING.

Both conflicts were additive rather than semantic: each side inserted new code at
the same point, and git could not tell they were independent.

`state.ts` — the base added `forgetBindingUnscoped` (the no-credentials unlink
path) exactly where this branch added the binding-change listener registry. Both
kept. The conflict split INSIDE `notifyBindingChanged`, so the closing braces
after the marker belonged to only one of the two blocks and the naive resolution
left the function unterminated; restored.

`manage.test.ts` — the two import blocks are a union, not a choice:
`onBindingChanged` and `resetPollMemoForTests` from this branch,
`resolveProjectIdentifier` and `pendingCount` from the base.

508 tests pass, typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant