Skip to content

Commit 7be8077

Browse files
sahrizviclaude
andcommitted
fix(workspace): retry a failed workspace summary; make the unlink race test prove its claim
- A failed `/summary` fetch was cached as "not known" for the full five minutes, so one network blip hid the workspace's integrations that long. It is now kept for the failing step only; the next step asks again. - "a step arriving after an unlink does not join the pre-unlink resolve" waited for a second request on the wire. After the unlink there is no local binding, so step 2 settles without any request: the second request the test saw was a detached skill sync left over from an earlier test's bind. Loading `skill-sync` from the contents block shifted that timing and made it fail under load. The test now asserts the real invariant — step 2 settles while step 1 is still parked, and does not name the old workspace — and fails when both `inflight.clear()` and the generation check are removed. - `identity-section` tests reset the contents cache between tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV
1 parent 8ed08e3 commit 7be8077

3 files changed

Lines changed: 32 additions & 12 deletions

File tree

‎packages/opencode/src/altimate/workspace/contents.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ export async function workspaceSummary(datamateId: number, now = Date.now()): Pr
8787
let pending = hit?.pending
8888
if (!pending) {
8989
pending = fetchSummary(datamateId).then((value) => {
90-
summaries.set(datamateId, { at: Date.now(), value })
90+
// A failed fetch (integrations unknown) is kept for this step only, so the next step retries.
91+
summaries.set(datamateId, { at: value.integrations === null ? 0 : Date.now(), value })
9192
return value
9293
})
9394
summaries.set(datamateId, { at: hit?.at ?? 0, value: hit?.value, pending })

‎packages/opencode/test/altimate/workspace/contents.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ describe("workspace summary cache", () => {
7373
failing.mockRestore()
7474
})
7575

76+
test("a failed fetch is not cached: the next step asks again", async () => {
77+
const spy = spyOn(AltimateApi, "getDatamate")
78+
.mockRejectedValueOnce(new Error("offline"))
79+
.mockResolvedValueOnce({ id: "38", name: "w", integrations: [{ id: "github" }], memory_enabled: true } as any)
80+
expect(await Contents.workspaceSummary(38)).toEqual({ integrations: null, memoryEnabled: null })
81+
expect(await Contents.workspaceSummary(38)).toEqual({ integrations: ["github"], memoryEnabled: true })
82+
expect(spy).toHaveBeenCalledTimes(2)
83+
spy.mockRestore()
84+
})
85+
7686
test("a slow service does not hold the step: the answer is 'not known' until it arrives", async () => {
7787
let release!: (v: any) => void
7888
const spy = spyOn(AltimateApi, "getDatamate").mockImplementation(

‎packages/opencode/test/altimate/workspace/identity-section.test.ts‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ afterAll(() => {
3030
})
3131

3232
const { AltimateApi } = await import("../../../src/altimate/api/client")
33+
const Contents = await import("../../../src/altimate/workspace/contents")
3334
const {
3435
systemSection,
3536
resetOutcomeMemoForTests,
@@ -73,6 +74,7 @@ let projectDir = ""
7374
beforeEach(() => {
7475
process.env.ALTIMATE_WORKSPACE = "1"
7576
resetOutcomeMemoForTests()
77+
Contents.resetForTests()
7678
projectDir = mkdtempSync(path.join(SANDBOX, "proj-"))
7779
// Nothing here should need the network; anything that asks gets an empty 200.
7880
globalThis.fetch = (async () =>
@@ -518,12 +520,15 @@ describe("systemSection", () => {
518520
const gate = new Promise<void>((r) => (release = r))
519521
let firstStarted!: () => void
520522
const firstOnWire = new Promise<void>((r) => (firstStarted = r))
521-
let secondStarted!: () => void
522-
const secondOnWire = new Promise<void>((r) => (secondStarted = r))
523-
let calls = 0
524-
globalThis.fetch = (async () => {
525-
calls++
526-
if (calls === 1) {
523+
let bindingCalls = 0
524+
globalThis.fetch = (async (input: RequestInfo | URL) => {
525+
// Only binding resolves are gated. A detached skill sync left over from an earlier test's bind,
526+
// and the contents block's summary request, also reach this mock.
527+
if (!String(input instanceof Request ? input.url : input).includes("/datamate-project-bindings/")) {
528+
return new Response(JSON.stringify({}), { status: 200, headers: { "content-type": "application/json" } })
529+
}
530+
bindingCalls++
531+
if (bindingCalls === 1) {
527532
firstStarted()
528533
await gate
529534
return new Response(
@@ -534,7 +539,6 @@ describe("systemSection", () => {
534539
{ status: 200, headers: { "content-type": "application/json" } },
535540
)
536541
}
537-
secondStarted()
538542
return new Response(JSON.stringify({ detail: "not found" }), {
539543
status: 404,
540544
headers: { "content-type": "application/json" },
@@ -543,11 +547,16 @@ describe("systemSection", () => {
543547
const first = inProject(systemSection)
544548
await firstOnWire // step 1's request is out and parked on the gate
545549
await clearLocalBinding(projectDir, { scope: ACME_SCOPE })
546-
const second = inProject(systemSection)
547-
await secondOnWire // step 2 made its OWN request while step 1 was still pending
550+
// Step 2 must settle while step 1 is still parked. Had it joined step 1's resolve it would
551+
// wait out RESOLVE_DEADLINE_MS; the bound here is well inside that.
552+
const joined = Symbol("joined")
553+
const next = await Promise.race([
554+
inProject(systemSection),
555+
new Promise<typeof joined>((r) => setTimeout(() => r(joined), RESOLVE_DEADLINE_MS / 2)),
556+
])
548557
release()
549-
const [, next] = await Promise.all([first, second])
550-
expect(calls).toBe(2)
558+
await first
559+
expect(next).not.toBe(joined)
551560
expect(next).not.toContain('is "old"')
552561
expect(next).toContain("No Altimate Workspace")
553562
})

0 commit comments

Comments
 (0)