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
5 changes: 5 additions & 0 deletions .changeset/bound-long-session-memory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@reddb-io/redcode": patch
---

Stop long sessions from growing without bound and being OOM-killed: turn diffs no longer embed a whole copy of every large file they touch, concurrent turn summaries collapse into one run instead of hydrating the session several times over, edit tool metadata carries diagnostics only for the files it touched, and the TUI mirrors a session's messages only once something asks for that session. Also documents installing and upgrading with mise.
5 changes: 5 additions & 0 deletions .changeset/lsp-cap-npm-deadline-trim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@reddb-io/redcode": patch
---

Cap how many language servers run at once (`REDCODE_LSP_MAX_CLIENTS`, default 8) so a monorepo with per-package linter configs stops spawning one server per package, put a deadline on the npm install that plugin loading holds a cross-process lock across, and trim the whitespace around a typed message so a trailing newline is not part of what you said and a blank input is not sent at all.
5 changes: 5 additions & 0 deletions .changeset/self-healing-startup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@reddb-io/redcode": patch
---

Recover from stuck states without the user having to diagnose them: a worker thread that throws or dies now fails the waiting call instead of freezing the UI, a lock whose owning process is gone is taken over immediately rather than after a minute, startup no longer waits forever on a stalled home directory, a piped stdin that never closes, a hung git, or an unbounded musl probe, and language servers close documents past an open-file cap instead of holding every file the session ever touched.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,34 @@ Bun, pnpm, and Yarn work too. The install resolves one native package for your p
variants where the architecture needs them. Each package contains `redcode` and the matching
`redcode-rpc-sidecar` companion.

### With mise

