Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 76 additions & 19 deletions packages/opencode/src/altimate/workspace/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,17 @@ 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. */
/** `unsynced: null` means the workspace's memory setting is not known from
* cache and status did not go to the network to find out. Rendering that as
/** `unsynced: null` means the workspace's memory setting is not known — from
* cache for the menu, which never asks; from the bounded poller path for the
* sidebar, when the service could not be reached. Rendering that 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 @@ -92,25 +98,59 @@ export interface SyncReport {
/** What the project is linked to and how far its local state has drifted.
*
* Cheap enough for a status line: one binding read from the local cache and, when
* memory is on, one index read. Network only when there is no cached row. */
export async function status(directory: string): Promise<StatusReport> {
// The cached row first, and the resolver only when there is none. A fresh
// clone, or a new machine, whose project is still bound server-side has no
// cached row, and reading only the cache answered "this project is not
// linked" with a lone Done — so that case asks. But the resolver revalidates
// a cached row too, and on the first call of a process nothing has been
// validated yet: the menu then sat on the API's full timeout when the
// service was unreachable. A cached row is taken as it is here; the poll and
// the operations behind the menu are what revalidate it.
* memory is on, one index read. Network only when there is no cached row, or
* on the poller path. */
export async function status(
directory: string,
opts: {
/** Set for the sidebar poller. Resolves the workspace's memory setting
* through the poller path — the service is asked at most once every few
* minutes when the answer is "no" and not at all once it is "yes" — because
* on a cold cache nothing else would ever warm it, and the counts would
* simply never appear. The binding goes through the resolver too: the
* poll runs in the background, and it is what revalidates a cached row.
*
* Unset for the `/workspace` menu, which is awaited before the dialog can
* open and must NOT wait on the network: there the setting is read from
* cache alone and reported as unknown (`null`) if not held, and a cached
* row is taken as it is. `sync` does the live check. */
poll?: boolean
/** A binding the caller has already resolved this pass. The sidebar
* resolves before it asks for status; resolving again here doubled the
* requests during an outage, when neither answer is memoized. */
binding?: CachedBinding | null
} = {},
): Promise<StatusReport> {
// The cached row first, and the resolver only when there is none — for the
// menu. A fresh clone, or a new machine, whose project is still bound
// server-side has no cached row, and reading only the cache answered "this
// project is not linked" with a lone Done — so that case asks. But the
// resolver revalidates a cached row too, and on the first call of a process
// nothing has been validated yet: the menu then sat on the API's full
// timeout when the service was unreachable.
const binding =
(await readLocalBinding(directory).catch(() => null)) ?? (await resolveBinding(directory).catch(() => null))
opts.binding !== undefined
? opts.binding
: opts.poll
? await resolveBinding(directory).catch(() => null)
: ((await readLocalBinding(directory).catch(() => null)) ??
(await resolveBinding(directory).catch(() => null)))
return {
binding,
memory: await memoryCounts(directory, binding),
memory: await memoryCounts(directory, binding, opts.poll === true),
skillsEnabled: SkillSync.isEnabled(),
skillsSyncedAt: await skillsSyncedAt(directory, binding),
}
}

/** The age of the last clean skill sync FOR THIS BINDING, or null. */
async function skillsSyncedAt(directory: string, binding: CachedBinding | null): Promise<number | null> {
if (!binding) return null
const scope = await currentScope()
if (!scope) return null
return SkillSync.lastSuccessfulSyncAt(directory, { datamateId: binding.datamateId, ...scope })
}

/** Pull: bring local state in line with the workspace.
*
* Both halves are attempted even if one fails — they are independent, and a
Expand Down Expand Up @@ -221,18 +261,35 @@ export async function sync(directory: string): Promise<SyncReport> {
* accurate claim — nothing is pending against a workspace that accepts nothing.
* Best-effort: a status line must not fail because an index read did.
*
* Cache-only. `status` is awaited before the `/workspace` dialog can appear,
* so it must not sit on the network: the enablement check behind `pendingCount`
* is a GET with a 15s budget, and on a slow or dead link the menu looked like it
* did nothing. When the setting is not known from cache the count is `null` —
* unknown — and `sync` does the live check. */
* The enablement policy differs by caller, and both are deliberate.
*
* The `/workspace` menu (`poll` false) is awaited before the dialog can appear,
* so it reads the setting from cache alone: the check behind `pendingCount` is a
* GET with a 15s budget, and on a slow or dead link the menu looked like it did
* nothing. Not known from cache is `null`, and `sync` does the live check.
*
* The sidebar poller (`poll` true) may ask, on the rate-limited poller path.
* It runs in the background, so a wait costs nothing visible — and on a cold
* cache it is the only thing that will ever warm the answer the menu then
* reads. Either way, "unknown" is reported as unknown, never as zero. */
async function memoryCounts(
directory: string,
binding: CachedBinding | null,
poll: boolean,
): Promise<{ local: number; unsynced: number | null } | null> {
if (!MemorySync.isEnabled()) return null
try {
const blocks = await MemoryStore.listAll({ directory })
if (poll && 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.
if (status !== "enabled") return { local: blocks.length, unsynced: status === "disabled" ? 0 : null }
// Enablement is settled; `pendingCount`'s own gate must not re-ask.
return { local: blocks.length, unsynced: await MemorySync.pendingCount(blocks, binding, { trustEnabled: true }) }
}
return { local: blocks.length, unsynced: await MemorySync.pendingCount(blocks, binding, { network: false }) }
} catch (err) {
log.warn("could not count local memory for the workspace status", { err: String(err) })
Expand Down
111 changes: 109 additions & 2 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 @@ -207,8 +208,17 @@ async function memoryEnabled(binding: CachedBinding): Promise<boolean> {
/** Three-way, because a read that cannot reach the service must not be reported
* as "this workspace has no memory" — that reads as success while destroying
* whatever the session already had. */
async function memoryStatus(binding: CachedBinding): Promise<"enabled" | "disabled" | "error"> {
const cached = memoryEnabledCache.get(binding.datamateId)
async function memoryStatus(
binding: CachedBinding,
opts: {
/** Skip the positive cache and ask. The cache is keyed by bare workspace
* id, which is safe for the write path (its credentials are fixed) but not
* for a caller whose own memo is tenant-scoped: on a memo miss it must not
* inherit a positive written under a previous account. */
fresh?: boolean
} = {},
): Promise<"enabled" | "disabled" | "error"> {
const cached = opts.fresh ? undefined : memoryEnabledCache.get(binding.datamateId)
if (cached && Date.now() - cached.checkedAt < MEMORY_ENABLED_TTL_MS) return "enabled"
try {
const workspaces = await WorkspaceApi.listDatamates()
Expand Down Expand Up @@ -660,6 +670,96 @@ async function runQueue<T>(
return { ok, failed, declined, skipped, deferred }
}

/** 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
/** How long the poller sits on an "unknown" before asking again. Not
* memoizing it at all meant every tick during an outage asked — and a queued
* re-run after a self-adoption asked twice in one tick. One poll interval:
* the tile shows no counts until the next tick either way. */
const POLL_UNKNOWN_TTL_MS = 30 * 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" | "unknown" }>()
const pollInFlight = new Map<string, Promise<"enabled" | "disabled" | "unknown">>()

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"> {
// The scoped memo is the poller's only cache. `memoryEnabledCache` is keyed by
// bare workspace id — right for the write path, which is tenant-bound by its
// credentials — but consulting it here reopened a 60s cross-tenant window the
// scoped memo had closed: a positive written under the previous account was
// served to a same-numbered workspace in the next. One extra request per five
// minutes per tenant is the price, and `memoryStatus` still warms both.
const key = await pollMemoKey(binding)
const memo = pollMemo.get(key)
if (memo && Date.now() - memo.at < (memo.status === "unknown" ? POLL_UNKNOWN_TTL_MS : POLL_TTL_MS))
return memo.status
// The in-flight ask is memoized too, not only the settled answer. Two
// refreshes overlapping on a cold memo — a remount while a slow one is
// still out — both saw it empty and both put a request on the wire.
const pending = pollInFlight.get(key)
if (pending) return pending
const ask = (async () => {
try {
const status = await memoryStatus(binding, { fresh: true })
const answer = status === "error" ? ("unknown" as const) : status
pollMemo.set(key, { at: Date.now(), status: answer })
return answer
} finally {
pollInFlight.delete(key)
}
})()
pollInFlight.set(key, ask)
return ask
}

/** Test seam: the poller memo is process-global and would otherwise leak between
* cases in the same file. */
export function resetPollMemoForTests(): void {
Comment thread
sahrizvi marked this conversation as resolved.
pollMemo.clear()
pollInFlight.clear()
memoryEnabledCache.clear()
memoryDisabledMemo.clear()
}

/** Split blocks into those the workspace still needs and those already there at
* their current payload.
*
Expand Down Expand Up @@ -718,10 +818,17 @@ export async function pendingCount(
* known"), never wait on the network. The default asks, and is what a
* sweep wants. */
network?: boolean
/** The caller has already resolved the workspace's setting as enabled —
* the poller does, on its own rate-limited path — so the gate here must
* not ask again. Without this the poll dripped a `/datamates` request every
* 60s once the write path's positive expired, which is the exact drip the
* poller memo exists to stop. */
trustEnabled?: boolean
} = {},
): Promise<number | null> {
if (blocks.length === 0) return 0
if (!isEnabled()) return 0
if (opts.trustEnabled && binding) return partitionPending(blocks, binding, await readIndex()).pending.length
if (opts.network === false && binding) {
const cached = memoryEnabledCached(binding)
if (cached === "unknown") return null
Expand Down
Loading
Loading