Skip to content

Commit 4db96c3

Browse files
authored
Merge pull request #2825 from generalaction/fix-parcel-watcher
fix: parcel app crashes
2 parents e611777 + 66851f0 commit 4db96c3

98 files changed

Lines changed: 3178 additions & 576 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

agents/architecture/acp-runtime.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,16 @@ designed.
8181
## Process Hosting
8282

8383
Desktop-local ACP and workspace-server ACP both use plain Node child processes via
84-
`childProcessHost()` and `spawnRuntime()`. The child process entry calls
84+
`spawnWorker()`. The child process entry calls
8585
`bootAcpRuntimeProcess()` from `@emdash/runtime/acp-agents/node`, which constructs
8686
`AcpRuntime`, a machine-scoped `AgentPluginHost`, `ChildAcpProcessHost`,
8787
`LocalAttachmentStore`, and `NodePtySpawner`. The `AgentPluginHost` owns the
8888
runtime process's plugin registry, execution context, plugin filesystem, env, home
8989
directory, host dependency manager, and spawn-context cache; ACP-specific
9090
resources such as process handles, ACP ports, terminal management, attachment
91-
storage, and session cells stay inside the ACP runtime.
91+
storage, and session cells stay inside the ACP runtime. Each host owns a worker
92+
manifest that maps the ACP worker id to the emitted child-process entry path for
93+
that host's build.
9294

9395
The concrete plugin registry is injected by each host entry (`emdash-desktop` and
9496
`workspace-server`) rather than imported by `@emdash/runtime`; this keeps runtime

apps/emdash-desktop/electron.vite.config.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { resolve } from 'node:path';
22
import tailwindcss from '@tailwindcss/vite';
33
import react from '@vitejs/plugin-react';
44
import { defineConfig } from 'electron-vite';
5+
import { desktopWorkerBuildInputs } from './src/main/worker-manifest';
56

