Skip to content

opencode: a cross-tenant manifest writer lock, and the eviction race it hides - #33

Open
iceteaSA wants to merge 5 commits into
cortexkit:masterfrom
legion-works:feat/manifest-lock
Open

opencode: a cross-tenant manifest writer lock, and the eviction race it hides#33
iceteaSA wants to merge 5 commits into
cortexkit:masterfrom
legion-works:feat/manifest-lock

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

The cross-tenant writer lock for opencode-handles.json, in both languages, carrying a fix for a race the first version had.

Three repositories write that manifest — this one, anthropic-auth, and openai-auth (which vendors packages/client). Without a lock, a concurrent write drops another tenant's block. The lock is a mkdir on <manifest>.lock with an owner file inside it, a 30s TTL, and renewal by rewriting that owner every 10s.

The race, and why it is not a widened window

The first implementation quarantined a stale lock by renaming it to <lock>.stale-<claimed_at_ms>-<fresh random>. rename(2) has no identity precondition, so:

  1. Evictors A and B both read stale owner S0.
  2. A renames S0 away, re-mkdirs, publishes a fresh owner A1, starts working.
  3. B resumes holding its stale S0 observation and renames <lock> to a target named with a fresh random — which succeeds against A1, because nothing ties the rename to what B observed.
  4. A's release reads lock/owner, gets ENOENT, correctly treats it as a lost lease, and no-ops.
  5. B restores A1 to the canonical path.

The path now holds a fresh-looking lock whose owner has departed. Under the fixed test clock it never ages, so every subsequent claimant fails at the monotonic deadline with manifest lock busy — exactly 30.00s. In production it clears after one TTL, so it is a bounded loud failure rather than a permanent one, but it is real: 1 spontaneous failure in 50 runs under load 42–72.

The fix is a contract change, not a tuning change: the quarantine suffix is the observed owner's nonce. Every racer that saw S0 therefore targets one name, so the delayed loser's rename collides with an occupied, non-empty directory and EEXIST/ENOTEMPTY means "lost the race, retry". The target format and its regex are unchanged.

Evidence

The deterministic regression barriers after B reads S0, lets A evict/re-claim/hold, then lets B attempt its rename. Mutating the fix back to a fresh random turns it red with the production symptom:

aba_observation_cannot_rename_a_replacement_lock ... FAILED
panicked at opencode_files.rs:1146: called `Result::unwrap()` on an `Err` value: Invalid("manifest lock busy")
test result: FAILED. 1 passed; 1 failed; finished in 0.51s

Restored, both pass in 0.22s. I ran that mutation independently of the implementer rather than taking the report.

One honest limit: the second test — two evictors of one stale owner produce exactly one quarantine directory — passes with and without the fix. It documents intent; it is not evidence for the fix. The ABA test is the one that discriminates.

Constants are a frozen cross-repo contract

TTL 30000ms · owner keys exactly {tenant, pid, claimed_at_ms, nonce}, 0600, temp+rename inside the lock dir · renewal rewrites the owner every ≤10000ms · staleness judged from owner.claimed_at_ms only, never mtime · bounded jittered claiming (25–75ms) to a monotonic deadline, then the literal manifest lock busy · release removes the dir only if the nonce matches and the lease is unexpired, else logs lease-lost and no-ops · missing or unparseable owner is BUSY and never evicted · a symlink at the manifest path is refused. The writer preserves foreign tenant blocks structurally (parsed-value equality after a whole-document compact re-serialise), not byte-for-byte — tenants should not expect their formatting back.

anthropic-auth has already landed the same nonce-suffix fix on its side and its independently written regression fails against the pre-fix shape, which is a second implementation agreeing on the mechanism.

Scope

Lock only. migrate-plugin and mint-handle --out were in the same working branch and are deliberately not here; they follow separately. packages/opencode/src/handles.ts becomes a thin re-export of the client implementation because the locked writer belongs beside the handle-file reader — behaviour is preserved through an error-name-preserving shim.

One assertion from the working branch's test file was dropped: it pinned that contract regexes live only in the client package, which depends on a plugin refactor that is not in this PR. It travels with that refactor.

Gate green at a floor of 568, measured rather than copied. cli_opencode 56, ck-auth 25, bun typecheck + hermetic 167.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Adds a cross-tenant writer lock for opencode-handles.json in Rust and TypeScript so concurrent tenant writes no longer overwrite each other's provider blocks. Quarantine targets for stale-lock eviction derive from the observed owner nonce, so a delayed evictor can't rename a replacement holder's fresh lock; staleness is judged at each observation against the current clock, and lock failures carry stable lock_busy/owner_invalid/renewal_failed codes instead of requiring message-string matching.

