Skip to content
Draft
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
12 changes: 8 additions & 4 deletions packages/opencode/src/altimate/workspace/contents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export async function workspaceSkills(directory: string, datamateId: number): Pr
type Summary = { integrations: string[] | null; memoryEnabled: boolean | null; knowledge: WorkspaceKnowledge | null }
const UNKNOWN: Summary = { integrations: null, memoryEnabled: null, knowledge: null }
/** `complete`: everything the response promised was read, so the entry may be reused. */
type Fetched = { value: Summary; complete: boolean }
type Fetched = { value: Summary; complete: boolean; failed?: boolean }
/** Keyed by account AND workspace id: ids are per tenant, so another account's workspace with the
* same id must never be answered from this cache. */
const summaries = new Map<string, { at: number; value?: Summary; pending?: Promise<Fetched> }>()
Expand Down Expand Up @@ -239,7 +239,7 @@ async function fetchSummary(datamateId: number, creds: Credentials): Promise<Fet
complete: !(knowledge?.kind === "selected" && knowledge.names === null),
}
} catch {
return { value: UNKNOWN, complete: false }
return { value: UNKNOWN, complete: false, failed: true }
}
}

Expand All @@ -253,10 +253,14 @@ export async function workspaceSummary(datamateId: number, now = Date.now(), wai
if (hit?.value && now - hit.at < SUMMARY_TTL_MS) return hit.value
let pending = hit?.pending
if (!pending) {
const previous = hit?.value
pending = fetchSummary(datamateId, account.creds).then((fetched) => {
// A failed refresh keeps the answer it was refreshing, filed with `at: 0` so the next step
// retries. Replacing it with "not known" dropped lines from the cached system prefix.
const kept: Fetched = fetched.failed && previous ? { value: previous, complete: false } : fetched
// An incomplete fetch is kept for this step only, so the next step retries.
summaries.set(key, { at: fetched.complete ? Date.now() : 0, value: fetched.value })
return fetched
summaries.set(key, { at: kept.complete ? Date.now() : 0, value: kept.value })
return kept
})
summaries.set(key, { at: hit?.at ?? 0, value: hit?.value, pending })
}
Expand Down
73 changes: 43 additions & 30 deletions packages/opencode/src/altimate/workspace/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ import { workspaceLabel } from "./workspace-name"
import { isEnabled } from "./engine-seams"
import { Instance } from "../../project/instance"
import { AltimateApi } from "../api/client"
import { memoryEnabledCached } from "./memory-sync"
import { memoryEnabledLastKnown } from "./memory-sync"

/** Independent of `awareness.ts`'s MAX_SECTION_CHARS (2,000) — this section is a short,
* fixed-shape identity statement, not an open-ended list of served integrations, so a
* much smaller ceiling is enough. The label is budgeted separately (`MAX_LABEL_CHARS`
* in `workspace-name.ts`) so the cap here is defense in depth and never cuts the
* instruction itself: the longest fixed shape (pinned, stale, with the team-memory line)
* instruction itself: the longest fixed shape (pinned, with the team-memory line)
* is ~1,550 characters before the label, and a label at its budget still leaves room. A
* test renders every shape with a budget-sized label and checks the name survives. */
export const MAX_SECTION_CHARS = 2_000
Expand Down Expand Up @@ -123,6 +123,10 @@ function teamMemoryLine(tools: MemoryTools = { read: true, write: true }): strin
return undefined
}

function lastCheckNote(verifyNoun: "link" | "pin"): string {
return `The ${verifyNoun} is re-checked every few minutes, so a change made elsewhere may not show yet.`
}

