From ee1d325cfb04ded09ee0b7cb3307ea9bc25eeea2 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Sat, 1 Aug 2026 16:57:04 +0800 Subject: [PATCH] Codex/fix desktop build oom (#102) ### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** ### How did you verify your code works? ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ ### Checklist - [ ] I have tested my changes locally - [ ] I have not included unrelated changes in this PR _If you do not follow this template your PR will be automatically rejected._ --- .github/workflows/desktop-build.yml | 8 ++++ bun.lock | 2 +- packages/core/src/deepagent/atomic-write.ts | 26 ++++++++++++- packages/core/src/deepagent/document-store.ts | 36 +++++++++++++++++- .../src/deepagent/durable-knowledge-store.ts | Bin 18479 -> 18387 bytes .../test/deepagent/document-store.test.ts | 20 ++++++++++ packages/desktop/electron-builder.config.ts | 1 + packages/desktop/electron.vite.config.ts | 2 +- packages/desktop/package.json | 2 +- .../desktop/scripts/audit-server-bundle.ts | 21 +++++++++- packages/desktop/scripts/subagents-smoke.ts | 24 ++++++++---- 11 files changed, 128 insertions(+), 14 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 74f35a64..96a877ee 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -142,6 +142,14 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: npx electron-builder ${{ matrix.platform_flag }} --publish never --config electron-builder.config.ts + - name: Smoke test packaged macOS app + if: matrix.group == 'mac' + working-directory: packages/desktop + run: | + executable=$(find dist/mac-arm64 -maxdepth 4 -type f -path '*/Contents/MacOS/DeepAgent Code*' -perm +111 -print -quit) + test -n "$executable" + DEEPAGENT_CODE_DESKTOP_EXECUTABLE="$executable" bun run test:subagents-cold-start + - name: Upload package artifacts uses: actions/upload-artifact@v4 with: diff --git a/bun.lock b/bun.lock index bbbb4deb..20cbf817 100644 --- a/bun.lock +++ b/bun.lock @@ -349,7 +349,6 @@ "name": "@deepagent-code/desktop", "version": "1.4.4", "dependencies": { - "@deepagent-code/core": "workspace:*", "@lydell/node-pty": "catalog:", "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -364,6 +363,7 @@ "devDependencies": { "@actions/artifact": "4.0.0", "@deepagent-code/app": "workspace:*", + "@deepagent-code/core": "workspace:*", "@deepagent-code/ui": "workspace:*", "@jridgewell/trace-mapping": "0.3.31", "@playwright/test": "catalog:", diff --git a/packages/core/src/deepagent/atomic-write.ts b/packages/core/src/deepagent/atomic-write.ts index 861bf101..d038ebb9 100644 --- a/packages/core/src/deepagent/atomic-write.ts +++ b/packages/core/src/deepagent/atomic-write.ts @@ -1,4 +1,14 @@ -import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from "node:fs" +import { + closeSync, + fsyncSync, + linkSync, + mkdirSync, + openSync, + renameSync, + rmSync, + writeFileSync, + writeSync, +} from "node:fs" import { randomUUID } from "node:crypto" import path from "node:path" @@ -69,6 +79,20 @@ export const writeFileExclusive = (file: string, content: string): void => { } } +// Built-in domain-pack seeds are immutable packaged inputs and can be regenerated after a crash. +// Keep each visible file atomic, but avoid two fsyncs per seed during first-run corpus installation. +export const writeRecoverableFileExclusive = (file: string, content: string): void => { + const dir = path.dirname(file) + mkdirSync(dir, { recursive: true }) + const tmp = path.join(dir, `.${path.basename(file)}.seed-${process.pid}-${randomUUID()}`) + try { + writeFileSync(tmp, content, "utf8") + linkSync(tmp, file) + } finally { + rmSync(tmp, { force: true }) + } +} + // Best-effort directory fsync so a rename/create is durable. Not all platforms/filesystems permit // opening a directory for fsync (Windows throws EPERM/EISDIR, some FUSE mounts reject it); a failure // here does not compromise the file body (already fsync'd) so it is intentionally swallowed. diff --git a/packages/core/src/deepagent/document-store.ts b/packages/core/src/deepagent/document-store.ts index 0376a683..1f9e9e45 100644 --- a/packages/core/src/deepagent/document-store.ts +++ b/packages/core/src/deepagent/document-store.ts @@ -1,7 +1,7 @@ import { mkdirSync, readdirSync, readFileSync, existsSync } from "node:fs" import { createHash } from "node:crypto" import path from "node:path" -import { writeFileAtomic, writeFileExclusive } from "./atomic-write" +import { writeFileAtomic, writeFileExclusive, writeRecoverableFileExclusive } from "./atomic-write" // V3 Document System (docs/28): the bedrock. All persistent state is a typed-document // graph — small files, content-addressed, append-only with a supersede chain, bidirectional @@ -368,6 +368,26 @@ export class DocumentStore { return next } + // Trusted built-in corpus files are recoverable from the packaged domain packs. New seeds can + // therefore skip per-file fsync while retaining atomic visibility; updates still use the normal + // append-only durable path so shipped corpus revisions preserve version history. + seedActive(input: CreateDocInput): Doc { + const cur = this.findLogical(input) + if (cur) { + const doc = this.upsert(input) + if (doc.status !== "active") this.setStatus(doc.id, "active") + return this.get(doc.id)! + } + + const id = this.allocateId(input.type, input.domain ?? null, input.idSlug, input.description) + let doc = { ...this.docFromInput(id, 1, input), status: "active" as const } + this.assertKnowledgeConfidence(doc) + this.assertLinkTargets(doc.links) + doc = { ...doc, hash: computeHash(doc) } + this.persistRecoverable(doc) + return doc + } + update(id: string, body: string, links?: readonly DocLink[]): Doc { const cur = this.get(id) if (!cur) throw new Error(`update: unknown doc ${id}`) @@ -607,6 +627,20 @@ export class DocumentStore { } this.indexDoc(doc) } + private persistRecoverable(doc: Doc): void { + const dir = path.join(this.root, "docs", doc.type) + mkdirSync(dir, { recursive: true }) + const file = path.join(dir, `${idToFile(doc.id)}@v${doc.version}.json`) + try { + writeRecoverableFileExclusive(file, JSON.stringify(doc, null, 2)) + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") throw error + const existing = this.readVersionFile(file) + if (!existing || existing.hash !== doc.hash) + throw new DocumentConflictError(doc.id, doc.version, existing?.hash ?? "", doc.hash) + } + this.indexDoc(doc) + } private replace(doc: Doc): void { // rewrites the SAME version in place with new status/superseded_by; rehash so INV-2 holds. This // is an intentional overwrite (not a new version), so it uses the crash-safe atomic OVERWRITE diff --git a/packages/core/src/deepagent/durable-knowledge-store.ts b/packages/core/src/deepagent/durable-knowledge-store.ts index ab364124e6318476ec971591cb50ffbcfb5c9448..b237e8d2d204c4d0f9a2e70c35cfc96f97e6c781 100644 GIT binary patch delta 87 zcmZ2Kf$?%bJ_J^rZ^^-WR^`*39moIx diff --git a/packages/core/test/deepagent/document-store.test.ts b/packages/core/test/deepagent/document-store.test.ts index 45c004a1..4532bed0 100644 --- a/packages/core/test/deepagent/document-store.test.ts +++ b/packages/core/test/deepagent/document-store.test.ts @@ -174,6 +174,26 @@ describe("V3 DocumentStore", () => { // overwrites the same version atomically (temp+fsync+rename). These tests pin the CAS + durability // behavior that H32-1 (v4.0.4) builds on. describe("F30-1 DocumentStore CAS + atomic durability", () => { + test("recoverable built-in seeds retain active status and exclusive-create CAS", () => { + const h1 = new DocumentStore(root) + const h2 = new DocumentStore(root) + const input = { + type: "strategy" as const, + scope: "durable", + body: "trusted built-in strategy", + description: "built-in strategy", + idSlug: "built-in-strategy", + confidence: { evidence_strength: "strong" as const, support_count: 1 }, + provenance: { source: "human" as const }, + } + const first = h1.seedActive(input) + const concurrent = h2.seedActive(input) + expect(first.status).toBe("active") + expect(concurrent.hash).toBe(first.hash) + expect(new DocumentStore(root).get(first.id)).toEqual(first) + expect(readdirSync(path.join(root, "docs", "strategy"))).toEqual([`${first.id.replaceAll(":", "__")}@v1.json`]) + }) + test("normal single-writer flow is unchanged (create + updates land byte-identically)", () => { const a = store.create(design("v1")) const a2 = store.update(a.id, "v2") diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index ba308c7e..a177fdaf 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -62,6 +62,7 @@ const getBase = (): Configuration => ({ "resources/*.metainfo.xml", "resources/deepagent-code-cli*", ], + asarUnpack: ["out/main/chunks/node.js"], beforePack: () => auditPackageInputs(path.dirname(fileURLToPath(import.meta.url))), extraResources: [ { diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 9045594d..cc911e5a 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -39,7 +39,7 @@ export default defineConfig(({ command }) => ({ rollupOptions: { input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" }, }, - externalizeDeps: { include: ["@lydell/node-pty"] }, + externalizeDeps: { exclude: ["@deepagent-code/core"], include: ["@lydell/node-pty"] }, }, plugins: [ { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9c7497bb..ee695d87 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -36,7 +36,6 @@ }, "main": "./out/main/index.js", "dependencies": { - "@deepagent-code/core": "workspace:*", "@lydell/node-pty": "catalog:", "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -51,6 +50,7 @@ "devDependencies": { "@actions/artifact": "4.0.0", "@deepagent-code/app": "workspace:*", + "@deepagent-code/core": "workspace:*", "@deepagent-code/ui": "workspace:*", "@jridgewell/trace-mapping": "0.3.31", "@playwright/test": "catalog:", diff --git a/packages/desktop/scripts/audit-server-bundle.ts b/packages/desktop/scripts/audit-server-bundle.ts index da8e9043..c732fdb6 100644 --- a/packages/desktop/scripts/audit-server-bundle.ts +++ b/packages/desktop/scripts/audit-server-bundle.ts @@ -4,8 +4,9 @@ import { strict as assert } from "node:assert" import { readdir } from "node:fs/promises" import path from "node:path" -const chunks = path.resolve("out/main/chunks") -const sidecar = await Bun.file(path.resolve("out/main/sidecar.js")).text() +const main = path.resolve("out/main") +const chunks = path.join(main, "chunks") +const sidecar = await Bun.file(path.join(main, "sidecar.js")).text() const server = Bun.file(path.join(chunks, "node.js")) const sourceMap = Bun.file(path.join(chunks, "node.js.map")) const files = await readdir(chunks) @@ -18,6 +19,22 @@ assert.deepEqual( [], "Rollup must not emit a transformed copy of the server bundle", ) +assert.deepEqual( + ( + await Promise.all( + [path.join(main, "index.js"), path.join(main, "sidecar.js"), ...files.map((file) => path.join(chunks, file))] + .filter((file) => file.endsWith(".js")) + .map(async (file) => + new Bun.Transpiler({ loader: "js" }) + .scanImports(await Bun.file(file).text()) + .filter((item) => item.path.startsWith("@deepagent-code/")) + .map((item) => `${path.relative(main, file)} -> ${item.path}`), + ), + ) + ).flat(), + [], + "Packaged main process must not import TypeScript workspace packages", +) const source = await server.text() assert.match(source, /sourceMappingURL=node\.js\.map/, "external server bundle is not linked to its source map") diff --git a/packages/desktop/scripts/subagents-smoke.ts b/packages/desktop/scripts/subagents-smoke.ts index ae971119..b7af0644 100644 --- a/packages/desktop/scripts/subagents-smoke.ts +++ b/packages/desktop/scripts/subagents-smoke.ts @@ -8,6 +8,7 @@ import { _electron as electron, type ElectronApplication, type Page } from "@pla const root = await realpath(await mkdtemp(join(tmpdir(), "deepagent-code-subagents-smoke-"))) const workspace = join(root, "workspace") const main = resolve("out/main/index.js") +const packagedExecutable = process.env.DEEPAGENT_CODE_DESKTOP_EXECUTABLE await mkdir(workspace, { recursive: true }) const env = Object.fromEntries( @@ -40,7 +41,13 @@ const api = (page: Page) => page.evaluate(() => (window as unknown as { api: Des let activeApp: ElectronApplication | undefined async function launch() { - const app = await electron.launch({ args: [main], env, timeout: 30_000 }) + const started = performance.now() + const app = await electron.launch({ + args: packagedExecutable ? [] : [main], + ...(packagedExecutable ? { executablePath: packagedExecutable } : {}), + env, + timeout: 30_000, + }) activeApp = app console.log( "Electron main launched", @@ -51,7 +58,11 @@ async function launch() { ) const page = await app.firstWindow({ timeout: 30_000 }) await page.waitForFunction(() => Boolean((window as unknown as { api?: DesktopAPI }).api)) - return { app, page } + const server = await api(page) + const startupMs = Math.round(performance.now() - started) + console.log("Electron startup ready", { packaged: Boolean(packagedExecutable), startupMs }) + if (packagedExecutable) assert.equal(startupMs < 20_000, true, `packaged startup took ${startupMs}ms`) + return { app, page, server } } async function close(app: ElectronApplication) { @@ -87,9 +98,8 @@ async function listSessions(server: Awaited>) { try { const first = await launch() - const server = await api(first.page) - const parent = await createSession(server, { title: "Cold-start parent" }) - const child = await createSession(server, { + const parent = await createSession(first.server, { title: "Cold-start parent" }) + const child = await createSession(first.server, { title: "Cold-start researcher", parentID: parent.id, metadata: { @@ -104,7 +114,7 @@ try { }, }, }) - const firstSessions = await listSessions(server) + const firstSessions = await listSessions(first.server) assert.equal( firstSessions.some((session) => session.id === parent.id), true, @@ -146,7 +156,7 @@ try { second.page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()) }) - const sessions = await listSessions(await api(second.page)) + const sessions = await listSessions(second.server) assert.equal( sessions.some((session) => session.id === parent.id), true,