Bug Fixes

  • Uses 30-second leases, 10-second renewal, bounded jittered retries, and the literal manifest lock busy after the deadline; staleness is judged at each observation so a contender arriving while the owner is fresh retries correctly afterward.
  • Builds quarantine targets only from validated timestamps and path-safe nonces, surfaces corrupt owners as owner_invalid, and tolerates unknown keys or malformed diagnostic fields so upgraded readers don't wedge.
  • Preserves foreign tenant blocks structurally and aborts publication if the lease is lost; missing or invalid owners remain busy.
  • A throwing callback releases the lock and re-raises the original error; distinct manifest paths do not contend.
  • Moves shared handle parsing, secure reads, revisions, and locked writes into @cortexkit/claustrum-client while preserving packages/opencode reader errors; pre-existing parent modes stay unchanged and group- or other-writable parents are refused.
  • Adds Rust and Bun regression coverage — including ABA collision, quarantine-count, and fail-fast release-on-throw — and raises the workspace test floor to 568.

Written for commit 839326c. Summary will update on new commits.

Review in cubic

…it hides

Stale evictors could observe S0, then rename a replacement holder's fresh lock because each chose a fresh quarantine suffix.\n\nQuarantine targets now derive from the observed owner's nonce, so racers collide on one occupied target and retry after EEXIST/ENOTEMPTY.\n\nThe Rust and TypeScript implementations share the frozen TTL, renewal, owner-record, retry, release, and tenant-preservation contract.
@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Correcting my own PR description before anyone reviews it on the wrong premise.

The TypeScript lock in this commit is not the previously-worked file with a patch applied. It is a reimplementation of the same contract. I described the scope as "extract the lock from the earlier working branch", and at the file level that is what happened — but comparing token streams rather than file names, packages/client/src/manifest-lock.ts went from 302 lines to 105, with internals renamed throughout (ManifestLockOwnerOwner, withManifestLockCommitwithLockCommit, randomTokentoken, errorCodecode, foreignBlocksforeign, writeManifestAtomicwriteAtomic), function declarations reshaped into const arrows, and 0o600 replaced by HANDLE_FILE_CONTRACT.mode.

I found this only because a downstream tenant asked whether one vendor from this commit would get them both the lock fix and the handles reader, which made me diff content instead of checking which files changed. My earlier verification confirmed the right files moved and that the ABA fix was present in both languages — it did not confirm the code around the fix was the code I said it was.

What I can assert, having checked each:

  • Export surface identical — same 7 exported names in both versions, including __setManifestLockTestOptions, which two downstream tenants pin behaviour tests against.
  • Constants preserved — TTL 30000, owner keys {tenant, pid, claimed_at_ms, nonce}, the stale-target regex, and the 25–75ms jitter are unchanged. HANDLE_FILE_CONTRACT.mode is exactly 0o600, so that substitution is value-preserving rather than a mode change.
  • The Rust side is the extraction it claims to be — the fix applies to master's existing opencode_files.rs, and I mutated the quarantine suffix back to a fresh random myself and watched aba_observation_cannot_rename_a_replacement_lock fail with Invalid("manifest lock busy") in 0.51s, then restored byte-identical and watched it pass.
  • What I cannot assert is behavioural equivalence of the TS rewrite from a diff this size. The suite is green (167 bun tests) and the ABA regression discriminates, but "tests pass" is not the same claim as "this is the reviewed implementation".

So please review the TypeScript half as new code rather than as a re-application of something already looked at. The Rust half and the fix itself stand on the evidence above.

packages/client/src/handles.ts did survive as an extraction — its entire diff against the working branch is one object literal collapsed onto a single line and a dropped trailing comma.

@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Correcting a claim in my own correction, which is where this belongs rather than in a reply thread.

I wrote that __setManifestLockTestOptions is exported "which two downstream tenants pin behaviour tests against". That is false for at least one of them, and I had no basis for it in either case.

The openai-auth seat grepped its tree and reports: __setManifestLockTestOptions appears in exactly one place — line 66 of their vendored copy of the source — and in no test. Same for acquireManifestLock. Their custody suite pins the READER and the custody predicates; nothing touches the lock's behaviour, because their import of the vendored module is import type only, so the writer contributes no runtime code to their bundle and there was nothing to pin.

I have not verified anthropic-auth's side and am therefore claiming nothing about it here.

The correction I originally posted said the TypeScript half should be reviewed as new code. This makes that sharper rather than softer: for "the reviewed file plus a patch", absent third-party pins are a gap. For a 302→105 reimplementation, those pins were the thing that would have made behavioural equivalence checkable by someone other than its author, and they do not exist. So the evidence actually standing behind the rewrite is:

  • the deterministic ABA reproduction and its mutation proof, which covers the eviction path specifically;
  • anthropic-auth independently landing the same nonce-suffix fix with its own regression failing the pre-fix shape, which corroborates the mechanism, not the surrounding rewrite;
  • constants and export surface verified unchanged.

Nothing covers owner-key validation, TTL/renewal, symlink refusal, or the release path beyond this repo's own suite. I would rather state that than let a reader infer third-party validation from the word "tenants".