function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string {
if (outcome.status === "bound") {
const id = String(outcome.binding.datamateId)
Expand All @@ -140,13 +144,17 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string {
: "This project was last known to be linked to"
const verifyNoun = pinned ? "pin" : "link"
const memoryLine = opts.teamMemory ? teamMemoryLine(opts.memoryTools) : undefined
// This section sits in the cached system prefix and renders on every step, so a verified
// link reads the same whether its memo is fresh or not. A stale outcome is different: only
// the resolver marks one, when the server could not confirm the link, and that state already
// drops contents and the memory line, so it keeps its own hedged sentence.
return [
HEADING,
"",
(outcome.stale
? `${subjectPast} Altimate Workspace id ${id}; ${named}. ` +
`The ${verifyNoun} could not be re-verified just now, so it may since have changed.`
: `${subject} Altimate Workspace id ${id}; ${named}.`) +
: `${subject} Altimate Workspace id ${id}; ${named}. ${lastCheckNote(verifyNoun)}`) +
(pinned
? " Skills and memory follow this workspace, and so does warehouse tool routing unless " +
"integrations are set to local, even where the project's own link names a different one."
Expand All @@ -167,17 +175,13 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string {
// come up normally. The active instruction is scoped to TRIGGER (a real identity
// question), not "any mention of the word" — the earlier draft's "whenever
// 'workspace' comes up" phrasing was exactly the over-triggering this fixes.
// Same wording for a fresh and a memoised miss, for the cache reason above.
return [
HEADING,
"",
outcome.stale
? "No Altimate Workspace was linked to this project as of the last check, up to five " +
"minutes ago; a link made elsewhere since then would not show yet."
: "No Altimate Workspace is linked to this project.",
outcome.stale
? `When ${TRIGGER}, say that none was linked as of the last check, that a link made ` +
"since then may not show yet, and offer to help link one."
: `When ${TRIGGER}, say plainly that none is linked yet and offer to help link one.`,
`No Altimate Workspace is linked to this project as of the last check. ${lastCheckNote("link")}`,
`When ${TRIGGER}, say that none was linked as of the last check, that a link made ` +
"elsewhere may take a few minutes to show, and offer to help link one.",
LINK_HINT,
"Outside such a question, other services' own \"workspace\" concepts (e.g. a " +
"Databricks workspace) are unrelated — discuss them normally, with no linking " +
Expand Down Expand Up @@ -239,7 +243,7 @@ function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string {
export const OUTCOME_MEMO_MS = 30_000
/** How long prompt assembly waits for a resolve before rendering what it has.
* Past this the resolve keeps running and fills the memo for the next step; this
* step renders the last known outcome (marked stale if it named a workspace) or
* step renders the last known outcome as it was, or
* "unknown". The `git remote` probe inside the resolver is synchronous and can
* hold the loop for up to three seconds on a hung git; that is the resolver's
* cost on every caller and is not changed here. */
Expand Down Expand Up @@ -352,42 +356,47 @@ async function accountScope(): Promise<AccountScope | null> {
return { tenant: c.altimateInstanceName, apiUrl: c.altimateUrl, account }
}

/** The team-memory line is shown only for a bound workspace known to have memory
* enabled. "Unknown" is not enough: the write path uploads only on a confirmed
* "enabled", so advertising sync on an unconfirmed or failed check would tell the
* model teammates will read a block that stays on this machine. The memo is
* populated by the first enablement check of the session (the backfill sweep on
* bind, or the first mirror), so the line appears from the next turn on. */
/** The team-memory line is shown only for a bound workspace whose last enablement check
* said "enabled". Never checked is not enough: the write path uploads only on a confirmed
* "enabled", so advertising sync before any check would tell the model teammates will read
* a block that stays on this machine. The answer is the last one any check gave (backfill on
* bind, a mirror, the TUI poll), with no expiry on read: a 60 s window here dropped the line
* from the cached system prefix a minute after each check. */
/** A pin that actually governs this directory: valid and scoped to a root containing it. A
* malformed pin, or one for another folder, fails closed, so no advice may rely on it. */
function governingPin(directory: string): boolean {
const pin = readPin()
return pin.kind === "valid" && resolveWithinRoot(directory, pin.root) !== null
}

function renderOptions(outcome: BindingOutcome, directory: string, memoryTools?: MemoryTools): RenderOptions {
function renderOptions(
outcome: BindingOutcome,
directory: string,
scope: AccountScope,
memoryTools?: MemoryTools,
): RenderOptions {
if (outcome.status === "unknown") return { pinned: governingPin(directory) }
if (outcome.status !== "bound") return {}
// A stale outcome is "last known … may since have changed": promising that a
// save syncs to that workspace would contradict the line above it.
// A stale outcome is a link the server could not confirm: promising that a save
// syncs to that workspace would not be true of it.
if (outcome.stale) return {}
return { teamMemory: memoryEnabledCached(outcome.binding) === "enabled", memoryTools }
return { teamMemory: memoryEnabledLastKnown(outcome.binding, scope) === "enabled", memoryTools }
}

function keyFor(scope: AccountScope, directory: string): string {
return `${scope.tenant}|${scope.apiUrl}|${scope.account}|${directory}`
}

/** What to render when the resolve has not settled inside the deadline: the last
* known outcome for this key, marked stale if it named a workspace; failing that,
/** What to render when the resolve has not settled inside the deadline: the previous
* outcome for this key (bound or unbound), as it was; failing that,
* the binding the local cache holds for the SAME account — read under the
* current credentials and used only if they still match the key, so a switch
* during the wait cannot surface the other account's cache; else unknown.
*
* The local-cache read deliberately repeats a slice of what
* `resolveBindingOutcome` does ("serve the cached row as stale when the server
* cannot be asked"): the resolver is the thing that has not answered yet, so the
* only way to say "last known" without waiting for it is to look at the same
* only way to name a cached link without waiting for it is to look at the same
* row it would have looked at. The resolver stays the owner of what the row
* MEANS; this is a read of it under the one rule the resolver would apply. */
async function lastKnown(key: string, directory: string): Promise<BindingOutcome> {
Expand All @@ -396,7 +405,11 @@ async function lastKnown(key: string, directory: string): Promise<BindingOutcome
const scope = await accountScope().catch(() => null)
if (!scope || keyFor(scope, directory) !== key) return { status: "unknown" }
const previous = memo.get(key)?.outcome
if (previous?.status === "bound") return { ...previous, stale: true }
// A re-check still in flight is not a failed one: render the previous outcome as it was,
// linked or not. Marking a link stale here dropped the workspace contents and memory line,
// and letting a previous "unbound" fall through rendered "could not be verified"; either
// re-created the cached system prefix for one step. Only the resolver marks an outcome stale.
if (previous && previous.status !== "unknown") return previous
// Under a pin the project's own cached link is not the answer — the pin outranks it
// in the resolver, and an invalid pin fails closed — so a cold memo renders unknown
// rather than the workspace the pin exists to override.
Expand Down Expand Up @@ -431,8 +444,8 @@ async function lastKnown(key: string, directory: string): Promise<BindingOutcome
* as a separate block under its own cap: `render` drops the identity section entirely when it
* overflows, and a long skill list must not take the link statement down with it. */
async function withContents(outcome: BindingOutcome, directory: string, identity: string): Promise<string> {
// A stale outcome is a link that could not be confirmed and may have changed; the identity text
// says "last known". Listing that workspace's contents would present it as current, so skip them.
// A stale outcome is a link the server could not confirm and that may have changed. Listing
// that workspace's contents would present it as current, so skip them.
if (!identity || outcome.status !== "bound" || outcome.stale) return identity
const contents = await Contents.section(directory, outcome.binding.datamateId).catch(() => "")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

MINOR (not blocking, pre-existing): the contents block still depends on request timing

  • A cold summary slower than SUMMARY_WAIT_MS (300 ms): step 1 lacks the Integrations, Workspace memory and Knowledge lines and step 2 has them. One extra full write per process on a linked project.
  • A failed refresh at the 5-minute TTL overwrites the good cached value with UNKNOWN (contents.ts:258), so those lines vanish for that step. Keeping hit.value on a failed fetch fixes this one. A merely slow refresh already keeps the old value.
  • The skills read races SKILLS_WAIT_MS. Under heavy machine load it rendered "could not be read just now".

I reproduced the first two with a stubbed snapshot and summary, and saw the third in a load-affected run. This runs on every step, so the PR does not introduce it. It is safe to do in a follow-up, but it belongs in the list of what still moves the prefix.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Failed-refresh case fixed in 36ec951 (keeps hit.value, retries next step, with a test); the cold-summary and skills-read races are left as follow-ups and listed in the PR body as remaining prefix movers.

return contents ? `${identity}\n\n${contents}` : identity
Expand Down Expand Up @@ -466,15 +479,15 @@ export async function systemSection(opts: { memoryTools?: MemoryTools } = {}): P
})
const key = keyFor(scope, directory)
const hit = memo.get(key)
if (fresh(hit)) return withContents(hit!.outcome, directory, render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome, directory, opts.memoryTools)))
if (fresh(hit)) return withContents(hit!.outcome, directory, render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome, directory, scope, opts.memoryTools)))
// The fallback is itself raced against a small budget, so the wait is
// bounded by RESOLVE_DEADLINE_MS + FALLBACK_BUDGET_MS, not by the disk.
const deadline = after(RESOLVE_DEADLINE_MS, () =>
Promise.race([lastKnown(key, directory), after(FALLBACK_BUDGET_MS, () => ({ status: "unknown" }))]),
)
try {
const outcome = await Promise.race([resolve(key, directory), deadline])
return withContents(outcome, directory, render(outcome, MAX_SECTION_CHARS, renderOptions(outcome, directory, opts.memoryTools)))
return withContents(outcome, directory, render(outcome, MAX_SECTION_CHARS, renderOptions(outcome, directory, scope, opts.memoryTools)))
} finally {
for (const t of timers) clearTimeout(t)
}
Expand Down
33 changes: 33 additions & 0 deletions packages/opencode/src/altimate/workspace/memory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,34 @@ export function memoryEnabledCached(binding: CachedBinding): "enabled" | "disabl
return "unknown"
}

/** The last answer any enablement check gave, per account and workspace, with no expiry on
* read. For the system prompt's team-memory line, which renders on every step into the cached
* prefix: reading the 60 s positive TTL there dropped the line a minute after each check and
* re-created the prefix. Keyed by tenant and API URL as well as id (ids are tenant-local).
* A failed or inconclusive check keeps the previous answer; a definite one replaces it. */
const memoryVerdict = new Map<string, "enabled" | "disabled">()

function verdictKey(tenant: string, apiUrl: string, datamateId: number): string {
return `${tenant}|${apiUrl}|${datamateId}`
}

async function verdictKeyFor(binding: CachedBinding): Promise<string | undefined> {
const creds = await AltimateApi.getCredentials().catch(() => undefined)
if (!creds?.altimateInstanceName || !creds.altimateUrl) return undefined
return verdictKey(creds.altimateInstanceName, creds.altimateUrl, binding.datamateId)
}

/** The workspace's memory setting for a per-step reader: a check inside the positive or
* negative TTL first, else the last answer recorded for this account. Never the network. */
export function memoryEnabledLastKnown(
binding: CachedBinding,
account: { tenant: string; apiUrl: string },
): "enabled" | "disabled" | "unknown" {
const cached = memoryEnabledCached(binding)
if (cached !== "unknown") return cached
return memoryVerdict.get(verdictKey(account.tenant, account.apiUrl, binding.datamateId)) ?? "unknown"
}

/** Test seam: both memos are process-global, and an earlier case's answer
* would otherwise leak into a later one. */
/** Test seam: record the workspace's "memory off" answer without a request. */
Expand All @@ -290,6 +318,7 @@ export function noteMemoryDisabledForTests(datamateId: number): void {
export function resetEnablementMemoForTests(): void {
memoryEnabledCache.clear()
memoryDisabledMemo.clear()
memoryVerdict.clear()
}

/** Warn once per workspace, not once per write. */
Expand Down Expand Up @@ -337,6 +366,8 @@ async function memoryStatus(
): Promise<MemoryStatusOutcome> {
const cached = opts.fresh ? undefined : memoryEnabledCache.get(binding.datamateId)
if (cached && Date.now() - cached.checkedAt < MEMORY_ENABLED_TTL_MS) return "enabled"
// Read before the request, so the answer is filed under the account that asked.
const verdictAt = await verdictKeyFor(binding)
try {
const workspaces = await WorkspaceApi.listDatamates()
const match = workspaces.find((w) => w.id === binding.datamateId)
Expand All @@ -362,6 +393,7 @@ async function memoryStatus(
})
}
const value = match.memoryEnabled === true
if (verdictAt) memoryVerdict.set(verdictAt, value ? "enabled" : "disabled")
if (value) {
memoryEnabledCache.set(binding.datamateId, { checkedAt: Date.now() })
memoryDisabledMemo.delete(binding.datamateId)
Expand Down Expand Up @@ -1259,6 +1291,7 @@ export function resetPollMemoForTests(): void {
pollInFlight.clear()
memoryEnabledCache.clear()
memoryDisabledMemo.clear()
memoryVerdict.clear()
}

/** Split blocks into those the workspace still needs and those already there at
Expand Down
8 changes: 5 additions & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2065,7 +2065,6 @@ export namespace SessionPrompt {
// altimate_change start — persisted plain-text section has a stable position
...(teamRules ? [teamRules] : []),
// altimate_change end
...hoistedReminders,
]
// altimate_change start — run-mode-only completion instruction. This text
// used to sit in builder.txt, but builder is a PRIMARY agent, so it also
Expand Down Expand Up @@ -2176,8 +2175,11 @@ export namespace SessionPrompt {
abort,
sessionID,
system,
// altimate_change start — upstream_fix: append ambient context after the cached instructions
environment: await SystemPrompt.environment(model),
// altimate_change start — upstream_fix: append ambient context after the cached instructions.
// Hoisted reminders (task pin, plan switch) appear on some steps only, so they ride in the
// same trailing message, after the cache breakpoint; inside `system` they changed the
// cached prefix and re-created it at full input rate.
environment: [...(await SystemPrompt.environment(model)), ...hoistedReminders],
// altimate_change end
messages: [
// altimate_change start - use the history converted before the fresh-user check
Expand Down
19 changes: 19 additions & 0 deletions packages/opencode/test/altimate/workspace/contents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,25 @@ describe("workspace summary", () => {
expect(spy).toHaveBeenCalledTimes(2)
})

// The summary renders into the cached system prefix on every step. A refresh at the TTL that
// fails must keep the good answer it was refreshing, or the Integrations, memory and knowledge
// lines vanish for that step and the whole prefix is re-created.
test("a failed refresh past the TTL keeps the cached answer, and the next step retries", async () => {
const good = summary({ integrations: [{ id: "github" }], memory_enabled: true })
const spy = spyOn(AltimateApi, "getDatamate")
.mockResolvedValueOnce(good)
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValueOnce(summary({ integrations: [{ id: "snowflake" }], memory_enabled: true }))
const t0 = Date.now()
const expected = { integrations: ["github"], memoryEnabled: true, knowledge: null }
expect(await Contents.workspaceSummary(39, t0)).toEqual(expected)
const late = t0 + 5 * 60_000 + 1
expect(await Contents.workspaceSummary(39, late)).toEqual(expected)
// Still the good answer, and not cached as fresh: the next step asks again.
expect(await Contents.workspaceSummary(39, late)).toEqual({ integrations: ["snowflake"], memoryEnabled: true, knowledge: null })
expect(spy).toHaveBeenCalledTimes(3)
})

test("a slow service does not hold the step past its wait; the answer arrives on a later step", async () => {
let release!: (v: any) => void
const spy = spyOn(AltimateApi, "getDatamate").mockImplementation(() => new Promise((r) => (release = r)) as any)
Expand Down
Loading
Loading