mise installs the release binary straight from GitHub, no Node required. This is what
[red-dev](https://github.com/reddb-io/red-dev) sets up, so a machine provisioned by it already has
Redcode this way.

```bash
mise use -g github:reddb-io/redcode@latest
redcode
```

Upgrade the same install with:

```bash
mise upgrade github:reddb-io/redcode
```

Two notes worth knowing:

- Pin `@latest` rather than an exact version. `mise upgrade` keeps whatever range the tool was
installed with, so an exact pin never moves on its own.
- If an upgrade reports success but `redcode --version` does not change, mise served a cached
version list. Run `mise cache clear github:reddb-io/redcode` and upgrade again.

Keep one installation method per machine. An npm global and a mise install can both provide
`redcode`, and then `$PATH` order decides which one runs — updating the one you are not running
looks like an update that did nothing. `which redcode` tells you which copy is live.

There is no beta channel, no container image, no desktop build, no package-manager tap, and no
hosted deployment. See [What Ships And What Doesn't](#what-ships-and-what-doesnt) for why that is
enforced rather than merely intended.
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export const Flag = {
REDCODE_MODELS_URL: process.env["REDCODE_MODELS_URL"],
REDCODE_MODELS_PATH: process.env["REDCODE_MODELS_PATH"],
REDCODE_DB: process.env["REDCODE_DB"],
REDCODE_LSP_OPEN_FILE_LIMIT: process.env["REDCODE_LSP_OPEN_FILE_LIMIT"],
REDCODE_LSP_MAX_CLIENTS: process.env["REDCODE_LSP_MAX_CLIENTS"],

REDCODE_WORKSPACE_ID: process.env["REDCODE_WORKSPACE_ID"],
REDCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("REDCODE_EXPERIMENTAL_WORKSPACES"),
Expand Down
35 changes: 27 additions & 8 deletions packages/core/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,33 @@ export const Path = paths

Flock.setGlobal({ state })

await Promise.all([
fs.mkdir(Path.data, { recursive: true }),
fs.mkdir(Path.config, { recursive: true }),
fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.tmp, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }),
fs.mkdir(Path.repos, { recursive: true }),
// This is module-level, so it runs before argv is even parsed — `redcode --version`
// included. When the home directory sits on a stalled mount (a dropped network drive, a
// Windows filesystem seen through WSL) these calls block with nothing printed and no way
// to tell what is wrong. Say what is stuck and let the process continue: whatever needs a
// directory will fail with its own error, which is far easier to act on than a freeze.
const MKDIR_DEADLINE_MS = 10_000

await Promise.race([
Promise.all([
fs.mkdir(Path.data, { recursive: true }),
fs.mkdir(Path.config, { recursive: true }),
fs.mkdir(Path.state, { recursive: true }),
fs.mkdir(Path.tmp, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }),
fs.mkdir(Path.repos, { recursive: true }),
]),
new Promise<void>((resolve) => {
const timer = setTimeout(() => {
process.stderr.write(
`redcode: still waiting on ${redcodeHome} after ${MKDIR_DEADLINE_MS / 1000}s — ` +
`the filesystem holding it may be unavailable. Set REDCODE_TEST_HOME or HOME to a local path.\n`,
)
resolve()
}, MKDIR_DEADLINE_MS)
timer.unref?.()
}),
])

export class Service extends Context.Service<Service, Interface>()("@redcode/Global") {}
Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ interface ArboristTree {
edgesOut: Map<string, { to?: ArboristNode }>
}

// Generous: a cold install of a large plugin over a slow link is minutes, not seconds.
const REIFY_DEADLINE = "5 minutes"

const layer = Layer.effect(
Service,
Effect.gen(function* () {
Expand Down Expand Up @@ -105,7 +108,22 @@ const layer = Layer.effect(
add,
dir: input.dir,
}),
}) as Effect.Effect<ArboristTree, InstallFailedError>
}).pipe(
// The install lock is held across this, and the heartbeat keeps refreshing while it
// runs, so a registry connection that never answers looks alive forever: the stale
// breaker never fires and every other process waits out the full lock timeout. A
// deadline turns that into a failure this caller can report and retry.
Effect.timeout(REIFY_DEADLINE),
Effect.catchTag("TimeoutError", () =>
Effect.fail(
new InstallFailedError({
cause: new Error(`npm install in ${input.dir} exceeded ${REIFY_DEADLINE}`),
add,
dir: input.dir,
}),
),
),
) as Effect.Effect<ArboristTree, InstallFailedError>
}).pipe(
Effect.withSpan("Npm.reify", {
attributes: input,
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/util/flock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,45 @@ export namespace Flock {
}
}

async function readMeta(metaPath: string) {
try {
const parsed: unknown = JSON.parse(await readFile(metaPath, "utf8"))
if (!parsed || typeof parsed !== "object") return
const { pid, hostname } = parsed as { pid?: unknown; hostname?: unknown }
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return
if (typeof hostname !== "string") return
return { pid, hostname }
} catch {
return
}
}

// Signal 0 tests for existence without delivering anything. EPERM means the process is
// alive and owned by someone else, which still counts as alive.
function alive(pid: number) {
try {
process.kill(pid, 0)
return true
} catch (err) {
return code(err) === "EPERM"
}
}

// A lock whose owner is gone is stale now, not staleMs from now. Waiting out the
// heartbeat window after a crash or a kill is a minute of a frozen app for nothing.
// Only decided on this host: a pid from another machine says nothing about this one.
async function abandoned(metaPath: string) {
const meta = await readMeta(metaPath)
if (!meta) return false
if (meta.hostname !== os.hostname()) return false
if (meta.pid === process.pid) return false
return !alive(meta.pid)
}

async function stale(lockDir: string, heartbeatPath: string, metaPath: string, staleMs: number) {
// Stale detection allows automatic recovery after crashed owners.
const now = wall()
if (await abandoned(metaPath)) return true
const heartbeat = await stats(heartbeatPath)
if (heartbeat) {
return now - heartbeat.mtimeMs > staleMs
Expand Down
52 changes: 52 additions & 0 deletions packages/core/test/util/flock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,58 @@ describe("util.flock", () => {
expect(hit).toBe(true)
}, 20_000)

test("takes over immediately when the owning process is gone, without waiting out the heartbeat", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const key = "flock:dead-owner"
const ready = path.join(tmp.path, "ready")
// A long stale window is exactly the case this covers: waiting it out after a crash
// is a frozen app for no reason, so recovery must come from the owner being gone.
const proc = spawnWorker({ key, dir, ready, holdMs: 60_000, staleMs: 60_000, timeoutMs: 90_000 })

await wait(ready, 5_000)
await stopWorker(proc)

const started = Date.now()
let hit = false
await Flock.withLock(
key,
async () => {
hit = true
},
{ dir, staleMs: 60_000, timeoutMs: 20_000 },
)

expect(hit).toBe(true)
expect(Date.now() - started).toBeLessThan(10_000)
}, 40_000)

test("leaves a live owner's lock alone even when its pid is recorded", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const key = "flock:live-owner"
const ready = path.join(tmp.path, "ready")
const proc = spawnWorker({ key, dir, ready, holdMs: 3_000, staleMs: 60_000, timeoutMs: 30_000 })

await wait(ready, 5_000)
const meta = await readJson<{ pid: number }>(path.join(lock(dir, key), "meta.json"))
expect(meta.pid).toBeGreaterThan(0)

// The holder is alive, so this must wait for it to finish rather than break in.
const started = Date.now()
let hit = false
await Flock.withLock(
key,
async () => {
hit = true
},
{ dir, staleMs: 60_000, timeoutMs: 20_000 },
)
expect(hit).toBe(true)
expect(Date.now() - started).toBeGreaterThan(1_000)
await stopWorker(proc)
}, 40_000)

test("breaks stale lock dirs when heartbeat is missing", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
Expand Down
5 changes: 4 additions & 1 deletion packages/redcode/bin/redcode
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ const names = (() => {
}

try {
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
// Bounded like the darwin and windows probes above. Under WSL a spawn whose PATH
// resolution crosses /mnt/c or a dropped network mount can block in uninterruptible
// IO, and this runs before anything else, so an unbounded probe hangs even `-v`.
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8", timeout: 1500 })
const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase()
if (text.includes("musl")) return true
} catch {
Expand Down
22 changes: 20 additions & 2 deletions packages/redcode/src/cli/cmd/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,16 @@ async function target() {
return new URL("../tui/worker.ts", import.meta.url)
}

// A non-TTY stdin is not always a finite pipe: launched from a wrapper, an editor task or a
// Windows console shim, the child can inherit a pipe nobody ever closes, and reading it to
// EOF then blocks the TUI before its first frame with nothing on screen. Waiting briefly and
// carrying on costs a caller who really did pipe a slow prompt; hanging costs the session.
const STDIN_DEADLINE_MS = 2000

async function input(value?: string) {
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
const piped = process.stdin.isTTY
? undefined
: await withTimeout(Bun.stdin.text(), STDIN_DEADLINE_MS, "stdin").catch(() => undefined)
if (!value) return piped
if (!piped) return value
return piped + "\n" + value
Expand Down Expand Up @@ -218,18 +226,28 @@ export const TuiThreadCommand = cmd({
}
const cwd = Filesystem.resolve(process.cwd())

let stopped = false
const worker = new Worker(file, {
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
})
const client = Rpc.client<typeof rpc>(worker)
// A worker that fails to load, or dies, posts nothing back. Without these the calls
// waiting on it stay pending and the UI shows an empty screen with no error.
worker.addEventListener("error", (event) => {
const detail = event instanceof ErrorEvent && event.message ? event.message : "worker failed to start"
client.fail(`Redcode server thread failed: ${detail}`)
})
worker.addEventListener("close", () => {
if (stopped) return
client.fail("Redcode server thread exited unexpectedly")
})
const reload = () => {
client.call("reload", undefined).catch(() => {})
}
process.on("SIGUSR2", reload)

let stopped = false
const stop = async () => {
if (stopped) return
stopped = true
Expand Down
36 changes: 36 additions & 0 deletions packages/redcode/src/lsp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { pathToFileURL, fileURLToPath } from "url"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import { Process } from "@/util/process"
import { Flag } from "@reddb-io/redcode-core/flag/flag"
import { LANGUAGE_EXTENSIONS } from "./language"
import { Effect, Schema } from "effect"
import type * as LSPServer from "./server"
Expand Down Expand Up @@ -275,7 +276,40 @@ export async function create(input: {
})
}

// Every file the agent reads or edits used to stay resident here in full, in every client
// that matches it — and a .ts file matches typescript, eslint, oxlint and biome at once.
// Over a long session that is unbounded. Documents past the cap are closed on the server
// and dropped here; the next touch reopens them, which is the same didOpen a first touch
// would have sent.
const OPEN_FILE_LIMIT = (() => {
const configured = Number(Flag.REDCODE_LSP_OPEN_FILE_LIMIT)
return Number.isInteger(configured) && configured > 0 ? configured : 200
})()
const files: Record<string, { version: number; text: string }> = {}
const opened: string[] = []

const forget = (filePath: string) => {
delete files[filePath]
pushDiagnostics.delete(filePath)
pullDiagnostics.delete(filePath)
published.delete(filePath)
}

const touched = async (filePath: string) => {
const at = opened.indexOf(filePath)
if (at >= 0) opened.splice(at, 1)
opened.push(filePath)
while (opened.length > OPEN_FILE_LIMIT) {
const evict = opened.shift()
if (evict === undefined || evict === filePath) continue
forget(evict)
await connection
.sendNotification("textDocument/didClose", {
textDocument: { uri: pathToFileURL(evict).href },
})
.catch(() => {})
}
}

// --- Diagnostic helpers ---

Expand Down Expand Up @@ -589,6 +623,7 @@ export async function create(input: {

const next = document.version + 1
files[request.path] = { version: next, text }
await touched(request.path)
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: pathToFileURL(request.path).href,
Expand Down Expand Up @@ -630,6 +665,7 @@ export async function create(input: {
},
})
files[request.path] = { version: 0, text }
await touched(request.path)
return 0
},
},
Expand Down
13 changes: 13 additions & 0 deletions packages/redcode/src/lsp/diagnostic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ export function pretty(diagnostic: LSPClient.Diagnostic) {
return `${severity} [${line}:${col}] ${diagnostic.message}`
}

// LSP.diagnostics() answers with every file every client has ever opened, which on a large
// workspace is tens of megabytes. Tool metadata is cloned, written to the durable store and
// pushed to every client on each edit, and every reader of it looks up one file — so only the
// files the tool touched belong in there.
export function pick(diagnostics: Record<string, LSPClient.Diagnostic[]>, files: string[]) {
const result: Record<string, LSPClient.Diagnostic[]> = {}
for (const file of files) {
const hit = diagnostics[file]
if (hit) result[file] = hit
}
return result
}

export function report(file: string, issues: LSPClient.Diagnostic[]) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) return ""
Expand Down
Loading
Loading