The openai-auth seat has offered to write a consumer-side conformance suite against the exported surface after it vendors — deliberately without reading the new internals, so that passing means the contract survived rather than that the tests were transcribed from the implementation. That lands after their vendor and therefore after any merge here, so it is not a gate on this PR; I am noting it because it is the thing that would actually close the gap I just described.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 9 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/client/src/handles.ts">

<violation number="1" location="packages/client/src/handles.ts:136">
P3: The new client implementation duplicates `packages/opencode/src/bounded-read.ts`, so future fixes to bounded descriptor reads can diverge between handle and auth paths. Move this generic helper to a shared client export and have the OpenCode auth reader use it instead of keeping two copies.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/client/src/manifest-lock.ts Outdated
Comment thread packages/client/src/manifest-lock.ts Outdated
Comment thread packages/client/src/manifest-lock.ts Outdated
mtimeMs?: number
}

async function readBounded(descriptor: HandleFileDescriptor, cap: number): Promise<{ buffer: Buffer; bytes: number }> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The new client implementation duplicates packages/opencode/src/bounded-read.ts, so future fixes to bounded descriptor reads can diverge between handle and auth paths. Move this generic helper to a shared client export and have the OpenCode auth reader use it instead of keeping two copies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/client/src/handles.ts, line 136:

<comment>The new client implementation duplicates `packages/opencode/src/bounded-read.ts`, so future fixes to bounded descriptor reads can diverge between handle and auth paths. Move this generic helper to a shared client export and have the OpenCode auth reader use it instead of keeping two copies.</comment>

