LAC-2281: Lash Upstream Sync — 2026-06-01 (256 commits)#26
Conversation
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
…30167) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn.
…o#30145) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
…anomalyco#29710) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk>
Merges anomalyco/opencode `dev` into lash through commit d7ba8b1 fix(stats): center top models dot grid. Notable upstream changes: - refactor(core): move database schema ownership (anomalyco#29068) — removed packages/opencode/src/bus barrel, leaving only bus/global.ts. - refactor: collapse bus barrel into bus/index.ts (anomalyco#22902). - ProviderID/ModelID for session payloads now live in @opencode-ai/core/provider as ProviderV2.ID and ModelID. Lash adaptations layered on top: - Migrated plugin/shell-mode/cwd.ts CwdEvent from BusEvent.define + Bus.publish to EventV2.define + GlobalBus.emit so the ambient (non-Effect) plugin keeps publishing cwd.updated events with the same payload shape consumers expect. - Migrated plugin/shell-mode/session-shell.ts to filter session.idle events through GlobalBus.on rather than the removed Bus.subscribe. - Updated test/plugin/cwd.test.ts to subscribe via GlobalBus. - Pointed test/session/prompt-variant.test.ts at @opencode-ai/core/provider for ProviderV2.ID / ModelID after the legacy src/provider/schema module was removed upstream. - Kept invariants from UPSTREAM_SYNC.md: agent_cycle keybind stays shift+tab, EventCwdUpdated remains in the SDK event union, ExecutionMode/ WorkingDir providers wrap <App />, titlebar nav refactor preserved with upstream void lint marker, child-store fallback adds cwd: "", and directory-sync.ts keeps lash's dir rename plus upstream { mcp: true }. Refs: LAC-2281
There was a problem hiding this comment.
Code Review
This pull request introduces a new AWS S3 Tables and Iceberg data lake infrastructure, a new CLI package for debugging agents, a support lookup tool, and a redesigned layout and composer UI. It also optimizes billing and usage tracking by batching updates in Redis for high-traffic workspaces and migrating rate-limiting state entirely to Redis. The review feedback highlights a critical billing bug where paid usage could be lost during flushes, unsafe navigation side-effects inside an Immer producer, a potential fetch TypeError for GET/HEAD requests in the stats proxy, a Redis memory leak due to missing TTLs, and style guide violations regarding star and aliased imports.
|
|
||
| const workspaceDelta = balanceFlush.flush?.workspaceCost ?? cost | ||
| const userDelta = balanceFlush.flush?.userCost ?? cost | ||
| const balanceDelta = billingSource === "free" || billingSource === "byok" ? 0 : workspaceDelta |
There was a problem hiding this comment.
Critical Billing Bug: Paid Usage Lost on Free/BYOK Flushes
When balanceFlush.batched is true, workspaceDelta represents the accumulated paid balance that needs to be deducted. However, if the current request that triggers the flush happens to be a free or byok request, balanceDelta is set to 0 instead of workspaceDelta:
const balanceDelta = billingSource === "free" || billingSource === "byok" ? 0 : workspaceDeltaThis means any accumulated paid usage from previous requests in the batch is completely lost and never deducted from the workspace balance.
Since workspaceCost in Redis only accumulates paid costs (free/byok requests write 0 to workspaceCost on line 991), workspaceDelta is always the correct amount to deduct regardless of the current request's billing source.
| const balanceDelta = billingSource === "free" || billingSource === "byok" ? 0 : workspaceDelta | |
| const balanceDelta = balanceFlush.batched ? workspaceDelta : (billingSource === "free" || billingSource === "byok" ? 0 : cost) |
| removeSessions: (input: SessionTabsRemovedDetail) => { | ||
| void startTransition(() => { | ||
| setStore( | ||
| produce((tabs) => { | ||
| const sessionIDs = new Set(input.sessionIDs) | ||
| const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined | ||
| const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1 | ||
| const removedCurrent = | ||
| currentIndex !== -1 && | ||
| tabs[currentIndex]?.dir === input.directory && | ||
| sessionIDs.has(tabs[currentIndex]?.sessionId ?? "") | ||
|
|
||
| for (let i = tabs.length - 1; i >= 0; i--) { | ||
| const tab = tabs[i] | ||
| if (!tab) continue | ||
| if (tab.dir !== input.directory) continue | ||
| if (!sessionIDs.has(tab.sessionId)) continue | ||
| tabs.splice(i, 1) | ||
| } | ||
|
|
||
| if (!removedCurrent) return | ||
| const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1] | ||
| if (nextTab) navigate(nextTab.href) | ||
| else navigate("/") | ||
| }), | ||
| ) | ||
| }) | ||
| }, |
There was a problem hiding this comment.
Side-Effects Inside Immer produce Callback
Calling navigate inside the produce callback is unsafe. Immer's produce should be a pure function that only mutates the draft state. Triggering routing transitions or other side-effects during the store update phase can lead to race conditions, double-renders, or inconsistent UI states because the store write has not yet committed.
Instead, determine the target nextHref inside the producer and perform the navigation side-effect outside of setStore.
removeSessions: (input: SessionTabsRemovedDetail) => {
void startTransition(() => {
let nextHref: string | undefined
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1
const removedCurrent =
currentIndex !== -1 &&
tabs[currentIndex]?.dir === input.directory &&
sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab) continue
if (tab.dir !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}
if (removedCurrent) {
const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1]
nextHref = nextTab ? nextTab.href : "/"
}
}),
)
if (nextHref !== undefined) {
navigate(nextHref)
}
})
},
| const response = await fetch(targetUrl, { | ||
| method: req.method, | ||
| headers: req.headers, | ||
| body: req.body, | ||
| }) |
There was a problem hiding this comment.
TypeError: GET/HEAD Requests Cannot Have a Body
Passing req.body to fetch when the method is GET or HEAD will throw a TypeError in standard Fetch environments (including Cloudflare Workers, Node.js, and Bun). Since statsProxy proxies all HTTP methods, GET requests will crash on this call.
Ensure body is set to undefined for GET and HEAD requests.
| const response = await fetch(targetUrl, { | |
| method: req.method, | |
| headers: req.headers, | |
| body: req.body, | |
| }) | |
| const response = await fetch(targetUrl, { | |
| method: req.method, | |
| headers: req.headers, | |
| body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body, | |
| }) |
| export async function accumulateUsage(workspaceID: string, userID: string, workspaceCost: number, userCost: number) { | ||
| const redis = getRedis() | ||
| const wKey = `${Resource.App.stage}:usage:wrk:${workspaceID}` | ||
| const uKey = `${Resource.App.stage}:usage:usr:${workspaceID}:${userID}` | ||
|
|
||
| await Promise.all([redis.incrby(wKey, workspaceCost), redis.incrby(uKey, userCost)]) |
There was a problem hiding this comment.
Redis Memory Leak: Missing TTL on Usage Keys
Accumulating usage with redis.incrby without setting an expiration (TTL) will cause keys to persist indefinitely in Redis. Over time, as workspaces and users become inactive, this will lead to a memory leak.
Use a Redis pipeline to atomically increment the values and set a reasonable TTL (e.g., 30 days) to automatically clean up inactive keys.
| export async function accumulateUsage(workspaceID: string, userID: string, workspaceCost: number, userCost: number) { | |
| const redis = getRedis() | |
| const wKey = `${Resource.App.stage}:usage:wrk:${workspaceID}` | |
| const uKey = `${Resource.App.stage}:usage:usr:${workspaceID}:${userID}` | |
| await Promise.all([redis.incrby(wKey, workspaceCost), redis.incrby(uKey, userCost)]) | |
| export async function accumulateUsage(workspaceID: string, userID: string, workspaceCost: number, userCost: number) { | |
| const redis = getRedis() | |
| const wKey = `${Resource.App.stage}:usage:wrk:${workspaceID}` | |
| const uKey = `${Resource.App.stage}:usage:usr:${workspaceID}:${userID}` | |
| const pipeline = redis.pipeline() | |
| pipeline.incrby(wKey, workspaceCost) | |
| pipeline.expire(wKey, 30 * 24 * 60 * 60) | |
| pipeline.incrby(uKey, userCost) | |
| pipeline.expire(uKey, 30 * 24 * 60 * 60) | |
| await pipeline.exec() |
| list.map(async (conn) => { | ||
| results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) | ||
| }), |
There was a problem hiding this comment.
Defensive Programming: Guard Against Null/Undefined Connections
If list contains any null or undefined elements, attempting to access conn.http will throw a TypeError. Adding a simple guard ensures robustness.
list.map(async (conn) => {
if (!conn) return
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),| import * as Effect from "effect/Effect" | ||
| import * as Command from "effect/unstable/cli/Command" |
There was a problem hiding this comment.
Style Guide Violation: Star Imports
This import violates the repository style guide rule: 'Never use star imports. Do not use import * as Foo from "..." or import type * as Foo from "...".'
Import the named exports directly instead.
| import * as Effect from "effect/Effect" | |
| import * as Command from "effect/unstable/cli/Command" | |
| import { Effect } from "effect" | |
| import { Command } from "effect/unstable/cli/Command" |
References
- Never use star imports. Do not use import * as Foo from "..." or import type * as Foo from "...". (link)
| import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" | ||
| import { Wildcard } from "./util/wildcard" | ||
| import { Location } from "./location" | ||
|
|
||
| export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" }) | ||
| export type Effect = typeof Effect.Type | ||
|
|
||
| export class Info extends Schema.Class<Info>("Policy.Info")({ | ||
| action: Schema.String, | ||
| effect: Effect, | ||
| resource: Schema.String, | ||
| }) {} | ||
|
|
||
| export interface Interface { | ||
| readonly load: (statements: Info[]) => EffectRuntime.Effect<void> | ||
| readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect<Effect> | ||
| readonly hasStatements: () => boolean |
There was a problem hiding this comment.
Style Guide Violation: Aliased Import
This import violates the repository style guide rule: 'Never alias imports. Do not use import { foo as bar } from "..." or renamed imports like resolve as pathResolve.'
To avoid the naming conflict with the local Effect schema/type, rename the local schema/type to PolicyEffect and import Effect directly from 'effect' without aliasing.
| import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" | |
| import { Wildcard } from "./util/wildcard" | |
| import { Location } from "./location" | |
| export const Effect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" }) | |
| export type Effect = typeof Effect.Type | |
| export class Info extends Schema.Class<Info>("Policy.Info")({ | |
| action: Schema.String, | |
| effect: Effect, | |
| resource: Schema.String, | |
| }) {} | |
| export interface Interface { | |
| readonly load: (statements: Info[]) => EffectRuntime.Effect<void> | |
| readonly evaluate: (action: string, resource: string, fallback: Effect) => EffectRuntime.Effect<Effect> | |
| readonly hasStatements: () => boolean | |
| import { Context, Effect, Layer, Schema } from "effect" | |
| import { Wildcard } from "./util/wildcard" | |
| import { Location } from "./location" | |
| export const PolicyEffect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" }) | |
| export type PolicyEffect = typeof PolicyEffect.Type | |
| export class Info extends Schema.Class<Info>("Policy.Info")({ | |
| action: Schema.String, | |
| effect: PolicyEffect, | |
| resource: Schema.String, | |
| }) {} | |
| export interface Interface { | |
| readonly load: (statements: Info[]) => Effect.Effect<void> | |
| readonly evaluate: (action: string, resource: string, fallback: PolicyEffect) => Effect.Effect<PolicyEffect> |
References
- Never alias imports. Do not use import { foo as bar } from "..." or renamed imports like resolve as pathResolve. (link)
Gemini code review on PR #26 flagged that removeSessions ran navigate() inside the Immer produce callback, which is unsafe — produce should be pure mutation only, and triggering routing during the store write phase can cause race conditions and double-renders. removeTab already followed the correct pattern (compute nextHref inside produce, navigate outside); mirror it for removeSessions. Refs: LAC-2281
|
Thanks @gemini-code-assist. Triage for this upstream sync PR: Addressed
Deferred — pre-existing upstream code (out of scope for this sync)
If we patched these in this PR we'd be reverting upstream design choices without owning the broader context; better to track them as separate upstream-bug tickets if they bite us. |
|
Thanks for the update, @lacymorrow. I've reviewed the triage list and agree that tracking these as separate upstream-bug tickets is the right approach to maintain the integrity of this sync. The fix for |
Rebased on top of dev after PR #26 (LAC-2281, 2026-06-01 sync) merged. Many original conflicts auto-resolved because dev already contained 256 of the 303 upstream commits. Remaining conflicts resolved per UPSTREAM_SYNC.md playbook with all lash invariants preserved: - agent_cycle = shift+tab in TUI keybind config - EventCwdUpdated in SDK event union (incl. GlobalEvent.payload) - getCwd()/setCwd() in shell-mode + prompt.ts cwd sentinel - Double-left-border row layout in prompt/index.tsx - ExecutionModeProvider + WorkingDirProvider wrap <App /> in app.tsx - tabs.tsx removeSessions navigate() hoisted out of produce callback (mirrors c04019d pattern for titlebar.tsx) Post-merge typecheck fixes included: - which() import path (filesystem services consolidation) - @/, @shell-mode, @tui-integration aliases in tui + cli tsconfigs (after upstream TUI extraction 106f8e9) - *.wasm module declaration - ModelID via ModelV2.ID after upstream provider/index.ts removal Refs: LAC-2337
Summary
Merges
upstream/dev(anomalyco/opencode) intolashdevthrough commitd7ba8b1125 fix(stats): center top models dot grid— 256 new commits.Routine LAC-2277 escalated this sync as LAC-2281 because the auto-merge produced 25 conflicts.
Notable upstream changes
packages/opencode/src/bus/barrel collapsed to justbus/global.ts. Ambient publishers must callGlobalBus.emit("event", { directory, payload }); ambient subscribers useGlobalBus.on("event", handler). Event registration moves fromBusEvent.definetoEventV2.define({ type, schema }).ProviderID/ModelIDfor session payloads now come from@opencode-ai/core/provider(ProviderV2.ID,ModelID); the legacypackages/opencode/src/provider/schemais gone.createCliRenderer,TuiPluginRuntime).EventCwdUpdatedpreserved manually.Lash adaptations layered on top
plugin/shell-mode/cwd.tsCwdEventfromBusEvent.define+Bus.publishtoEventV2.define+GlobalBus.emitso the ambient (non-Effect) plugin keeps publishingcwd.updatedevents with the same payload shape consumers expect.plugin/shell-mode/session-shell.tsidle-shell cleanup to filtersession.idlepayloads throughGlobalBus.onrather than the removedBus.subscribe.test/plugin/cwd.test.tsto subscribe viaGlobalBus.test/session/prompt-variant.test.tsat@opencode-ai/core/provider(ProviderV2.ID,ModelID).UPSTREAM_SYNC.mdplaybook:packages/opencode/src/cli/cmd/tui/app.tsx— kept upstream render tree, layered lashExecutionModeProvider+WorkingDirProvider.packages/sdk/js/src/v2/gen/types.gen.ts— kept upstream regeneration, preservedEventCwdUpdatedin 3 conflict regions.packages/core/src/catalog.ts— took upstream's event-drivenState.update, dropped lashSemaphore(no longer needed;state.updateprovides serialization).packages/app/src/components/titlebar.tsx— kept lash'snextHref-before-navigate refactor, added upstream'svoidlint marker onstartTransition.packages/app/src/context/directory-sync.ts— combined lash'sdirrename (fix for self-shadow bug) with upstream's{ mcp: true }option.packages/app/src/context/global-sync/child-store.ts— kept upstream fallback structure, added lash'scwd: ""field.Invariants verified (per
UPSTREAM_SYNC.md)agent_cyclekeybind stillshift+tab(packages/opencode/src/cli/cmd/tui/config/keybind.ts:126).EventCwdUpdatedpreserved in SDK event union and event type alias (packages/sdk/js/src/v2/gen/types.gen.ts:49,:1443,:3270).getCwd()/setCwd()/ shell-mode integration intact inpackages/opencode/plugin/shell-mode/cwd.ts.Test plan
bun installclean — lockfile regenerated.bun turbo typecheck— 21/21 packages clean.Refs: LAC-2281