67
const workspaceAliases = {
78
'@emdash/core/acp/client': resolve('../../packages/core/src/acp/client.ts'),
@@ -25,6 +26,12 @@ const workspaceAliases = {
2526
'@emdash/core/exec': resolve('../../packages/core/src/exec/index.ts'),
2627
'@emdash/core/pty/node': resolve('../../packages/core/src/pty/node/index.ts'),
2728
'@emdash/core/pty': resolve('../../packages/core/src/pty/index.ts'),
29+
'@emdash/core/services/fs-watch/api': resolve(
30+
'../../packages/core/src/services/fs-watch/api/index.ts'
31+
),
32+
'@emdash/core/services/fs-watch/worker': resolve(
33+
'../../packages/core/src/services/fs-watch/worker/index.ts'
34+
),
2835
'@emdash/plugins/agents/types': resolve('../../packages/plugins/src/agents/types.ts'),
2936
'@emdash/plugins/agents': resolve('../../packages/plugins/src/agents/registry.ts'),
3037
'@emdash/runtime/agent-config/node': resolve(
@@ -52,6 +59,7 @@ const workspaceAliases = {
5259
'../../packages/wire/src/util/process-runtime/index.ts'
5360
),
5461
'@emdash/wire/util': resolve('../../packages/wire/src/util/index.ts'),
62+
'@emdash/wire/worker': resolve('../../packages/wire/src/worker/index.ts'),
5563
'@emdash/wire': resolve('../../packages/wire/src/index.ts'),
5664
};
5765

@@ -63,8 +71,10 @@ export default defineConfig({
6371
rollupOptions: {
6472
input: {
6573
index: resolve('src/main/index.ts'),
66-
'acp-runtime': resolve('src/main/core/acp/runtime-process/entry.ts'),
67-
'agent-config-runtime': resolve('src/main/core/agent-config/runtime-process/entry.ts'),
74+
...desktopWorkerBuildInputs(),
75+
},
76+
output: {
77+
entryFileNames: '[name].js',
6878
},
6979
},
7080
},
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { createScope } from '@emdash/wire/util';
2+
import { log } from '@main/lib/logger';
3+
4+
export const appScope = createScope({ label: 'main', logger: log });

apps/emdash-desktop/src/main/app/shutdown.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { app } from 'electron';
2+
import { acpAgentStatusBridge } from '@main/core/acp/agent-status-bridge';
3+
import { disposeAcpRuntimeProcess } from '@main/core/acp/controller';
4+
import { disposeAgentConfigRuntimeProcess } from '@main/core/agent-config/controller';
25
import { agentHookService } from '@main/core/agent-hooks/agent-hook-service';
36
import { automationsService } from '@main/core/automations/automations-service';
47
import { remoteTmuxReaperService } from '@main/core/pty/remote-tmux-reaper-service';
@@ -9,6 +12,7 @@ import { updateService } from '@main/core/updates/update-service';
912
import { log } from '@main/lib/logger';
1013
import { telemetryService } from '@main/lib/telemetry';
1114
import { projectManager } from '../core/projects/project-manager';
15+
import { appScope } from './app-scope';
1216

1317
/* Maximum time (ms) to wait for the critical shutdown phase to complete. */
1418
const CRITICAL_DEADLINE_MS = 5_000;
@@ -47,8 +51,12 @@ export async function runQuitCleanup(): Promise<void> {
4751

4852
// critical phase
4953
const criticalSteps: Array<[string, () => Promise<void>]> = [
54+
['acpAgentStatusBridge.dispose', async () => acpAgentStatusBridge.dispose()],
5055
['projectManager.release', () => projectManager.release()],
5156
['runtimeManager.dispose', () => runtimeManager.dispose()],
57+
['disposeAcpRuntimeProcess', () => disposeAcpRuntimeProcess()],
58+
['disposeAgentConfigRuntimeProcess', () => disposeAgentConfigRuntimeProcess()],
59+
['appScope.dispose', () => appScope.dispose()],
5260
['telemetryService.dispose', () => telemetryService.dispose()],
5361
];
5462
await withDeadline(
Lines changed: 25 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { existsSync } from 'node:fs';
21
import { join } from 'node:path';
32
import { acpApiContract, type AcpApiContract } from '@emdash/core/acp';
43
import {
@@ -7,31 +6,39 @@ import {
76
withValidation,
87
type ContractClient,
98
} from '@emdash/wire/api';
10-
import { type ManagedProcess } from '@emdash/wire/process';
11-
import { childProcessHost } from '@emdash/wire/process/node';
12-
import { forwardRuntimeLogs, spawnRuntime } from '@emdash/wire/util/process-runtime';
9+
import { lazyWorker, type WorkerHandle } from '@emdash/wire/worker';
1310
import { app, ipcMain, MessageChannelMain } from 'electron';
11+
import { appScope } from '@main/app/app-scope';
1412
import { setSessionId } from '@main/core/conversations/set-session-id';
1513
import { log } from '@main/lib/logger';
14+
import { desktopWorkerPath } from '@main/worker-manifest';
1615

1716
const ACP_WIRE_CHANNEL = 'acp-wire';
1817

19-
type AcpRuntimeHandle = Awaited<ReturnType<typeof spawnAcpRuntime>>;
2018
export type AcpRuntimeClient = ContractClient<AcpApiContract>;
19+
type AcpRuntimeHandle = WorkerHandle<AcpApiContract> & { readonly client: AcpRuntimeClient };
20+
21+
const acpRuntimeScope = appScope.child('acp-runtime-host');
22+
const acpWorker = lazyWorker(
23+
() => ({
24+
name: 'acp',
25+
contract: acpApiContract,
26+
entry: desktopWorkerPath('acp'),
27+
scope: acpRuntimeScope,
28+
env: {
29+
...process.env,
30+
EMDASH_ACP_ATTACHMENTS_DIR: join(app.getPath('userData'), 'acp-attachments'),
31+
},
32+
}),
33+
{
34+
onSpawned: (handle) => installRendererWire(withSessionIdPersistence(handle.client)),
35+
}
36+
);
2137

22-
let handlePromise: Promise<AcpRuntimeHandle> | null = null;
2338
let rendererWireDispose: (() => void) | null = null;
2439

25-
export function initializeAcpRuntimeProcess(): Promise<AcpRuntimeHandle> {
26-
if (handlePromise) return handlePromise;
27-
handlePromise = spawnAcpRuntime().then((handle) => {
28-
installRendererWire(handle.client);
29-
app.once('before-quit', () => {
30-
void disposeAcpRuntimeProcess();
31-
});
32-
return handle;
33-
});
34-
return handlePromise;
40+
export async function initializeAcpRuntimeProcess(): Promise<AcpRuntimeHandle> {
41+
return decorateAcpRuntimeHandle(await acpWorker.get());
3542
}
3643

3744
export async function getAcpRuntimeClient(): Promise<AcpRuntimeClient> {
@@ -41,40 +48,13 @@ export async function getAcpRuntimeClient(): Promise<AcpRuntimeClient> {
4148
export async function disposeAcpRuntimeProcess(): Promise<void> {
4249
rendererWireDispose?.();
4350
rendererWireDispose = null;
44-
const handle = await handlePromise;
45-
handlePromise = null;
46-
await handle?.dispose();
51+
await acpWorker.dispose();
4752
}
4853

49-
async function spawnAcpRuntime() {
50-
const entry = resolveRuntimeEntry();
51-
log.info('ACP runtime child process entry resolved', { entry });
52-
const handle = await spawnRuntime({
53-
host: childProcessHost(),
54-
contract: acpApiContract,
55-
spec: {
56-
entry,
57-
env: {
58-
...process.env,
59-
EMDASH_ACP_ATTACHMENTS_DIR: join(app.getPath('userData'), 'acp-attachments'),
60-
},
61-
supervision: { restart: 'on-failure', backoffMs: [250, 1_000, 2_500], maxRestarts: 5 },
62-
},
63-
onProcess: attachAcpRuntimeLogging,
64-
});
65-
handle.onRestarted(() => {
66-
log.info('ACP runtime child process restarted');
67-
});
54+
function decorateAcpRuntimeHandle(handle: WorkerHandle<AcpApiContract>): AcpRuntimeHandle {
6855
return { ...handle, client: withSessionIdPersistence(handle.client) };
6956
}
7057

71-
function attachAcpRuntimeLogging(process: ManagedProcess): void {
72-
forwardRuntimeLogs(process, log, { source: 'acp-runtime' });
73-
process.onExit((exit) => {
74-
log.warn('ACP runtime child process exited', exit);
75-
});
76-
}
77-
7858
function withSessionIdPersistence(client: AcpRuntimeClient): AcpRuntimeClient {
7959
return {
8060
...client,
@@ -128,14 +108,3 @@ function installRendererWire(client: AcpRuntimeClient): void {
128108
function runtimeWireValidationPolicy() {
129109
return import.meta.env.DEV ? 'full' : 'inputs';
130110
}
131-
132-
function resolveRuntimeEntry(): string {
133-
const candidates = [join(__dirname, 'acp-runtime.js'), join(__dirname, 'acp-runtime.mjs')];
134-
const entry = candidates.find((candidate) => existsSync(candidate));
135-
if (!entry) {
136-
throw new Error(
137-
`ACP runtime child process entry is missing. Checked: ${candidates.join(', ')}`
138-
);
139-
}
140-
return entry;
141-
}
Lines changed: 30 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,38 @@
1-
import { existsSync } from 'node:fs';
2-
import { join } from 'node:path';
3-
import { agentConfigContract } from '@emdash/core/workspace-server';
4-
import { exposeWireToWindows, forwardController, withValidation } from '@emdash/wire/api';
5-
import { type ManagedProcess } from '@emdash/wire/process';
6-
import { childProcessHost } from '@emdash/wire/process/node';
7-
import { forwardRuntimeLogs, spawnRuntime } from '@emdash/wire/util/process-runtime';
8-
import { app, ipcMain, MessageChannelMain } from 'electron';
9-
import { log } from '@main/lib/logger';
1+
import { agentConfigContract, type AgentConfigContract } from '@emdash/core/workspace-server';
2+
import {
3+
exposeWireToWindows,
4+
forwardController,
5+
withValidation,
6+
type ContractClient,
7+
} from '@emdash/wire/api';
8+
import { lazyWorker, type WorkerHandle } from '@emdash/wire/worker';
9+
import { ipcMain, MessageChannelMain } from 'electron';
10+
import { appScope } from '@main/app/app-scope';
11+
import { desktopWorkerPath } from '@main/worker-manifest';
1012

1113
const AGENT_CONFIG_WIRE_CHANNEL = 'agent-config-wire';
1214

13-
type AgentConfigRuntimeHandle = Awaited<ReturnType<typeof spawnAgentConfigRuntime>>;
14-
export type AgentConfigRuntimeClient = AgentConfigRuntimeHandle['client'];
15+
const agentConfigRuntimeScope = appScope.child('agent-config-runtime-host');
16+
const agentConfigWorker = lazyWorker(
17+
() => ({
18+
name: 'agent-config',
19+
contract: agentConfigContract,
20+
entry: desktopWorkerPath('agent-config'),
21+
scope: agentConfigRuntimeScope,
22+
env: process.env,
23+
}),
24+
{
25+
onSpawned: (handle) => installRendererWire(handle.client),
26+
}
27+
);
28+
29+
type AgentConfigRuntimeHandle = WorkerHandle<AgentConfigContract>;
30+
export type AgentConfigRuntimeClient = ContractClient<AgentConfigContract>;
1531

16-
let handlePromise: Promise<AgentConfigRuntimeHandle> | null = null;
1732
let rendererWireDispose: (() => void) | null = null;
1833

19-
export function initializeAgentConfigRuntimeProcess(): Promise<AgentConfigRuntimeHandle> {
20-
if (handlePromise) return handlePromise;
21-
handlePromise = spawnAgentConfigRuntime().then((handle) => {
22-
installRendererWire(handle.client);
23-
app.once('before-quit', () => {
24-
void disposeAgentConfigRuntimeProcess();
25-
});
26-
return handle;
27-
});
28-
return handlePromise;
34+
export async function initializeAgentConfigRuntimeProcess(): Promise<AgentConfigRuntimeHandle> {
35+
return agentConfigWorker.get();
2936
}
3037

3138
export async function getAgentConfigRuntimeHandle(): Promise<AgentConfigRuntimeHandle> {
@@ -39,35 +46,7 @@ export async function getAgentConfigRuntimeClient(): Promise<AgentConfigRuntimeC
3946
export async function disposeAgentConfigRuntimeProcess(): Promise<void> {
4047
rendererWireDispose?.();
4148
rendererWireDispose = null;
42-
const handle = await handlePromise;
43-
handlePromise = null;
44-
await handle?.dispose();
45-
}
46-
47-
async function spawnAgentConfigRuntime() {
48-
const entry = resolveRuntimeEntry();
49-
log.info('Agent-config runtime child process entry resolved', { entry });
50-
const handle = await spawnRuntime({
51-
host: childProcessHost(),
52-
contract: agentConfigContract,
53-
spec: {
54-
entry,
55-
env: process.env,
56-
supervision: { restart: 'on-failure', backoffMs: [250, 1_000, 2_500], maxRestarts: 5 },
57-
},
58-
onProcess: attachAgentConfigRuntimeLogging,
59-
});
60-
handle.onRestarted(() => {
61-
log.info('Agent-config runtime child process restarted');
62-
});
63-
return handle;
64-
}
65-
66-
function attachAgentConfigRuntimeLogging(process: ManagedProcess): void {
67-
forwardRuntimeLogs(process, log, { source: 'agent-config-runtime' });
68-
process.onExit((exit) => {
69-
log.warn('Agent-config runtime child process exited', exit);
70-
});
49+
await agentConfigWorker.dispose();
7150
}
7251

7352
function installRendererWire(client: AgentConfigRuntimeClient): void {
@@ -93,17 +72,3 @@ function installRendererWire(client: AgentConfigRuntimeClient): void {
9372
function runtimeWireValidationPolicy() {
9473
return import.meta.env.DEV ? 'full' : 'inputs';
9574
}
96-
97-
function resolveRuntimeEntry(): string {
98-
const candidates = [
99-
join(__dirname, 'agent-config-runtime.js'),
100-
join(__dirname, 'agent-config-runtime.mjs'),
101-
];
102-
const entry = candidates.find((candidate) => existsSync(candidate));
103-
if (!entry) {
104-
throw new Error(
105-
`Agent-config runtime child process entry is missing. Checked: ${candidates.join(', ')}`
106-
);
107-
}
108-
return entry;
109-
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { runFsWatchWorkerProcess } from '@emdash/core/services/fs-watch/worker';
2+
3+
runFsWatchWorkerProcess();

apps/emdash-desktop/src/main/core/runtime/runtime-manager.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ import {
99
import { contains, FilesRuntime } from '@emdash/core/files';
1010
import { GitRuntime } from '@emdash/core/git';
1111
import { ResourceMap } from '@emdash/core/lib';
12+
import { spawnFsWatchWorker } from '@emdash/core/services/fs-watch/worker';
1213
import type { Lease } from '@emdash/shared';
14+
import { appScope } from '@main/app/app-scope';
1315
import { getDependencyManager } from '@main/core/dependencies/dependency-managers';
1416
import { NON_INTERACTIVE_GIT_ENV } from '@main/core/execution-context/non-interactive-git-env';
1517
import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager';
1618
import { getGitExecutable } from '@main/core/utils/exec';
1719
import { log } from '@main/lib/logger';
20+
import { desktopWorkerPath } from '@main/worker-manifest';
1821
import { ConstantHealthSource } from './health';
1922
import { LegacySshFilesRuntime } from './legacy/ssh-files';
2023
import { LegacySshGitRuntime } from './legacy/ssh-git';
@@ -81,15 +84,25 @@ class DynamicGitExec implements BoundExec {
8184

8285
class LocalMachineRuntime implements MachineRuntime {
8386
readonly machine: MachineRef = { kind: 'local' };
87+
private readonly scope = appScope.child('local-machine-runtime');
88+
private readonly watcher = spawnFsWatchWorker({
89+
entry: desktopWorkerPath('fs-watch'),
90+
scope: this.scope,
91+
env: process.env,
92+
onError: (context, error) =>
93+
log.warn('File watching background error', { context, error: String(error) }),
94+
});
8495
readonly files = Object.assign(
8596
new FilesRuntime({
97+
watcher: this.watcher,
8698
onError: (context, error) =>
8799
log.warn('Local file runtime background error', { context, error: String(error) }),
88100
}),
89101
{ path: nativeRuntimePath }
90102
);
91103
readonly git = new GitRuntime({
92104
exec: new DynamicGitExec(process.cwd()),
105+
watcher: this.watcher,
93106
onError: (context, error) =>
94107
log.warn('Local GitRuntime background error', { context, error: String(error) }),
95108
});
@@ -98,6 +111,8 @@ class LocalMachineRuntime implements MachineRuntime {
98111
async dispose(): Promise<void> {
99112
await this.files.dispose();
100113
await this.git.dispose();
114+
await this.watcher.dispose();
115+
await this.scope.dispose();
101116
}
102117
}
103118

0 commit comments

Comments
 (0)