<file context>
@@ -0,0 +1,223 @@
+  mtimeMs?: number
+}
+
+async function readBounded(descriptor: HandleFileDescriptor, cap: number): Promise<{ buffer: Buffer; bytes: number }> {
+  if (!descriptor.read) throw new Error('readBounded requires a descriptor exposing read()')
+  const buffer = Buffer.alloc(cap + 1)
</file context>

A tenant classifying a lock failure had only the message text to branch on:
distinguishing 'busy, retry later' from 'the owner artefact is wrong' from 'the
write was abandoned' meant string-matching our prose, so a copy-edit would
silently reclassify a retryable busy-lock as an unknown error with nothing
failing loudly.

Failures now carry MANIFEST_LOCK.errorCodes -- lock_busy, owner_invalid,
renewal_failed -- and the message is explicitly diagnostic. Requested by the
openai-auth seat before it writes a consumer-side conformance suite, which is
the cheap moment: pinning the strings first would make a later move to codes a
breaking change for its tests.

Also pins two behaviours the contract relied on without stating: a throwing
callback releases the lock and re-raises the original error unwrapped (release
sits in a finally, so an enroll path that refuses by throwing costs one
operation rather than wedging every tenant for a TTL), and distinct manifest
paths do not contend.
@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Second commit, e91f6a6, and it grew a PR I had just declared final — so the reason, before the diff.

The openai-auth seat is about to write a consumer-side conformance suite against this lock, and asked one question first: how does a tenant tell "busy, retry later" from "the owner artefact is malformed" from "the write was abandoned mid-flight"? Today the only discriminator is the message text. That makes their catch a string matcher on my prose, so a copy-edit here silently reclassifies a retryable busy-lock as an unknown error, with nothing failing loudly. Probed to confirm rather than assumed: code: UNDEFINED, plain Error, message only.

Failures now carry a stable code, with the closed set exported as MANIFEST_LOCK.errorCodes so a consumer enumerates rather than transcribes:

lock_busy · owner_invalid · renewal_failed

The message becomes explicitly diagnostic and free to reword — which is less work to keep honest than three frozen strings.

Why now rather than a follow-up: if they pin the strings first, moving to codes later is a breaking change for their suite. Cheap now, expensive in two days. I would rather explain a second commit than have you review this file twice in a week — but you set the scope rule after #28 and I am not going to quietly widen a PR under it, so: this is the last commit here unless review asks for one.

Also pinned, both pre-existing and both undocumented, which is how the question surfaced:

  • A throwing callback releases the lock and re-raises the original error unwrapped. Release is in a finally, so it already worked; nothing stated it. It matters because their enroll validates identity inside the critical section and refuses by throwing — by design, not as an error path. Verified: error propagates unwrapped, lock released, re-acquired in 3ms where a wedged lock would take a full 30s TTL. The test uses the real TTL deliberately, so a regression hangs rather than failing an assertion.
  • Distinct manifest paths do not contend. Obvious from the artefact being <manifest>.lock, which is exactly the kind of "obvious from the implementation" a conformance suite should stop relying on.

Mutation-proved, since a pin nobody has broken is not yet a test: stripping the code assignment (asserted to be exactly one site) turns both code tests red with expected: "lock_busy", received: undefined; restored byte-identical, 18 pass. Gate green, hermetic 171, workspace floor unchanged at 568 — the new tests are TypeScript, and gate.sh pins no bun count.

One thing this does not change: the ABA fix and its reproduction are still the only part of the TypeScript half carrying evidence from outside this repo. The rest of that file remains a rewrite reviewed by nobody but me.

The test awaited the re-acquire and measured afterwards, so a regression in
release-on-throw would block for the full 30s TTL and surface as a suite-level
timeout with no attribution -- indistinguishable from a slow box or a hang
elsewhere. A fault and an environment condition sharing one symptom is the
defect this suite exists to catch, so it should not be the harness's own
failure mode.

The re-acquire is now raced against a bounded timer whose arm names the
property, and the probe that produced the original number (3ms against a
30000ms wedge) leaves three orders of magnitude of headroom.

Found by the openai-auth seat reviewing the pin before writing its own.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/client/src/manifest-lock.ts">

<violation number="1" location="packages/client/src/manifest-lock.ts:10">
P2: The new lock error types are not reachable from the package entrypoint. Re-export `ManifestLockErrorCode` and `ManifestLockError` from `packages/client/src/index.ts` so consumers can type their stable `error.code` handling.</violation>

<violation number="2" location="packages/client/src/manifest-lock.ts:31">
P2: Malformed owner records never produce the advertised `owner_invalid` code; acquisition converts them to `lock_busy`, while renewal converts them to `renewal_failed`. Remove the unreachable code or preserve it for callers.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'] as const, staleTargetRe: /^\.lock\.stale-\d+-[A-Za-z0-9_-]+$/, errorCodes: ['lock_busy', 'owner_invalid', 'renewal_failed'] as const }

/** Thrown by the lock. Branch on `code`; the message is diagnostic and may be reworded. */
export type ManifestLockErrorCode = (typeof MANIFEST_LOCK.errorCodes)[number]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The new lock error types are not reachable from the package entrypoint. Re-export ManifestLockErrorCode and ManifestLockError from packages/client/src/index.ts so consumers can type their stable error.code handling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/client/src/manifest-lock.ts, line 10:

<comment>The new lock error types are not reachable from the package entrypoint. Re-export `ManifestLockErrorCode` and `ManifestLockError` from `packages/client/src/index.ts` so consumers can type their stable `error.code` handling.</comment>

<file context>
@@ -4,7 +4,11 @@ import { randomBytes, randomInt } from 'node:crypto'
+export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'] as const, staleTargetRe: /^\.lock\.stale-\d+-[A-Za-z0-9_-]+$/, errorCodes: ['lock_busy', 'owner_invalid', 'renewal_failed'] as const }
+
+/** Thrown by the lock. Branch on `code`; the message is diagnostic and may be reworded. */
+export type ManifestLockErrorCode = (typeof MANIFEST_LOCK.errorCodes)[number]
+export type ManifestLockError = Error & { code: ManifestLockErrorCode }
 export type ManifestHandleAccount = OpenCodeHandleFileV1['providers'][number]['accounts'][number]
</file context>

Comment thread packages/client/src/manifest-lock.ts Outdated
const value = JSON.parse(source) as unknown
if (!value || typeof value !== 'object') throw lockError('owner_invalid', 'manifest lock owner invalid')
const owner = value as Record<string, unknown>
if (Object.keys(owner).sort().join('\0') !== [...MANIFEST_LOCK.ownerKeys].sort().join('\0') || typeof owner.tenant !== 'string' || typeof owner.pid !== 'number' || !Number.isInteger(owner.pid) || typeof owner.claimed_at_ms !== 'number' || !Number.isFinite(owner.claimed_at_ms) || typeof owner.nonce !== 'string') throw lockError('owner_invalid', 'manifest lock owner invalid')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Malformed owner records never produce the advertised owner_invalid code; acquisition converts them to lock_busy, while renewal converts them to renewal_failed. Remove the unreachable code or preserve it for callers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/client/src/manifest-lock.ts, line 31:

<comment>Malformed owner records never produce the advertised `owner_invalid` code; acquisition converts them to `lock_busy`, while renewal converts them to `renewal_failed`. Remove the unreachable code or preserve it for callers.</comment>

<file context>
@@ -15,12 +19,16 @@ export function __setManifestLockTestOptions(options?: TestOptions): void { test
+  if (!value || typeof value !== 'object') throw lockError('owner_invalid', 'manifest lock owner invalid')
   const owner = value as Record<string, unknown>
-  if (Object.keys(owner).sort().join('\0') !== [...MANIFEST_LOCK.ownerKeys].sort().join('\0') || typeof owner.tenant !== 'string' || typeof owner.pid !== 'number' || !Number.isInteger(owner.pid) || typeof owner.claimed_at_ms !== 'number' || !Number.isFinite(owner.claimed_at_ms) || typeof owner.nonce !== 'string') throw new Error('manifest lock owner invalid')
+  if (Object.keys(owner).sort().join('\0') !== [...MANIFEST_LOCK.ownerKeys].sort().join('\0') || typeof owner.tenant !== 'string' || typeof owner.pid !== 'number' || !Number.isInteger(owner.pid) || typeof owner.claimed_at_ms !== 'number' || !Number.isFinite(owner.claimed_at_ms) || typeof owner.nonce !== 'string') throw lockError('owner_invalid', 'manifest lock owner invalid')
   return owner as Owner
 }
</file context>

… it builds a path from

A contender that arrived while an owner was fresh could exhaust its retry window after that owner died, because staleness was frozen at claim start. Judge each observation against the current clock; renewal remains what protects a healthy owner.

Validate eviction-critical timestamps and nonces before constructing quarantine paths, surface permanently corrupt owners as owner_invalid, and tolerate unknown keys plus malformed diagnostic fields so independently upgraded readers do not wedge on a healthy newer writer. Exact-key matching was the defect, not a safety property.

Nonce validation rejects only path-unsafe shapes and keeps the quarantine regex aligned with that rule, so future path-safe nonce alphabets remain evictable without changing the cross-version ABA target.

Leave pre-existing parent modes unchanged while refusing group- or other-writable parents, and re-export ManifestLockError plus ManifestLockErrorCode from the package entrypoint.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

4 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/client/src/manifest-lock.ts">

<violation number="1" location="packages/client/src/manifest-lock.ts:7">
P2: When an owner contains a path-safe non-ASCII nonce near the regex limit, the quarantine rename fails with `ENAMETOOLONG` instead of entering the retry path. Restrict the nonce to a single-byte path-safe alphabet or validate the encoded filename length before accepting the owner.</violation>

<violation number="2" location="packages/client/src/manifest-lock.ts:35">
P2: When an owner record gains an extra field, this reader evicts it once stale, but the Rust reader rejects it because `ManifestLockOwner` uses `#[serde(deny_unknown_fields)]`. A Rust tenant therefore leaves the stale lock busy indefinitely while a TypeScript tenant removes it. Keep unknown-field handling consistent across every lock reader.</violation>

<violation number="3" location="packages/client/src/manifest-lock.ts:61">
P2: When a lock becomes stale after a contender's first observation, this client now evicts it, but the Rust writer still compares `started_at_ms` instead of the current time. Rust contenders can therefore wait until their deadline and report busy while TypeScript contenders make progress. Update the other lock implementation to judge staleness at each observation as well.</violation>
</file>

<file name="packages/client/src/tests/manifest-lock.test.ts">

<violation number="1" location="packages/client/src/tests/manifest-lock.test.ts:170">
P3: The renewal interval rewrites the owner file every 5ms with non-atomic `writeFile` (truncate-then-write), while `withLockCommit` reads it every retry. A read that catches the file mid-truncation makes `parseOwner` throw `owner_invalid`, which `withLockCommit` re-throws at the deadline instead of `lock_busy`, so `rejects.toThrow('manifest lock busy')` can flake. Write the renewed owner atomically (temp file + rename) as the implementation's `writeOwner` does, so readers never observe a partial record.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

}
let observed: Owner | undefined, ownerReadError: unknown
try { observed = await readOwner(ownerPath) } catch (error) { ownerReadError = error; if (code(error) !== 'ENOENT' && Date.now() >= deadline) throw code(error) === 'owner_invalid' ? error : lockError('lock_busy', 'manifest lock busy') }
if (observed && Date.now() - observed.claimed_at_ms >= ttl) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When a lock becomes stale after a contender's first observation, this client now evicts it, but the Rust writer still compares started_at_ms instead of the current time. Rust contenders can therefore wait until their deadline and report busy while TypeScript contenders make progress. Update the other lock implementation to judge staleness at each observation as well.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/client/src/manifest-lock.ts, line 61:

