Skip to content
Merged
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
54 changes: 44 additions & 10 deletions packages/opencode/src/altimate/workspace/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,16 @@ 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"

/** 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 and stale) is ~1,080 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 = 1_500
* instruction itself: the longest fixed shape (pinned, stale, 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


const HEADING = "## Altimate Workspace"
Expand Down Expand Up @@ -65,20 +66,37 @@ const LINK_HINT =
* leaving it to the caller) so the cap is part of the pure, testable surface — the
* guard is against a pathological workspace name, and every branch below is built from
* one, so it belongs where the name is rendered. */
export function render(outcome: BindingOutcome, cap = MAX_SECTION_CHARS): string {
const body = renderBody(outcome)
export function render(outcome: BindingOutcome, cap = MAX_SECTION_CHARS, opts: RenderOptions = {}): string {
const body = renderBody(outcome, opts)
if (body.length <= cap) return body
// Fail closed rather than truncate: a cut instruction is worse than a missing
// name. The name is the only variable field, so drop it and keep the id; if
// even that does not fit, say nothing rather than something partial.
if (outcome.status === "bound") {
const unnamed = renderBody({ ...outcome, binding: { ...outcome.binding, datamateName: "" } })
const unnamed = renderBody({ ...outcome, binding: { ...outcome.binding, datamateName: "" } }, opts)
if (unnamed.length <= cap) return unnamed
}
return ""
}

function renderBody(outcome: BindingOutcome): string {
export type RenderOptions = {
/** The bound workspace has memory on (or has not said otherwise): tell the model
* which store is the team's. `systemSection` derives it from the enablement memo;
* the pure formatter takes it as an argument so tests stay deterministic. */
teamMemory?: boolean
}

/** Two stores answer "remember this". Only one is read by other linked checkouts,
* and nothing told the model which — so on a plain "save this for the team" it
* reached for the engine's hub and the decision never left the session (#1332). */
const TEAM_MEMORY_LINE =
"Team memory: save decisions and conventions with `altimate_memory_write` (scope " +
'"project" for this project, "global" for everything); they sync to the workspace and to every ' +
"linked checkout. The `datamate_*` memory tools (`datamate_add_memories`, `datamate_search_memory`) " +
"are the engine's separate store and are not what teammates' sessions read. Before saving, check " +
"`altimate_memory_read` for an existing block on the same subject and update it rather than add a duplicate."

function renderBody(outcome: BindingOutcome, opts: RenderOptions = {}): string {
if (outcome.status === "bound") {
const id = String(outcome.binding.datamateId)
const name = workspaceLabel(outcome.binding.datamateName, undefined)
Expand All @@ -105,6 +123,7 @@ function renderBody(outcome: BindingOutcome): string {
? " Skills and memory follow this workspace; warehouse tool routing still follows the " +
"project's own link, which may name a different workspace."
: ""),
...(opts.teamMemory ? [TEAM_MEMORY_LINE] : []),
`When ${TRIGGER}, the answer is this Altimate Workspace — never substitute ` +
"another service's own \"workspace\" (a Databricks workspace, an IDE's " +
"workspace folder, etc.) for it, and the reverse: a question about another " +
Expand Down Expand Up @@ -287,6 +306,20 @@ 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. */
function renderOptions(outcome: BindingOutcome): RenderOptions {
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.
if (outcome.stale) return {}
return { teamMemory: memoryEnabledCached(outcome.binding) === "enabled" }
}

function keyFor(scope: AccountScope, directory: string): string {
return `${scope.tenant}|${scope.apiUrl}|${scope.account}|${directory}`
}
Expand Down Expand Up @@ -357,14 +390,15 @@ export async function systemSection(): Promise<string> {
if (!scope) return render({ status: "unknown" })
const key = keyFor(scope, directory)
const hit = memo.get(key)
if (fresh(hit)) return render(hit!.outcome)
if (fresh(hit)) return render(hit!.outcome, MAX_SECTION_CHARS, renderOptions(hit!.outcome))
// 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 {
return render(await Promise.race([resolve(key, directory), deadline]))
const outcome = await Promise.race([resolve(key, directory), deadline])
return render(outcome, MAX_SECTION_CHARS, renderOptions(outcome))
} finally {
for (const t of timers) clearTimeout(t)
}
Expand Down
79 changes: 68 additions & 11 deletions packages/opencode/src/altimate/workspace/memory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,11 @@ export function memoryEnabledCached(binding: CachedBinding): "enabled" | "disabl

/** 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. */
export function noteMemoryDisabledForTests(datamateId: number): void {
memoryDisabledMemo.set(datamateId, Date.now())
}

export function resetEnablementMemoForTests(): void {
memoryEnabledCache.clear()
memoryDisabledMemo.clear()
Expand Down Expand Up @@ -515,7 +520,16 @@ async function push(
* memory the user deleted. Two rapid saves race the same way and create
* duplicates. Keyed by scope+id, so unrelated blocks still mirror in parallel.
*/
const blockQueues = new Map<string, Promise<unknown>>()
// Anchored on a process-global, as skill-sync's tables are: this module is reached
// through two module graphs in one process — `MemoryStore` via `@/…`, the `run`
// exit path via a relative specifier — and a runtime that keeps a record per
// specifier would fork plain module state. A flush that saw an empty set while
// the writer's copy held the upload would defeat the #1332 fix silently.
const SYNC_STATE = Symbol.for("altimate.memory-sync.state")
const syncState: { blockQueues: Map<string, Promise<unknown>>; mirrorsInFlight: Set<Promise<void>> } = ((
globalThis as unknown as Record<symbol, typeof syncState | undefined>
)[SYNC_STATE] ??= { blockQueues: new Map(), mirrorsInFlight: new Set() })
const blockQueues = syncState.blockQueues

function serialize<T>(scope: "global" | "project", blockId: string, op: () => Promise<T>): Promise<T> {
const key = `${scope}:${blockId}`
Expand All @@ -530,6 +544,44 @@ function serialize<T>(scope: "global" | "project", blockId: string, op: () => Pr
return next
}

/** Mirrors still in flight. `MemoryStore.write` fires the mirror and forgets
* it (the local file is already durable, and a cloud failure must not fail
* the write), which is right for the TUI and wrong for a one-shot `run`: the
* process exits the moment the turn ends, routinely before the upload lands,
* and the block a teammate was meant to see never leaves the machine (#1332).
* Tracked here so `flushPendingMirrors` can hold the exit for them, the way
* `skill-sync.flushPendingSyncs` holds it for a cold skill sync. */
const mirrorsInFlight = syncState.mirrorsInFlight

/** Hold a task in `mirrorsInFlight` for its lifetime. */
async function tracked(task: Promise<void>): Promise<void> {
mirrorsInFlight.add(task)
try {
await task
} finally {
mirrorsInFlight.delete(task)
}
}

/** Await every mirror and archive still in flight, bounded, so a short-lived
* process does not exit with an upload or an archive half-done. Failures are
* already logged by the caller; this only waits. */
export async function flushPendingMirrors(timeoutMs = 30_000): Promise<void> {
const pending = [...mirrorsInFlight]
if (pending.length === 0) return
let timer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
Promise.allSettled(pending),
new Promise((resolve) => {
timer = setTimeout(resolve, timeoutMs)
}),
])
} finally {
if (timer) clearTimeout(timer)
}
}

/** Mirror one block. Safe to call unconditionally — returns immediately when
* the pilot flag is off, the project is unbound, or the workspace has memory
* disabled. */
Expand All @@ -538,7 +590,7 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi
// Queued BEFORE the binding lookup, not after. Both are async, so resolving
// them first let two operations on one block reach `serialize` in the
// opposite order to the writes that triggered them.
await serialize(block.scope, block.id, async () => {
const task = serialize(block.scope, block.id, async () => {
// A binding is required for EVERY scope, not just project. Memories are
// associated with a workspace, and the workspace is what carries the
// memory_enabled setting — mirroring from an unbound directory would upload
Expand All @@ -550,6 +602,7 @@ export async function mirrorBlock(block: MemoryBlock, directory?: string): Promi
if (!(await memoryEnabled(binding))) return
await push(block, binding, undefined, directory)
})
return tracked(task)
}

/** Archive a block's cloud record rather than deleting it, so the workspace
Expand All @@ -563,15 +616,19 @@ export async function archiveBlock(
if (!isEnabled()) return
// Queued behind any in-flight mirror for the same block, so a delete cannot
// run before the create it is meant to undo. The binding lookup happens
// inside the queued op for the same reason as in `mirrorBlock`.
return serialize(scope, blockId, async () => {
// Same capture as the mirror: the delete's own project decides which
// workspace record is archived, not whichever instance is current now.
const binding = await currentBinding(directory)
if (!binding) return
if (!(await memoryEnabled(binding))) return
await archiveNow(scope, blockId, binding)
})
// inside the queued op for the same reason as in `mirrorBlock`. Tracked like a
// mirror: a one-shot `run` that deletes a block must not exit before the
// workspace record is archived, or teammates keep a memory the author removed.
return tracked(
serialize(scope, blockId, async () => {
// Same capture as the mirror: the delete's own project decides which
// workspace record is archived, not whichever instance is current now.
const binding = await currentBinding(directory)
if (!binding) return
if (!(await memoryEnabled(binding))) return
await archiveNow(scope, blockId, binding)
}),
)
}

async function archiveNow(
Expand Down
36 changes: 31 additions & 5 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,14 +1075,32 @@ You are speaking to a non-technical business executive. Follow these rules stric
// altimate_change end

// Register crash handlers to flush the trace on unexpected exit
// altimate_change start — and hold a signal exit, briefly, for a memory
// mirror still on the wire: Ctrl-C while the last response streams used to
// kill the upload of a block saved that turn (#1332). Bounded well below
// the normal-exit flush, and a second signal is not delayed by the first.
let signalled = false
const exitAfterMirrors = (code: number) => {
if (signalled) return process.exit(code)
signalled = true
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (!CoreFlag.ALTIMATE_WORKSPACE) return process.exit(code)
// Stop the run first so no new mirror is enqueued behind the snapshot the
// flush takes; what is already on the wire is what gets the 2s.
eventAbort.abort()
void import("../../altimate/workspace/memory-sync")
.then((m) => m.flushPendingMirrors(2_000))
.catch(() => {})
.finally(() => process.exit(code))
}
const onSigint = () => {
tracer?.flushSync("Process interrupted")
process.exit(130)
exitAfterMirrors(130)
}
const onSigterm = () => {
tracer?.flushSync("Process interrupted")
process.exit(143)
exitAfterMirrors(143)
}
// altimate_change end
// altimate_change start — honest rc on fatal abort. beforeExit firing
// before the run finishes means the event loop drained before the run
// completed — the prompt/event stream was abandoned (observed: a
Expand Down Expand Up @@ -1445,9 +1463,17 @@ You are speaking to a non-technical business executive. Follow these rules stric
// received its skills at all. Imported lazily and only when the feature is
// on, so an opted-out run does not load the module.
if (CoreFlag.ALTIMATE_WORKSPACE) {
await import("../../altimate/workspace/skill-sync")
.then((m) => m.flushPendingSyncs())
.catch(() => {})
// And the memory mirrors: a block saved on the last turn was uploaded
// fire-and-forget and lost the same race (#1332). Both flushes run together
// under their own bounds, so two stalled backends cost one wait, not two.
await Promise.all([
import("../../altimate/workspace/skill-sync")
.then((m) => m.flushPendingSyncs())
.catch(() => {}),
import("../../altimate/workspace/memory-sync")
.then((m) => m.flushPendingMirrors())
.catch(() => {}),
])
}
// altimate_change end

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/memory/tools/memory-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { MEMORY_MAX_BLOCK_SIZE, MEMORY_MAX_BLOCKS_PER_SCOPE, CitationSchema, Mem
const idSchema = MemoryBlockSchema.shape.id

export const MemoryWriteTool = Tool.define("altimate_memory_write", {
description: `Save an Altimate Memory block for cross-session persistence. Use this to store information worth remembering across sessions — warehouse configurations, naming conventions, team preferences, data model notes, or past analysis decisions. Each block is a Markdown file persisted to disk. Max ${MEMORY_MAX_BLOCK_SIZE} chars per block, ${MEMORY_MAX_BLOCKS_PER_SCOPE} blocks per scope. Supports hierarchical IDs with slashes (e.g., 'warehouse/snowflake-config'), optional TTL expiration, and citation-backed memories.`,
description: `Save an Altimate Memory block for cross-session persistence. Use this to store information worth remembering across sessions — warehouse configurations, naming conventions, team preferences, data model notes, or past analysis decisions. When the project is linked to an Altimate Workspace with workspace sync on and workspace memory enabled this is the TEAM's memory: blocks sync to the workspace and to every linked checkout, so a decision saved here is what teammates' sessions read. Before saving, call \`altimate_memory_read\` for an existing block on the same subject and update it rather than add a duplicate. The datamate_* memory tools (datamate_add_memories, datamate_search_memory) are the engine's separate store and are not what linked checkouts read. Each block is a Markdown file persisted to disk. Max ${MEMORY_MAX_BLOCK_SIZE} chars per block, ${MEMORY_MAX_BLOCKS_PER_SCOPE} blocks per scope. Supports hierarchical IDs with slashes (e.g., 'warehouse/snowflake-config'), optional TTL expiration, and citation-backed memories.`,
parameters: z.object({
id: idSchema
.describe(
Expand Down
Loading
Loading