Skip to content
51 changes: 46 additions & 5 deletions packages/opencode/src/altimate/workspace/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,16 @@ export interface StatusReport {
/** Blocks held locally for this project, and how many have not reached the
* workspace. `null` when memory is off — "not synced" and "not applicable" are
* different answers and a status line must not conflate them. */
memory: { local: number; unsynced: number } | null
/** `unsynced: null` means the workspace's memory setting could not be
* resolved, so how much is outstanding is genuinely unknown. Rendering it as
* 0 would tell the user their memory is current when nobody knows. */
memory: { local: number; unsynced: number | null } | null
skillsEnabled: boolean
/** When workspace skills last synced successfully, or null if they have not in
* this process. Null is genuinely "unknown", not "never" — the store is
* per-process, so a fresh session has not synced yet even for a project whose
* snapshot is current on disk. Callers must not render it as "never synced". */
skillsSyncedAt: number | null
}

export interface RefreshReport {
Expand Down Expand Up @@ -73,12 +81,27 @@ export interface SyncReport {
*
* Cheap enough for a status line: one binding read from the local cache and, when
* memory is on, one index read. No network. */
export async function status(directory: string): Promise<StatusReport> {
export async function status(
directory: string,
opts: {
/** Set false for pollers. Resolves the workspace's memory setting through the
* poller path, which asks the service at most once every few minutes when the
* answer is "no" and not at all once it is "yes" — rather than on every tick,
* which is what asking the positive-only cache directly would cost.
*
* It does NOT mean "never touch the network": an earlier version of this took
* that literally, and the result was a sidebar whose counts never appeared at
* all on a session where nothing else warmed the cache — the exact drift the
* line exists to surface. Bounded, not forbidden. */
allowNetwork?: boolean
} = {},
): Promise<StatusReport> {
const binding = await readLocalBinding(directory).catch(() => null)
return {
binding,
memory: await memoryCounts(directory),
memory: await memoryCounts(directory, opts.allowNetwork !== false),
skillsEnabled: SkillSync.isEnabled(),
skillsSyncedAt: SkillSync.lastSuccessfulSyncAt(directory),
}
}

Expand Down Expand Up @@ -176,11 +199,29 @@ export async function sync(directory: string): Promise<SyncReport> {
/** Local block count and how many have not reached the workspace, or null when
* memory is off. Best-effort: a status line must not fail because an index read
* did. */
async function memoryCounts(directory: string): Promise<{ local: number; unsynced: number } | null> {
async function memoryCounts(
directory: string,
allowNetwork: boolean,
): Promise<{ local: number; unsynced: number | null } | null> {
if (!MemorySync.isEnabled()) return null
try {
const blocks = await MemoryStore.listAll({ directory })
const binding = await readLocalBinding(directory).catch(() => null)
// A poller resolves through the rate-limited path; everything else asks
// directly. Either way the answer is real, so the counts a status line shows
// agree with what a sweep would actually send.
if (!allowNetwork && binding) {
const status = await MemorySync.memoryEnabledForPoller(binding)
// "disabled" is a real answer: memory is off, so nothing is outstanding
// and 0 is the truth. "unknown" is not — the service could not be
// reached, and reporting 0 there claims the workspace is up to date on
// the strength of a failed request. Say how many blocks exist locally,
// 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>

}
}
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.

} catch (err) {
log.warn("could not count local memory for the workspace status", { err: String(err) })
Expand Down
64 changes: 64 additions & 0 deletions packages/opencode/src/altimate/workspace/memory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { TRAINING_META_COMMENT } from "@/altimate/training/types"
import { resolveBinding as resolveProjectBinding, type CachedBinding } from "./state"
import { indexKey, readIndex, readIndexEntry, recordIndexEntry } from "./memory-index"
import { WorkspaceApi } from "./api-client"
import { AltimateApi } from "@/altimate/api/client"
import {
LIST_LIMIT,
MemoryApi,
Expand Down Expand Up @@ -616,6 +617,69 @@ async function runQueue<T>(
return { ok, failed, declined, skipped }
}

/** How long a poller trusts either answer. Deliberately separate from
* `MEMORY_ENABLED_TTL_MS` and from the main cache: `memoryEnabled` stays
* positive-only with a 60s TTL so the WRITE path picks up a newly enabled
* workspace almost at once, which is the property that matters for not losing
* memory. A poller can afford to be a few minutes behind; what it cannot afford
* is a request every tick.
*
* Both answers are memoized, not just the "no". Reusing the write path's 60s
* positive meant that a minute after the first check an ENABLED workspace went
* back to the network on every other tick — a steady drip of `/datamates`
* requests for the life of the session. (cubic P2 on #1279.) */
const POLL_TTL_MS = 5 * 60 * 1000

/** Keyed by tenant and API URL as well as workspace id. Workspace ids are
* tenant-local, so a bare id let a same-numbered workspace in a NEWLY switched
* account inherit the previous tenant's answer and hide its unsynced count for
* the whole TTL. (cubic P2 on #1279.) */
const pollMemo = new Map<string, { at: number; status: "enabled" | "disabled" }>()

async function pollMemoKey(binding: CachedBinding): Promise<string> {
try {
const creds = await AltimateApi.getCredentials()
return `${creds.altimateInstanceName}|${creds.altimateUrl}|${binding.datamateId}`
} catch {
// No credentials resolved: fall back to an id-only key. The caller is about
// to fail its lookup anyway, and a wrong-tenant hit is impossible when
// there is no tenant.
return `?|?|${binding.datamateId}`
}
}

/** Whether this workspace has memory on, for a caller that polls.
*
* Three-way on purpose. `memoryEnabled` folds "the service could not be
* reached" into `false`, which is right for the write path — it fails closed so
* an outage cannot leak a mirror — but wrong for a status line: rendering an
* unreachable service as "0 not synced" tells the user their memory is current
* when nobody knows. `memoryStatus` already draws that distinction; this used
* to throw it away and then memoize the result for five minutes. (cubic P2 on
* #1279.)
*
* An "unknown" is never memoized: the next tick should ask again rather than
* inherit a network blip. */
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.

if (cached && Date.now() - cached.checkedAt < MEMORY_ENABLED_TTL_MS) return "enabled"
const key = await pollMemoKey(binding)
const memo = pollMemo.get(key)
if (memo && Date.now() - memo.at < POLL_TTL_MS) return memo.status
const status = await memoryStatus(binding)
if (status === "error") return "unknown"
pollMemo.set(key, { at: Date.now(), status })
return status
}

/** 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.

pollMemo.clear()
}

/** Split blocks into those the workspace still needs and those already there at
* their current payload.
*
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/src/altimate/workspace/skill-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,18 @@ export async function flushPendingSyncs(timeoutMs = 30_000): Promise<void> {
}
}

/** When this project's workspace skills last synced successfully, or null if
* they never have in this process.
*
* Exposed for the sidebar. `recentlySynced` answers a boolean against the poll
* interval, which cannot say "6 minutes ago" — and a status line whose whole job
* 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.

return lastSyncedAt.get(path.resolve(directory)) ?? null
}

/** Has this project's snapshot been checked within the poll interval? Callers
* on a per-message path use this to skip the network entirely. */
export async function recentlySynced(directory: string): Promise<boolean> {
Expand Down
73 changes: 73 additions & 0 deletions packages/opencode/src/altimate/workspace/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,45 @@ export async function resolveBindingOutcome(directory: string): Promise<BindingO

/** Drop a cached row the server no longer recognises, so later reads do not
* resurrect it from disk. */
/** Listeners fired when THIS process changes a project's binding.
*
* Exists for the sidebar tile, which otherwise learns about a link or unlink
* only on its next 30s poll: the user hits Unlink, gets a success toast, and
* watches the pane next to it keep naming the workspace for up to half a
* minute. The stale half is the one that looks authoritative.
*
* Deliberately a plain listener set rather than an event bus. Every binding
* write already funnels through this module, so one hook here covers link,
* unlink and rebind; a bus would mean plumbing a dependency through each
* writer for a single subscriber. The poll stays as the backstop — it is what
* catches a change made by ANOTHER process, which no in-process notifier can
* see. */
const bindingChangeListeners = new Set<() => void>()

export function onBindingChanged(listener: () => void): () => void {
bindingChangeListeners.add(listener)
return () => {
bindingChangeListeners.delete(listener)
}
}

/** Never throws: a listener is a UI refresh, and one bad subscriber must not
* fail the link or unlink that notified it. Iterates a copy so a listener that
* unsubscribes itself mid-notify cannot skip the next one. */
function notifyBindingChanged(): void {
// Snapshot first: a listener may subscribe or unsubscribe while being
// notified, and iterating the live Set would then walk a collection that
// changed underneath us.
const listeners = Array.from(bindingChangeListeners)
for (const listener of listeners) {
try {
listener()
} catch (err) {
log.warn("a binding-change listener threw", { err: String(err) })
}
}
}

/** Drop a directory's row without checking which account the cache belongs to.
*
* Only for the no-credentials unlink path above. The scoped `forgetBinding` is
Expand All @@ -421,14 +460,33 @@ function forgetBindingUnscoped(directory: string): void {
}

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.

try {
const cache = readCache()
if (!cache || cache.tenant !== key.tenant || cache.apiUrl !== key.apiUrl) return
delete cache.bindings[canonicalizeKey(directory)]
writeCache(cache)
dropped = true
} catch (err) {
log.warn("could not drop a binding the server no longer recognises", { err: String(err) })
}
// Outside the try on purpose. A listener is a UI refresh; its failure is not
// a failed cache drop, and notifying from inside would log a throwing
// subscriber as "could not drop a binding" — a misleading line about a write
// that had already succeeded.
//
// Notified even when the write FAILED, which is not obvious. The server-side
// unlink has already happened by the time we get here, and the resolve path
// does not depend on this file having been rewritten: `clearLocalBinding`
// drops the revalidation stamp and records a lookup miss, so the next resolve
// asks the server, hears "unbound", and the tile updates. Skipping the
// notification on a failed write left the pane naming a workspace this
// project is no longer bound to until the next poll — the exact lag the
// notifier exists to remove, in the case where something is already wrong.
// (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.

}

/** The server's answer for this project, with no cache consulted. */
Expand Down Expand Up @@ -563,13 +621,17 @@ export async function recordApprovedBinding(
// synchronously on the `link` path, which awaits the seed.
let bindingChanged = true
let alreadySeeded = false
/** The name as it was on disk, so a rename can be detected even when the
* binding's identity is unchanged. `undefined` when there was no prior row. */
let priorName: string | undefined
try {
const existing = readCache()
const cache: CacheFile =
existing && existing.tenant === key.tenant && existing.apiUrl === key.apiUrl
? existing
: { version: CACHE_VERSION, tenant: key.tenant, apiUrl: key.apiUrl, bindings: {} }
const prior = cache.bindings[canonicalizeKey(directory)]
priorName = prior?.datamateName
bindingChanged = !prior || !sameBinding(prior, binding)
alreadySeeded = !bindingChanged && !!prior?.seededAt
// Carry the seed marker across a warm so a completed seed is not repeated.
Expand All @@ -586,6 +648,17 @@ export async function recordApprovedBinding(
})
}

// Only when something a subscriber could render actually changed. A warm
// cache re-read must not wake the tile on every resolve.
//
// `bindingChanged` alone is not the right test: `sameBinding` compares
// identity (id, remote, path) because it also gates the memory seed, and
// widening it would re-seed a whole workspace every time someone renamed one.
// But the sidebar renders `datamateName`, so a rename is a visible change
// with an unchanged identity. Checked separately for that reason. (cubic P2
// on #1279.)
if (bindingChanged || priorName !== binding.datamateName) notifyBindingChanged()

// altimate_change start - seed the workspace with the memory this machine
// already holds. Deliberately OUTSIDE the try above: a failed cache write
// must not skip the backfill, and a failed backfill must not read as a failed
Expand Down
Loading
Loading