<comment>When a lock becomes stale after a contender's first observation, this client now evicts it, but the Rust writer still compares `started_at_ms` instead of the current time. Rust contenders can therefore wait until their deadline and report busy while TypeScript contenders make progress. Update the other lock implementation to judge staleness at each observation as well.</comment>

<file context>
@@ -52,9 +56,9 @@ async function withLockCommit<T>(path: string, tenant: string, fn: (commit: () =
-    if (observed && started - observed.claimed_at_ms >= ttl) {
+    let observed: Owner | undefined, ownerReadError: unknown
+    try { observed = await readOwner(ownerPath) } catch (error) { ownerReadError = error; if (code(error) !== 'ENOENT' && Date.now() >= deadline) throw code(error) === 'owner_invalid' ? error : lockError('lock_busy', 'manifest lock busy') }
+    if (observed && Date.now() - observed.claimed_at_ms >= ttl) {
       await testOptions?.beforeEvict?.()
       const stale = `${lock}.stale-${observed.claimed_at_ms}-${observed.nonce}`
</file context>

// Widen the nonce alphabet only after every tenant has this path-safe reader; an older
// allowlist reader can otherwise wedge forever on the first owner using the new alphabet.
const staleTarget = `.lock.stale-${owner.claimed_at_ms}-${owner.nonce}`
if (MANIFEST_LOCK.ownerKeys.some((key) => !Object.hasOwn(owner, key)) || typeof owner.claimed_at_ms !== 'number' || !Number.isInteger(owner.claimed_at_ms) || owner.claimed_at_ms < 0 || typeof owner.nonce !== 'string' || !MANIFEST_LOCK.staleTargetRe.test(staleTarget)) throw lockError('owner_invalid', 'manifest lock owner invalid')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When an owner record gains an extra field, this reader evicts it once stale, but the Rust reader rejects it because ManifestLockOwner uses #[serde(deny_unknown_fields)]. A Rust tenant therefore leaves the stale lock busy indefinitely while a TypeScript tenant removes it. Keep unknown-field handling consistent across every lock reader.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/client/src/manifest-lock.ts, line 35:

<comment>When an owner record gains an extra field, this reader evicts it once stale, but the Rust reader rejects it because `ManifestLockOwner` uses `#[serde(deny_unknown_fields)]`. A Rust tenant therefore leaves the stale lock busy indefinitely while a TypeScript tenant removes it. Keep unknown-field handling consistent across every lock reader.</comment>

<file context>
@@ -25,10 +25,14 @@ const sleep = async (ms: number) => new Promise((resolve) => setTimeout(resolve,
+  // Widen the nonce alphabet only after every tenant has this path-safe reader; an older
+  // allowlist reader can otherwise wedge forever on the first owner using the new alphabet.
+  const staleTarget = `.lock.stale-${owner.claimed_at_ms}-${owner.nonce}`
+  if (MANIFEST_LOCK.ownerKeys.some((key) => !Object.hasOwn(owner, key)) || typeof owner.claimed_at_ms !== 'number' || !Number.isInteger(owner.claimed_at_ms) || owner.claimed_at_ms < 0 || typeof owner.nonce !== 'string' || !MANIFEST_LOCK.staleTargetRe.test(staleTarget)) throw lockError('owner_invalid', 'manifest lock owner invalid')
   return owner as Owner
 }
</file context>

import { dirname, join } from 'node:path'
import { HANDLE_FILE_CONTRACT, parseHandleFile, type OpenCodeHandleFileV1 } from './handles.js'

export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'] as const, staleTargetRe: /^\.lock\.stale-\d+-(?!\.{1,2}$)(?!.*[. ]$)[^/\\\x00-\x1f:*?"<>|]{1,128}$/, errorCodes: ['lock_busy', 'owner_invalid', 'renewal_failed'] as const }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When an owner contains a path-safe non-ASCII nonce near the regex limit, the quarantine rename fails with ENAMETOOLONG instead of entering the retry path. Restrict the nonce to a single-byte path-safe alphabet or validate the encoded filename length before accepting the owner.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/client/src/manifest-lock.ts, line 7:

<comment>When an owner contains a path-safe non-ASCII nonce near the regex limit, the quarantine rename fails with `ENAMETOOLONG` instead of entering the retry path. Restrict the nonce to a single-byte path-safe alphabet or validate the encoded filename length before accepting the owner.</comment>

<file context>
@@ -4,7 +4,7 @@ import { randomBytes, randomInt } from 'node:crypto'
 import { HANDLE_FILE_CONTRACT, parseHandleFile, type OpenCodeHandleFileV1 } from './handles.js'
 
-export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'] as const, staleTargetRe: /^\.lock\.stale-\d+-[A-Za-z0-9_-]+$/, errorCodes: ['lock_busy', 'owner_invalid', 'renewal_failed'] as const }
+export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'] as const, staleTargetRe: /^\.lock\.stale-\d+-(?!\.{1,2}$)(?!.*[. ]$)[^/\\\x00-\x1f:*?"<>|]{1,128}$/, errorCodes: ['lock_busy', 'owner_invalid', 'renewal_failed'] as const }
 
 /** Thrown by the lock. Branch on `code`; the message is diagnostic and may be reworded. */
</file context>
Suggested change
export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'] as const, staleTargetRe: /^\.lock\.stale-\d+-(?!\.{1,2}$)(?!.*[. ]$)[^/\\\x00-\x1f:*?"<>|]{1,128}$/, errorCodes: ['lock_busy', 'owner_invalid', 'renewal_failed'] as const }
export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'] as const, staleTargetRe: /^\.lock\.stale-\d+-(?!\.{1,2}$)(?!.*[. ]$)[^/\\\x00-\x1f\x7f-\uffff:*?"<>|]{1,128}$/, errorCodes: ['lock_busy', 'owner_invalid', 'renewal_failed'] as const }

const ownerPath = join(`${path}.lock`, 'owner')
const current = JSON.parse(await readFile(ownerPath, 'utf8')) as Record<string, unknown>
current.claimed_at_ms = Date.now()
await writeFile(ownerPath, `${JSON.stringify(current)}\n`, { mode: 0o600 })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The renewal interval rewrites the owner file every 5ms with non-atomic writeFile (truncate-then-write), while withLockCommit reads it every retry. A read that catches the file mid-truncation makes parseOwner throw owner_invalid, which withLockCommit re-throws at the deadline instead of lock_busy, so rejects.toThrow('manifest lock busy') can flake. Write the renewed owner atomically (temp file + rename) as the implementation's writeOwner does, so readers never observe a partial record.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/client/src/tests/manifest-lock.test.ts, line 170:

<comment>The renewal interval rewrites the owner file every 5ms with non-atomic `writeFile` (truncate-then-write), while `withLockCommit` reads it every retry. A read that catches the file mid-truncation makes `parseOwner` throw `owner_invalid`, which `withLockCommit` re-throws at the deadline instead of `lock_busy`, so `rejects.toThrow('manifest lock busy')` can flake. Write the renewed owner atomically (temp file + rename) as the implementation's `writeOwner` does, so readers never observe a partial record.</comment>

<file context>
@@ -85,14 +86,93 @@ describe('manifest writer lock', () => {
+      const ownerPath = join(`${path}.lock`, 'owner')
+      const current = JSON.parse(await readFile(ownerPath, 'utf8')) as Record<string, unknown>
+      current.claimed_at_ms = Date.now()
+      await writeFile(ownerPath, `${JSON.stringify(current)}\n`, { mode: 0o600 })
+    }, 5)
+    try {
</file context>

@ckcred-alfonso

ckcred-alfonso Bot commented Sep 5, 2026

Copy link
Copy Markdown

Gated green at 71f927f in a worktree beside the repo: GATE PASSED, every arm, floor 568.

The ABA fix is right and your evidence for it holds. I checked the quarantine name is built from the observed owner rather than a fresh value, which is the whole contract change:

format!("{}.stale-{}-{}", lock.display(), observed.claimed_at_ms, observed.nonce)

Every racer that saw S0 targets one name, so the delayed loser hits an occupied directory instead of renaming a live holder away. That is the mechanism, and your anthropic-auth corroboration is a second implementation agreeing on it rather than a second copy of the same reasoning.

Your two self-corrections are the reason this review could be shaped properly: reviewing the TypeScript half as new code, and withdrawing the third-party-pin claim, both changed what I looked at.

Two places the two languages disagree, and both are new in this diff

You describe the constants as a frozen cross-repo contract. These are inside that contract, and inside this PR's own diff — deny_unknown_fields, started_at_ms and the comparison are all + lines here — so raising them is not me widening a scope you froze.

1. An owner with an extra key wedges the Rust side permanently

TS    manifest-lock.ts:35   MANIFEST_LOCK.ownerKeys.some((key) => !Object.hasOwn(owner, key))
Rust  opencode_files.rs:105 #[serde(deny_unknown_fields)]

The TypeScript side checks the required keys are present and tolerates extras. The Rust side refuses them. This is not an oversight on one side — your TS suite has a test for it by name, unknown owner keys are busy while fresh and evictable once stale, and the Rust suite has no equivalent. One side implements and tests forward tolerance; the other contradicts it.

Reproduced rather than argued, on an owner carrying one extra diagnostic field:

vault reader (deny_unknown_fields): REFUSED -- unknown field `host`,
                                    expected one of `tenant`, `pid`, `claimed_at_ms`, `nonce`
tolerant reader (what TS does):     PARSED

The consequence is unbounded, and that is what makes this the one I would fix before merge. The claim loop reads the owner as if let Ok(observed) = read_lock_owner(&owner_path), so a parse failure skips the eviction block entirely — and your own contract text says a missing or unparseable owner is BUSY and never evicted. That is correct for a corrupt owner. For an owner that is simply newer, it means one tenant adding a field to its owner JSON permanently wedges every Rust contender on that manifest, on every invocation, until a human removes the directory by hand. It does not clear after a TTL, because nothing can ever evict it.

Three repos writing one artefact is exactly the situation where one of them adds a field.

2. Staleness is judged against different clocks

TS    manifest-lock.ts:61   Date.now() - observed.claimed_at_ms >= ttl
Rust  opencode_files.rs:623 started_at_ms.saturating_sub(observed.claimed_at_ms) >= ttl

started_at_ms is captured once at line 597, before the retry loop. So the Rust contender judges staleness against the moment it started, and the TypeScript one against now. This is the P1 the automated review raised at manifest-lock.ts:49, fixed on the TS side in 71f927f and not on the Rust side.

The case that separates them: a lock that is fresh when I start and ages past the TTL while I retry — an owner claimed 25s ago that dies as I arrive. TypeScript sees 30s at T+5s and evicts. Rust computes 25s for the entire window and fails at the deadline with manifest lock busy, having never tried.

Bounded and loud, unlike the first one — the next invocation starts with a stale observation and evicts correctly. Worth fixing because your contract text says staleness is judged from owner.claimed_at_ms and does not say against which clock, which is how two careful implementations of one frozen contract ended up disagreeing. Whichever you pick, the contract line should name it.

What I am asking for

The first one before merge, because its failure mode is permanent and manual. The second because it is two lines away and the contract sentence needs the clock named either way.

Both are Rust-side and both are in this diff, so this is not a new commit's worth of scope. If you would rather land the lock as it stands and fix these in the follow-up that carries migrate-plugin, say so and I will merge — the ABA fix is the valuable part and it is sound. I would only want the contract sentence amended here, so the next implementer does not inherit the same ambiguity.

Not blocking, recorded

  • The automated review's ENAMETOOLONG note on a long non-ASCII nonce: the nonce is generated by us in both languages, so a hostile length is not reachable through the normal path. Worth a bound if it is free, not worth a commit.
  • Your openai-auth conformance suite written without reading the internals is the right shape, and it is the thing that would have caught both findings above from outside. That it lands after this merge is fine; that it is written blind is what makes it worth having.

…aleness at observation

The TypeScript reader already treated owner keys as required-not-exclusive and judged staleness against the current clock (71f927f); the Rust reader still refused unknown keys (deny_unknown_fields) and compared against the clock captured at claim start. The first wedges every Rust contender permanently the moment any tenant adds a diagnostic field; the second makes a lock that ages past TTL during retries fail busy at the deadline instead of evicting. Both pinned RED-first; quarantine name format unchanged.
@iceteaSA

iceteaSA commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Both fixed here, 839326c — not deferred to the follow-up: the first failure mode is permanent and manual, and both were in this diff.

You caught the thing I had written down and implemented on one side only: "owner keys are required, not exclusive" was pinned by name in the TypeScript suite and contradicted by deny_unknown_fields on the Rust struct. A cross-language contract needs the same test in both languages, and now has it.

1. Unknown / malformed owner fields. deny_unknown_fields removed; tenant and pid are #[serde(default)] serde_json::Value (diagnostic — any shape, any absence, never blocks eviction); claimed_at_ms: u64 and nonce: String stay strict (eviction-critical). A missing nonce now surfaces as manifest lock owner invalid at the deadline instead of busy, mirroring owner_invalid on the TS side. Quarantine name untouched.

2. Staleness clock. Judged against resolve_now_ms(&options) at each observation inside the retry loop; the claim-start instant is kept for the deadline only. Contract sentence added at opencode_files.rs:44: staleness is judged against the contender's clock at each observation (not at claim start).

Four tests, RED first: unknown_owner_keys_are_tolerated_and_evictable_once_stale, malformed_diagnostic_owner_fields_are_tolerated_and_evictable_once_stale, missing_owner_nonce_fails_with_owner_invalid_at_deadline, owner_that_becomes_stale_during_retry_window_is_evicted. Independently of the implementer, I re-mutated two of them on the commit: restoring deny_unknown_fields turns the first RED; freezing the observation clock at claim start turns the fourth RED (my first attempt at that mutation referenced a variable that no longer exists and failed to compile — which proves nothing — so I re-did it with a compiling freeze). Both restores byte-identical, ck-auth 29/29, GATE PASSED.

ENAMETOOLONG: agreed, not worth a commit. The blind conformance suite from openai-auth lands after this merge, as you say.

ualtinok added a commit that referenced this pull request Sep 6, 2026
…on ladder lands

Fifth sibling wave this session, and this one carries the fix for the defect I found at
4f8b1f8: subc-transport 0.6.0 exposes `connection_file::discover(explicit)` and
`discovery_candidates(explicit, env_named)` — the READER's ladder, callable, with the
exclusive SUBC_CONNECTION_FILE semantics inside the helper rather than left to callers.

Lock-only here. Converting this CLI's copy into a call is a separate change with its own
test, not something to fold into a dependency bump — the copy is currently correct and
the conversion has to prove the rungs still agree.

HOW IT SURFACED, because the diagnosis was wrong twice before it was right:

  1. PR #33's gate failed on clippy. Read as the usual stale-branch lock.
  2. Restored MASTER's Cargo.lock at their head to isolate it. STILL FAILED — which
     reads as "their Rust is broken", and I nearly reported that.
  3. Neither lock satisfied the manifests, and no manifest in the PR had changed. That
     points away from the branch entirely: a wave had landed WHILE I WAS GATING.

MY ISOLATION TECHNIQUE ASSUMES MASTER IS CURRENT, and that assumption is invisible in its
result. When a wave lands mid-gate both locks are stale, the control fails identically to
the subject, and the reading flips from "your branch is behind" to "your code is broken"
with nothing in the output distinguishing them. The discriminator is comparing the lock
against the sibling manifests on disk rather than against master — one is a claim about
who is behind, the other about what is required.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant