@neuralnomads/codenomad-dev 0.18.0-dev-20260716-40cee071 → 0.18.0-dev-20260720-4c50829d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/auth/manager.js +1 -1
  2. package/dist/filesystem/__tests__/search-cache.test.js +5 -5
  3. package/dist/filesystem/search-cache.js +1 -1
  4. package/dist/index.js +42 -0
  5. package/dist/index.test.js +40 -10
  6. package/dist/opencode-update/service.js +150 -0
  7. package/dist/opencode-update/service.test.js +107 -0
  8. package/dist/permissions/auto-accept-manager.js +165 -0
  9. package/dist/permissions/auto-accept-manager.test.js +208 -0
  10. package/dist/permissions/auto-accept-store.js +5 -2
  11. package/dist/permissions/opencode-yolo-metadata.js +81 -0
  12. package/dist/permissions/opencode-yolo-metadata.test.js +54 -0
  13. package/dist/server/__tests__/remote-proxy.test.js +64 -56
  14. package/dist/server/http-server.js +12 -15
  15. package/dist/server/remote-proxy.js +68 -98
  16. package/dist/server/routes/opencode-update.js +37 -0
  17. package/dist/server/routes/opencode-update.test.js +34 -0
  18. package/dist/server/routes/remote-servers.js +1 -1
  19. package/dist/server/routes/workspaces.test.js +38 -0
  20. package/dist/server/routes/worktrees.js +19 -0
  21. package/dist/server/routes/yolo.js +2 -1
  22. package/dist/server/tls.js +2 -2
  23. package/dist/settings/binaries.test.js +2 -7
  24. package/dist/shutdown.js +67 -59
  25. package/dist/shutdown.test.js +115 -78
  26. package/dist/ui/remote-ui.js +2 -2
  27. package/dist/usage/providers/api-key.js +3 -3
  28. package/dist/usage/providers/google.js +1 -1
  29. package/dist/usage/providers/oauth.js +1 -36
  30. package/dist/usage/providers/special.js +3 -3
  31. package/dist/usage/service.js +1 -1
  32. package/dist/usage/service.test.js +5 -3
  33. package/dist/usage/shared.js +4 -4
  34. package/dist/usage/shared.test.js +4 -4
  35. package/dist/workspaces/__tests__/spawn.test.js +2 -2
  36. package/dist/workspaces/__tests__/workspace-identity.test.js +94 -286
  37. package/dist/workspaces/binary-path.test.js +10 -0
  38. package/dist/workspaces/instance-client.js +4 -3
  39. package/dist/workspaces/launch-cleanup.test.js +21 -25
  40. package/dist/workspaces/manager.js +300 -351
  41. package/dist/workspaces/manager.test.js +209 -237
  42. package/dist/workspaces/process-identity.darwin.test.js +88 -0
  43. package/dist/workspaces/process-identity.js +172 -149
  44. package/dist/workspaces/process-identity.test.js +86 -140
  45. package/dist/workspaces/runtime.js +261 -373
  46. package/dist/workspaces/runtime.test.js +222 -547
  47. package/package.json +1 -1
  48. package/public/assets/{FilesTab-BjztpSdj.js → FilesTab-ChfE8-8N.js} +2 -2
  49. package/public/assets/{GitChangesTab-B9XraSoR.js → GitChangesTab-ntRtkxIF.js} +2 -2
  50. package/public/assets/{SplitFilePanel-Cu_5-Z-E.js → SplitFilePanel-Dav7VCqK.js} +1 -1
  51. package/public/assets/StatusTab-D3toOefp.js +1 -0
  52. package/public/assets/{align-justify-BKS9gyRk.js → align-justify-D91sAsLc.js} +1 -1
  53. package/public/assets/{bundle-full-CXGXXlzR.js → bundle-full-CWkY9-v4.js} +1 -1
  54. package/public/assets/{diff-viewer-CSNOYoEB.js → diff-viewer-Cn_41zpc.js} +1 -1
  55. package/public/assets/index-3dx-hhNQ.js +1 -0
  56. package/public/assets/index-BKwfZLzw.js +1 -0
  57. package/public/assets/index-C5IioEFT.js +1 -0
  58. package/public/assets/index-CZYc0VcU.js +2 -0
  59. package/public/assets/index-CdCoXYXP.js +1 -0
  60. package/public/assets/index-CwAVops2.js +1 -0
  61. package/public/assets/index-CzvCOwKc.js +1 -0
  62. package/public/assets/index-D-loOWPv.js +1 -0
  63. package/public/assets/{index-DVc4ls8c.js → index-DM1cpVNo.js} +1 -1
  64. package/public/assets/{index-sLe9hEfQ.js → index-DOprq6Gt.js} +1 -1
  65. package/public/assets/index-L7fX3W8k.css +1 -0
  66. package/public/assets/index-w2KdHEME.js +1 -0
  67. package/public/assets/{loading-CDmt9OJU.js → loading-BfoDcWcg.js} +1 -1
  68. package/public/assets/main-CaTCcck1.js +68 -0
  69. package/public/assets/{markdown-1ZLSxCFT.js → markdown-D33YI2g2.js} +3 -3
  70. package/public/assets/{monaco-viewer-D6JgH76W.js → monaco-viewer-DM35aobI.js} +4 -4
  71. package/public/assets/{tool-call-CyXb63LH.js → tool-call-tV85Jv2i.js} +3 -3
  72. package/public/assets/{unified-picker-DCCdSVwI.js → unified-picker-DSRT9GMh.js} +1 -1
  73. package/public/assets/{wrap-text-pNMdTIq3.js → wrap-text-Dmrn2DKo.js} +1 -1
  74. package/public/index.html +4 -4
  75. package/public/loading.html +4 -4
  76. package/public/sw.js +1 -1
  77. package/dist/workspaces/launch-cleanup.js +0 -132
  78. package/public/assets/StatusTab-D_IE79r-.js +0 -1
  79. package/public/assets/index-B3YjNCh4.js +0 -1
  80. package/public/assets/index-B3ZD2_SU.js +0 -1
  81. package/public/assets/index-Bh2ofaQo.js +0 -1
  82. package/public/assets/index-CM0c9xRZ.js +0 -1
  83. package/public/assets/index-CdDxouJl.js +0 -2
  84. package/public/assets/index-D06Y2cFq.js +0 -1
  85. package/public/assets/index-PmE51vPh.js +0 -1
  86. package/public/assets/index-Qj63IiSj.css +0 -1
  87. package/public/assets/index-VY_zwmsG.js +0 -1
  88. package/public/assets/index-qiiH7egV.js +0 -1
  89. package/public/assets/main-CTN6Wwz1.js +0 -68
@@ -2,6 +2,7 @@ import path from "path";
2
2
  import { spawnSync } from "child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { connect } from "net";
5
+ import { setTimeout as delay } from "node:timers/promises";
5
6
  import { FileSystemBrowser } from "../filesystem/browser";
6
7
  import { searchWorkspaceFiles } from "../filesystem/search";
7
8
  import { clearWorkspaceSearchCache } from "../filesystem/search-cache";
@@ -9,79 +10,93 @@ import { WorkspaceRuntime } from "./runtime";
9
10
  import { buildOpencodeConfigContent, getCodeNomadPluginUrl, resolveExistingOpencodeConfigContent, } from "../opencode-plugin.js";
10
11
  import { OPENCODE_SERVER_BASE_URL_ENV, buildOpencodeBasicAuthHeader, OPENCODE_SERVER_PASSWORD_ENV, OPENCODE_SERVER_USERNAME_ENV, resolveOpencodeServerAuth, } from "./opencode-auth";
11
12
  import { resolveWorkspaceIdentity } from "./workspace-identity";
13
+ import { parseWslUncPath } from "./spawn";
12
14
  const STARTUP_STABILITY_DELAY_MS = 1500;
15
+ const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000;
16
+ const ORDINARY_CREATION_OWNER = "";
17
+ const WORKSPACE_STATE = Symbol("workspaceState");
18
+ export function binaryPathsEqual(left, right, platform = process.platform) {
19
+ const leftWsl = parseWslUncPath(left);
20
+ const rightWsl = parseWslUncPath(right);
21
+ if (leftWsl || rightWsl) {
22
+ return Boolean(leftWsl
23
+ && rightWsl
24
+ && leftWsl.distro.toLowerCase() === rightWsl.distro.toLowerCase()
25
+ && leftWsl.linuxPath === rightWsl.linuxPath);
26
+ }
27
+ return platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
28
+ }
13
29
  export class WorkspaceLaunchCancelledError extends Error {
14
30
  constructor(workspaceId) {
15
31
  super(`Workspace ${workspaceId} launch was cancelled`);
16
32
  this.name = "WorkspaceLaunchCancelledError";
17
33
  }
18
34
  }
19
- export class WorkspaceShutdownTimeoutError extends Error {
20
- constructor(timeoutMs) {
21
- super(`Workspace shutdown did not finish within ${timeoutMs}ms; remaining workspace cleanup can be retried`);
22
- this.code = "WORKSPACE_SHUTDOWN_TIMEOUT";
35
+ export class WorkspaceLaunchTimeoutError extends Error {
36
+ constructor(workspaceId, timeoutMs) {
37
+ super(`${workspaceId ? `Workspace ${workspaceId}` : "Workspace"} did not finish launching within ${timeoutMs}ms`);
38
+ this.code = "WORKSPACE_LAUNCH_TIMEOUT";
23
39
  this.retryable = true;
24
- this.name = "WorkspaceShutdownTimeoutError";
40
+ this.name = "WorkspaceLaunchTimeoutError";
25
41
  }
26
42
  }
27
- export class WorkspaceLaunchSettlementTimeoutError extends Error {
28
- constructor(workspaceId, timeoutMs) {
29
- super(`Workspace ${workspaceId} launch cancellation did not settle within ${timeoutMs}ms; cleanup can be retried`);
30
- this.code = "WORKSPACE_LAUNCH_SETTLEMENT_TIMEOUT";
43
+ export class WorkspaceCleanupTimeoutError extends Error {
44
+ constructor(operation, timeoutMs) {
45
+ super(`Workspace ${operation} did not finish within ${timeoutMs}ms; cleanup can be retried`);
46
+ this.code = "WORKSPACE_CLEANUP_TIMEOUT";
31
47
  this.retryable = true;
32
- this.name = "WorkspaceLaunchSettlementTimeoutError";
48
+ this.name = "WorkspaceCleanupTimeoutError";
33
49
  }
34
50
  }
35
- export class WorkspaceShutdownError extends Error {
51
+ export class WorkspaceShutdownError extends AggregateError {
36
52
  constructor(errors) {
37
- super(`Failed to stop ${errors.length} workspace${errors.length === 1 ? "" : "s"} during shutdown; cleanup can be retried`);
53
+ super(errors, `Failed to stop ${errors.length} workspace${errors.length === 1 ? "" : "s"} during shutdown; cleanup can be retried`);
38
54
  this.code = "WORKSPACE_SHUTDOWN_FAILED";
39
55
  this.retryable = true;
40
56
  this.name = "WorkspaceShutdownError";
41
- this.errors = errors;
42
57
  }
43
58
  }
44
59
  export class WorkspaceManager {
45
60
  constructor(options) {
46
61
  this.options = options;
47
62
  this.workspaces = new Map();
48
- this.workspaceIdentities = new Map();
49
63
  this.pendingWorkspaceCreations = new Map();
50
- this.pendingWorkspaceOwners = new Map();
51
64
  this.cancelledCreationRequests = new Set();
52
65
  this.shuttingDown = false;
53
66
  this.opencodeAuth = new Map();
54
67
  this.runtime = options.runtime ?? new WorkspaceRuntime(this.options.eventBus, this.options.logger);
55
68
  this.codeNomadPluginUrl = getCodeNomadPluginUrl();
56
- this.shutdownTimeoutMs = Math.max(1, options.shutdownTimeoutMs ?? 10000);
57
- this.launchSettlementTimeoutMs = Math.max(1, options.launchSettlementTimeoutMs ?? 5000);
58
- this.scheduleTimeout = options.setTimeout ?? setTimeout;
59
- this.cancelTimeout = options.clearTimeout ?? clearTimeout;
60
69
  }
61
70
  list() {
62
71
  return Array.from(this.workspaces.values())
63
- .filter((record) => record.published)
64
- .map((record) => record.descriptor);
72
+ .filter((record) => record[WORKSPACE_STATE].published);
65
73
  }
66
74
  get(id) {
67
75
  const record = this.workspaces.get(id);
68
- return record?.published ? record.descriptor : undefined;
76
+ return record?.[WORKSPACE_STATE].published ? record : undefined;
69
77
  }
70
78
  getInstancePort(id) {
71
79
  const record = this.workspaces.get(id);
72
- return record?.published ? record.descriptor.port : undefined;
80
+ return record?.[WORKSPACE_STATE].published ? record.port : undefined;
73
81
  }
74
82
  getInstanceAuthorizationHeader(id) {
75
- return this.workspaces.get(id)?.published ? this.opencodeAuth.get(id)?.authorization : undefined;
83
+ return this.workspaces.get(id)?.[WORKSPACE_STATE].published ? this.opencodeAuth.get(id)?.authorization : undefined;
84
+ }
85
+ findReadyInstanceIdByBinary(binaryPath) {
86
+ const resolvedPath = this.resolveBinaryPath(binaryPath);
87
+ return this.list().find((workspace) => {
88
+ return workspace.status === "ready" && binaryPathsEqual(workspace.binaryId, resolvedPath);
89
+ })?.id;
76
90
  }
77
91
  findReadyWorkspaceByIdentity(identityKey, includeRestoreOwned) {
78
- for (const [workspaceId, record] of this.workspaces) {
79
- if (record.published
80
- && !record.lifecycle.cancelled
81
- && (includeRestoreOwned || !record.descriptor.requestId)
82
- && record.descriptor.status === "ready"
83
- && this.workspaceIdentities.get(workspaceId) === identityKey) {
84
- return record.descriptor;
92
+ for (const record of this.workspaces.values()) {
93
+ const state = record[WORKSPACE_STATE];
94
+ if (state.published
95
+ && !state.abortController.signal.aborted
96
+ && (includeRestoreOwned || !record.requestId)
97
+ && record.status === "ready"
98
+ && record.identityKey === identityKey) {
99
+ return record;
85
100
  }
86
101
  }
87
102
  return undefined;
@@ -130,74 +145,70 @@ export class WorkspaceManager {
130
145
  browser.writeFile(relativePath, contents);
131
146
  }
132
147
  async create(folder, name, options = {}) {
133
- const { workspacePath, identityKey } = await resolveWorkspaceIdentity(folder, this.options.rootDir);
134
- if (options.requestId && this.cancelledCreationRequests.delete(options.requestId)) {
135
- throw new Error(`Workspace creation request ${options.requestId} was cancelled`);
136
- }
137
- if (this.shuttingDown) {
138
- throw new Error("Workspace manager is shutting down");
139
- }
140
- if (options.forceNew) {
141
- const ownership = this.createOwnership(options.requestId);
142
- const result = await this.createResolvedWorkspace(workspacePath, identityKey, name, options, ownership);
143
- return this.resultForCreationOwner(result, options.requestId, ownership);
144
- }
145
- const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId));
146
- if (existing) {
147
- this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace");
148
- const record = this.workspaces.get(existing.id);
149
- if (options.requestId && record && record.creationOwnership.requestIds.size > 0) {
150
- record.creationOwnership.requestIds.add(options.requestId);
151
- this.syncCreationOwnership(record);
152
- return this.resultForCreationOwner({ workspace: existing, created: false }, options.requestId, record.creationOwnership);
148
+ const launchTimeoutMs = Math.max(1, this.options.launchTimeoutMs ?? DEFAULT_LAUNCH_TIMEOUT_MS);
149
+ const launchDeadlineAt = Date.now() + launchTimeoutMs;
150
+ try {
151
+ const { workspacePath, identityKey } = await this.withLaunchDeadline(resolveWorkspaceIdentity(folder, this.options.rootDir), undefined, launchDeadlineAt, launchTimeoutMs);
152
+ if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) {
153
+ throw new Error(`Workspace creation request ${options.requestId} was cancelled`);
153
154
  }
154
- return { workspace: existing, created: false };
155
- }
156
- const pending = this.pendingWorkspaceCreations.get(identityKey);
157
- if (pending) {
158
- pending.followerCount += 1;
159
- if (options.requestId) {
160
- pending.ownership.requestIds.add(options.requestId);
155
+ if (this.shuttingDown) {
156
+ throw new Error("Workspace manager is shutting down");
157
+ }
158
+ if (options.forceNew) {
159
+ const ownership = this.createOwnership(options.requestId);
160
+ const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt);
161
+ const result = await this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs);
162
+ return this.finishCreation(result, options.requestId, ownership);
161
163
  }
162
- else {
163
- const workspaceId = this.pendingWorkspaceOwners.get(identityKey);
164
- if (workspaceId) {
165
- this.retainCreationOwnership(this.workspaces.get(workspaceId));
164
+ const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId));
165
+ if (existing) {
166
+ this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace");
167
+ const record = this.workspaces.get(existing.id);
168
+ if (options.requestId && record) {
169
+ if (!record.ownership.has(options.requestId))
170
+ record.ownership.set(options.requestId, "active");
171
+ this.syncOwnership(record);
172
+ return this.finishCreation({ workspace: existing, created: false }, options.requestId, record.ownership);
166
173
  }
167
- else {
168
- pending.ownership.sharedByNonRestoreCaller = true;
174
+ return { workspace: existing, created: false };
175
+ }
176
+ const pending = this.pendingWorkspaceCreations.get(identityKey);
177
+ if (pending) {
178
+ const state = pending[WORKSPACE_STATE];
179
+ const owner = options.requestId ?? ORDINARY_CREATION_OWNER;
180
+ if (!pending.ownership.has(owner))
181
+ pending.ownership.set(owner, "active");
182
+ this.syncOwnership(pending);
183
+ const result = await state.creation;
184
+ return this.finishCreation({ workspace: result.workspace, created: false }, options.requestId, pending.ownership);
185
+ }
186
+ const ownership = this.createOwnership(options.requestId);
187
+ const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt);
188
+ const creation = this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs);
189
+ this.pendingWorkspaceCreations.set(identityKey, record);
190
+ try {
191
+ return this.finishCreation(await creation, options.requestId, ownership);
192
+ }
193
+ finally {
194
+ if (this.pendingWorkspaceCreations.get(identityKey) === record) {
195
+ this.pendingWorkspaceCreations.delete(identityKey);
169
196
  }
170
197
  }
171
- const result = await pending.promise;
172
- return this.resultForCreationOwner({ workspace: result.workspace, created: false }, options.requestId, pending.ownership);
173
- }
174
- const ownership = this.createOwnership(options.requestId);
175
- const creation = this.createResolvedWorkspace(workspacePath, identityKey, name, options, ownership, (workspaceId) => {
176
- this.pendingWorkspaceOwners.set(identityKey, workspaceId);
177
- });
178
- const pendingCreation = { promise: creation, ownership, followerCount: 0 };
179
- this.pendingWorkspaceCreations.set(identityKey, pendingCreation);
180
- try {
181
- const result = await creation;
182
- return this.resultForCreationOwner(options.requestId && pendingCreation.ownership.sharedByNonRestoreCaller
183
- ? { workspace: result.workspace, created: false }
184
- : result, options.requestId, pendingCreation.ownership);
185
198
  }
186
199
  finally {
187
- if (this.pendingWorkspaceCreations.get(identityKey) === pendingCreation) {
188
- this.pendingWorkspaceCreations.delete(identityKey);
189
- this.pendingWorkspaceOwners.delete(identityKey);
190
- }
200
+ if (options.requestId)
201
+ this.cancelledCreationRequests.delete(options.requestId);
191
202
  }
192
203
  }
193
- async createResolvedWorkspace(workspacePath, identityKey, name, options, creationOwnership, onReserved) {
204
+ reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) {
194
205
  const id = randomUUID();
195
206
  const binary = this.options.binaryResolver.resolve(options.binaryPath);
196
- const resolvedBinaryPath = this.resolveBinaryPath(binary.path);
207
+ const resolvedBinaryPath = this.resolveBinaryPath(binary.path, Math.max(1, launchDeadlineAt - Date.now()));
197
208
  clearWorkspaceSearchCache(workspacePath);
198
209
  this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace");
199
210
  const proxyPath = `/workspaces/${id}/instance`;
200
- const descriptor = {
211
+ const record = {
201
212
  id,
202
213
  requestId: options.requestId,
203
214
  path: workspacePath,
@@ -210,29 +221,60 @@ export class WorkspaceManager {
210
221
  createdAt: new Date().toISOString(),
211
222
  updatedAt: new Date().toISOString(),
212
223
  };
213
- let completeLaunch;
214
- const completion = new Promise((resolve) => {
215
- completeLaunch = resolve;
224
+ Object.defineProperties(record, {
225
+ identityKey: { value: identityKey },
226
+ ownership: { value: ownership },
227
+ [WORKSPACE_STATE]: { value: { abortController: new AbortController(), published: false, stoppedPublished: false } },
216
228
  });
217
- const record = {
218
- descriptor,
219
- lifecycle: {
220
- cancelled: false,
221
- settled: false,
222
- completion,
223
- complete: completeLaunch,
224
- stoppedEventPublished: false,
225
- },
226
- published: false,
227
- creationOwnership,
228
- releasedCreationRequestIds: new Set(),
229
- };
230
229
  this.workspaces.set(id, record);
231
- this.workspaceIdentities.set(id, identityKey);
232
- onReserved?.(id);
233
- if (options.requestId && this.cancelledCreationRequests.delete(options.requestId)) {
234
- record.lifecycle.cancelled = true;
230
+ if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) {
231
+ record[WORKSPACE_STATE].abortController.abort(new WorkspaceLaunchCancelledError(id));
232
+ }
233
+ return record;
234
+ }
235
+ startCreation(record, options, launchDeadlineAt, launchTimeoutMs) {
236
+ const creation = this.createWithDeadline(record, options, launchDeadlineAt, launchTimeoutMs);
237
+ record[WORKSPACE_STATE].creation = creation;
238
+ record[WORKSPACE_STATE].settlement = creation.then(() => undefined, () => undefined);
239
+ return creation;
240
+ }
241
+ async createWithDeadline(record, options, launchDeadlineAt, launchTimeoutMs) {
242
+ const timeoutMs = Math.max(1, launchDeadlineAt - Date.now());
243
+ const state = record[WORKSPACE_STATE];
244
+ let timeout = (this.options.setTimeout ?? setTimeout)(() => {
245
+ timeout = null;
246
+ if (!state.abortController.signal.aborted) {
247
+ state.abortController.abort(new WorkspaceLaunchTimeoutError(record.id, launchTimeoutMs));
248
+ }
249
+ }, timeoutMs);
250
+ try {
251
+ return await this.createResolvedWorkspace(record, options);
252
+ }
253
+ finally {
254
+ if (timeout)
255
+ (this.options.clearTimeout ?? clearTimeout)(timeout);
256
+ }
257
+ }
258
+ async withLaunchDeadline(operation, workspaceId, deadlineAt, launchTimeoutMs) {
259
+ const timeoutMs = Math.max(1, deadlineAt - Date.now());
260
+ let timeout = null;
261
+ const deadline = new Promise((_resolve, reject) => {
262
+ timeout = (this.options.setTimeout ?? setTimeout)(() => {
263
+ timeout = null;
264
+ reject(new WorkspaceLaunchTimeoutError(workspaceId, launchTimeoutMs));
265
+ }, timeoutMs);
266
+ });
267
+ try {
268
+ return await Promise.race([operation, deadline]);
269
+ }
270
+ finally {
271
+ if (timeout)
272
+ (this.options.clearTimeout ?? clearTimeout)(timeout);
235
273
  }
274
+ }
275
+ async createResolvedWorkspace(record, options) {
276
+ const state = record[WORKSPACE_STATE];
277
+ const { id, path: workspacePath, binaryId: resolvedBinaryPath, proxyPath } = record;
236
278
  try {
237
279
  this.throwIfCancelled(record);
238
280
  const serverConfig = this.options.settings.getOwner("config", "server");
@@ -262,291 +304,231 @@ export class WorkspaceManager {
262
304
  [OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
263
305
  };
264
306
  const logLevel = serverConfig?.logLevel;
265
- const { pid, port, exitPromise, cancellationPromise, getLastOutput } = await this.runtime.launch({
307
+ const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
266
308
  workspaceId: id,
267
309
  folder: workspacePath,
268
310
  binaryPath: resolvedBinaryPath,
269
311
  environment,
270
312
  logLevel,
313
+ signal: state.abortController.signal,
271
314
  onExit: (info) => this.handleProcessExit(info.workspaceId, info),
272
315
  });
273
- descriptor.pid = pid;
274
- descriptor.port = port;
316
+ record.pid = pid;
317
+ record.port = port;
275
318
  this.throwIfCancelled(record);
276
- record.published = true;
277
- this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor });
319
+ state.published = true;
320
+ this.options.eventBus.publish({ type: "workspace.created", workspace: record });
278
321
  this.throwIfCancelled(record);
279
- const readinessAbort = new AbortController();
280
- const runtimeVersion = await Promise.race([
281
- this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput, signal: readinessAbort.signal }),
282
- cancellationPromise.then((error) => {
283
- readinessAbort.abort(error);
284
- throw error;
285
- }),
286
- ]);
322
+ const runtimeVersion = await this.waitForWorkspaceReadiness({
323
+ workspaceId: id,
324
+ port,
325
+ exitPromise,
326
+ getLastOutput,
327
+ signal: state.abortController.signal,
328
+ });
287
329
  this.throwIfCancelled(record);
288
330
  if (runtimeVersion) {
289
- descriptor.binaryVersion = runtimeVersion;
331
+ record.binaryVersion = runtimeVersion;
290
332
  }
291
- descriptor.status = "ready";
292
- descriptor.updatedAt = new Date().toISOString();
293
- this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor });
333
+ record.status = "ready";
334
+ record.updatedAt = new Date().toISOString();
335
+ this.options.eventBus.publish({ type: "workspace.started", workspace: record });
294
336
  this.options.logger.info({ workspaceId: id, port }, "Workspace ready");
295
- return { workspace: descriptor, created: true };
337
+ return { workspace: record, created: true };
296
338
  }
297
339
  catch (error) {
298
- if (record.lifecycle.cancelled) {
299
- await this.stopRuntime(id);
300
- throw error instanceof WorkspaceLaunchCancelledError ? error : new WorkspaceLaunchCancelledError(id);
301
- }
302
- if (!record.published) {
303
- try {
304
- await this.stopRuntime(id);
305
- }
306
- catch (cleanupError) {
307
- this.options.logger.error({ workspaceId: id, err: cleanupError }, "Unpublished workspace cleanup remains pending");
308
- throw cleanupError;
309
- }
310
- if (this.workspaces.get(id) === record) {
311
- this.workspaces.delete(id);
312
- this.workspaceIdentities.delete(id);
313
- }
314
- this.opencodeAuth.delete(id);
315
- this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed before identity publication");
316
- throw error;
317
- }
340
+ const launchFailure = state.abortController.signal.aborted ? state.abortController.signal.reason : error;
318
341
  let stopFailure;
319
- await this.stopRuntime(id).catch((stopError) => {
342
+ await this.runtime.stop(id).catch((stopError) => {
320
343
  stopFailure = stopError;
321
- this.options.logger.warn({ workspaceId: id, err: stopError }, "Failed to stop workspace after startup error");
322
344
  });
323
345
  if (!stopFailure) {
324
- this.publishStopped(record);
325
- if (this.workspaces.get(id) === record) {
326
- this.workspaces.delete(id);
327
- this.workspaceIdentities.delete(id);
328
- }
329
- this.opencodeAuth.delete(id);
330
- throw error;
346
+ this.removeRecord(id, record, state.published);
347
+ throw launchFailure;
348
+ }
349
+ if (!state.published) {
350
+ throw stopFailure;
331
351
  }
332
- descriptor.status = "error";
333
- descriptor.error = stopFailure instanceof Error
352
+ record.status = "error";
353
+ record.error = stopFailure instanceof Error
334
354
  ? `Workspace startup failed and its process could not be stopped: ${stopFailure.message}`
335
- : error instanceof Error ? error.message : String(error);
336
- descriptor.updatedAt = new Date().toISOString();
337
- if (this.workspaces.get(id) === record && record.published) {
338
- this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor });
355
+ : launchFailure instanceof Error ? launchFailure.message : String(launchFailure);
356
+ record.updatedAt = new Date().toISOString();
357
+ if (this.workspaces.get(id) === record && state.published) {
358
+ this.options.eventBus.publish({ type: "workspace.error", workspace: record });
339
359
  }
340
- this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start");
341
- throw error;
342
- }
343
- finally {
344
- record.lifecycle.settled = true;
345
- record.lifecycle.complete();
360
+ this.options.logger.error({ workspaceId: id, err: launchFailure }, "Workspace failed to start");
361
+ throw launchFailure;
346
362
  }
347
363
  }
348
364
  delete(id) {
349
365
  const record = this.workspaces.get(id);
350
366
  if (!record)
351
367
  return Promise.resolve(undefined);
352
- record.lifecycle.cancelled = true;
353
- const identityKey = this.workspaceIdentities.get(id);
354
- if (identityKey && this.pendingWorkspaceOwners.get(identityKey) === id) {
355
- this.pendingWorkspaceCreations.delete(identityKey);
356
- this.pendingWorkspaceOwners.delete(identityKey);
368
+ const state = record[WORKSPACE_STATE];
369
+ if (!state.abortController.signal.aborted) {
370
+ state.abortController.abort(new WorkspaceLaunchCancelledError(id));
371
+ }
372
+ const pending = this.pendingWorkspaceCreations.get(record.identityKey);
373
+ if (pending === record) {
374
+ this.pendingWorkspaceCreations.delete(record.identityKey);
357
375
  }
358
- if (!record.lifecycle.deletePromise) {
376
+ if (!state.deletePromise) {
359
377
  let deletePromise;
360
378
  deletePromise = this.cleanupDeletedWorkspace(id, record).catch((error) => {
361
- if (record.lifecycle.deletePromise === deletePromise) {
362
- record.lifecycle.deletePromise = undefined;
363
- }
379
+ if (state.deletePromise === deletePromise)
380
+ state.deletePromise = undefined;
364
381
  throw error;
365
382
  });
366
- record.lifecycle.deletePromise = deletePromise;
383
+ state.deletePromise = deletePromise;
367
384
  }
368
- return record.lifecycle.deletePromise;
385
+ return state.deletePromise;
369
386
  }
370
387
  releaseCreationRequest(id, requestId) {
371
388
  const record = this.workspaces.get(id);
372
- if (!record?.published)
389
+ if (!record?.[WORKSPACE_STATE].published)
373
390
  return false;
374
- if (record.releasedCreationRequestIds.has(requestId))
391
+ const ownership = record.ownership;
392
+ const state = ownership.get(requestId);
393
+ if (state === "released")
375
394
  return true;
376
- if (!record.creationOwnership.requestIds.has(requestId))
395
+ if (state === "cancelled")
396
+ return false;
397
+ if (state !== "active")
377
398
  return false;
378
- this.retainCreationOwnership(record);
399
+ ownership.set(requestId, "released");
400
+ this.syncOwnership(record);
379
401
  return true;
380
402
  }
381
403
  async cancelCreationRequest(requestId) {
404
+ let matched = false;
382
405
  for (const [workspaceId, record] of this.workspaces) {
383
- if (record.releasedCreationRequestIds.has(requestId))
384
- return;
385
- if (!record.creationOwnership.requestIds.delete(requestId))
406
+ const ownership = record.ownership;
407
+ const state = ownership.get(requestId);
408
+ if (state === "released") {
409
+ matched = true;
386
410
  continue;
387
- record.creationOwnership.cancelledRequestIds.add(requestId);
388
- record.releasedCreationRequestIds.add(requestId);
389
- this.syncCreationOwnership(record);
390
- if (record.creationOwnership.requestIds.size > 0 || record.creationOwnership.sharedByNonRestoreCaller) {
411
+ }
412
+ if (state === "cancelled") {
413
+ matched = true;
414
+ if (this.hasActiveRequest(ownership) || this.isRetained(ownership))
415
+ continue;
416
+ await this.delete(workspaceId);
391
417
  return;
392
418
  }
419
+ if (state !== "active")
420
+ continue;
421
+ matched = true;
422
+ ownership.set(requestId, "cancelled");
423
+ this.syncOwnership(record);
424
+ if (this.hasActiveRequest(ownership) || this.isRetained(ownership))
425
+ return;
393
426
  await this.delete(workspaceId);
394
427
  return;
395
428
  }
396
- if (this.cancelledCreationRequests.size >= 1024) {
397
- const oldestRequestId = this.cancelledCreationRequests.values().next().value;
398
- if (oldestRequestId)
399
- this.cancelledCreationRequests.delete(oldestRequestId);
400
- }
401
- this.cancelledCreationRequests.add(requestId);
429
+ if (!matched)
430
+ this.cancelledCreationRequests.add(requestId);
402
431
  }
403
432
  createOwnership(requestId) {
404
- return {
405
- requestIds: new Set(requestId ? [requestId] : []),
406
- cancelledRequestIds: new Set(),
407
- sharedByNonRestoreCaller: !requestId,
408
- };
433
+ return new Map([[requestId ?? ORDINARY_CREATION_OWNER, "active"]]);
409
434
  }
410
- resultForCreationOwner(result, requestId, ownership) {
411
- if (requestId && ownership.cancelledRequestIds.has(requestId)) {
435
+ finishCreation(result, requestId, ownership) {
436
+ if (requestId && ownership.get(requestId) === "cancelled") {
412
437
  throw new Error(`Workspace creation request ${requestId} was cancelled`);
413
438
  }
439
+ const retained = this.isRetained(ownership);
414
440
  return {
415
- workspace: requestId && !ownership.sharedByNonRestoreCaller
416
- ? { ...result.workspace, requestId }
417
- : result.workspace,
418
- created: result.created,
441
+ workspace: requestId ? { ...result.workspace, requestId } : result.workspace,
442
+ created: result.created && !(requestId && retained),
419
443
  };
420
444
  }
421
- syncCreationOwnership(record) {
445
+ syncOwnership(record) {
422
446
  if (!record)
423
447
  return;
424
- record.descriptor.requestId = record.creationOwnership.sharedByNonRestoreCaller
448
+ const ownership = record.ownership;
449
+ record.requestId = this.isRetained(ownership)
425
450
  ? undefined
426
- : record.creationOwnership.requestIds.values().next().value;
451
+ : Array.from(ownership).find(([, state]) => state === "active")?.[0];
427
452
  }
428
- retainCreationOwnership(record) {
429
- if (!record)
430
- return;
431
- for (const requestId of record.creationOwnership.requestIds) {
432
- record.releasedCreationRequestIds.add(requestId);
433
- }
434
- record.creationOwnership.requestIds.clear();
435
- record.creationOwnership.sharedByNonRestoreCaller = true;
436
- this.syncCreationOwnership(record);
453
+ isRetained(ownership) {
454
+ return ownership.has(ORDINARY_CREATION_OWNER) || Array.from(ownership.values()).includes("released");
455
+ }
456
+ hasActiveRequest(ownership) {
457
+ return Array.from(ownership).some(([requestId, state]) => Boolean(requestId) && state === "active");
437
458
  }
438
459
  async shutdown() {
439
460
  this.shuttingDown = true;
440
461
  this.options.logger.info("Shutting down all workspaces");
441
462
  const stopTasks = Array.from(this.workspaces.keys(), (id) => this.delete(id));
442
- let stopFailures = [];
443
- if (stopTasks.length > 0) {
444
- const results = await this.withShutdownTimeout(Promise.allSettled(stopTasks));
445
- stopFailures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
446
- }
463
+ const results = stopTasks.length
464
+ ? await this.withTimeout(Promise.allSettled(stopTasks), this.options.shutdownTimeoutMs ?? 10000, "shutdown")
465
+ : [];
466
+ const stopFailures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
447
467
  if (this.workspaces.size === 0) {
448
- this.workspaceIdentities.clear();
449
468
  this.pendingWorkspaceCreations.clear();
450
- this.pendingWorkspaceOwners.clear();
451
469
  this.cancelledCreationRequests.clear();
452
- this.options.logger.info("All workspaces cleared");
453
470
  }
454
- else {
455
- this.options.logger.warn({ workspaceIds: Array.from(this.workspaces.keys()) }, "Some workspace records remain after failed shutdown cleanup");
456
- if (stopFailures.length === 0) {
457
- stopFailures.push(new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`));
458
- }
459
- }
460
- if (stopFailures.length > 0) {
471
+ else if (!stopFailures.length)
472
+ stopFailures.push(new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`));
473
+ if (stopFailures.length)
461
474
  throw new WorkspaceShutdownError(stopFailures);
462
- }
463
475
  }
464
- async withShutdownTimeout(operation) {
476
+ async withTimeout(operation, timeoutMs, label) {
465
477
  let timeout = null;
466
478
  const deadline = new Promise((_resolve, reject) => {
467
- timeout = this.scheduleTimeout(() => {
479
+ timeout = (this.options.setTimeout ?? setTimeout)(() => {
468
480
  timeout = null;
469
- reject(new WorkspaceShutdownTimeoutError(this.shutdownTimeoutMs));
470
- }, this.shutdownTimeoutMs);
481
+ reject(new WorkspaceCleanupTimeoutError(label, timeoutMs));
482
+ }, timeoutMs);
471
483
  });
472
484
  try {
473
485
  return await Promise.race([operation, deadline]);
474
486
  }
475
487
  finally {
476
- if (timeout) {
477
- this.cancelTimeout(timeout);
478
- }
488
+ if (timeout)
489
+ (this.options.clearTimeout ?? clearTimeout)(timeout);
479
490
  }
480
491
  }
481
492
  requireWorkspace(id) {
482
493
  const record = this.workspaces.get(id);
483
- if (!record?.published) {
494
+ if (!record?.[WORKSPACE_STATE].published)
484
495
  throw new Error("Workspace not found");
485
- }
486
- return record.descriptor;
496
+ return record;
487
497
  }
488
498
  throwIfCancelled(record) {
489
- if (record.lifecycle.cancelled) {
490
- throw new WorkspaceLaunchCancelledError(record.descriptor.id);
491
- }
492
- }
493
- async stopRuntime(workspaceId) {
494
- try {
495
- await this.runtime.stop(workspaceId);
496
- }
497
- catch (error) {
498
- this.options.logger.warn({ workspaceId, err: error }, "Failed to stop workspace process cleanly");
499
- throw error;
500
- }
499
+ record[WORKSPACE_STATE].abortController.signal.throwIfAborted();
501
500
  }
502
501
  async cleanupDeletedWorkspace(id, record) {
503
- this.options.logger.info({ workspaceId: id }, "Stopping workspace");
504
502
  // Stop once immediately, then again after launch settlement to cover a child
505
503
  // that became available while cancellation was propagating.
506
- await this.stopRuntime(id);
507
- if (!record.lifecycle.settled) {
508
- await this.withLaunchSettlementTimeout(id, record.lifecycle.completion);
509
- }
510
- try {
511
- await this.runtime.stop(id);
512
- }
513
- catch (error) {
514
- this.options.logger.warn({ workspaceId: id, err: error }, "Failed final workspace process cleanup");
515
- throw error;
516
- }
517
- if (this.workspaces.get(id) === record) {
518
- this.workspaces.delete(id);
519
- this.workspaceIdentities.delete(id);
520
- this.opencodeAuth.delete(id);
521
- clearWorkspaceSearchCache(record.descriptor.path);
522
- this.publishStopped(record);
523
- }
524
- return record.descriptor;
525
- }
526
- async withLaunchSettlementTimeout(workspaceId, completion) {
527
- let timeout = null;
528
- const deadline = new Promise((_resolve, reject) => {
529
- timeout = this.scheduleTimeout(() => {
530
- timeout = null;
531
- reject(new WorkspaceLaunchSettlementTimeoutError(workspaceId, this.launchSettlementTimeoutMs));
532
- }, this.launchSettlementTimeoutMs);
504
+ const immediateStop = this.runtime.stop(id).catch((error) => {
505
+ this.options.logger.warn({ workspaceId: id, err: error }, "Initial workspace process cleanup failed; retrying after launch settles");
533
506
  });
534
- try {
535
- await Promise.race([completion, deadline]);
536
- }
537
- finally {
538
- if (timeout) {
539
- this.cancelTimeout(timeout);
540
- }
541
- }
542
- }
543
- publishStopped(record) {
544
- if (record.lifecycle.stoppedEventPublished)
507
+ await this.withTimeout(record[WORKSPACE_STATE].settlement, this.options.launchSettlementTimeoutMs ?? 5000, `${id} launch cancellation`);
508
+ await immediateStop;
509
+ await this.runtime.stop(id);
510
+ this.removeRecord(id, record, true);
511
+ return record;
512
+ }
513
+ removeRecord(id, record, publishStopped) {
514
+ if (this.workspaces.get(id) !== record)
545
515
  return;
546
- record.lifecycle.stoppedEventPublished = true;
547
- this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: record.descriptor.id });
516
+ this.workspaces.delete(id);
517
+ this.opencodeAuth.delete(id);
518
+ clearWorkspaceSearchCache(record.path);
519
+ if (publishStopped)
520
+ this.publishStopped(record, "deleted");
521
+ }
522
+ publishStopped(record, reason = "stopped") {
523
+ const state = record[WORKSPACE_STATE];
524
+ if (!state.published || state.stoppedPublished)
525
+ return;
526
+ state.stoppedPublished = true;
527
+ record.status = "stopped";
528
+ record.error = undefined;
529
+ this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: record.id, reason });
548
530
  }
549
- resolveBinaryPath(identifier) {
531
+ resolveBinaryPath(identifier, timeoutMs = DEFAULT_LAUNCH_TIMEOUT_MS) {
550
532
  if (!identifier) {
551
533
  return identifier;
552
534
  }
@@ -556,7 +538,7 @@ export class WorkspaceManager {
556
538
  }
557
539
  const locator = process.platform === "win32" ? "where" : "which";
558
540
  try {
559
- const result = spawnSync(locator, [identifier], { encoding: "utf8" });
541
+ const result = spawnSync(locator, [identifier], { encoding: "utf8", timeout: Math.max(1, timeoutMs) });
560
542
  if (result.status === 0 && result.stdout) {
561
543
  const candidates = result.stdout
562
544
  .split(/\r?\n/)
@@ -594,25 +576,19 @@ export class WorkspaceManager {
594
576
  async waitForWorkspaceReadiness(params) {
595
577
  await Promise.race([
596
578
  this.waitForPortAvailability(params.port, 5000, params.signal),
597
- params.exitPromise.then((info) => {
598
- throw this.buildStartupError(params.workspaceId, "exited before becoming ready", info, params.getLastOutput());
599
- }),
579
+ this.exitDuringStartup(params, "exited before becoming ready"),
600
580
  ]);
601
581
  const version = await this.waitForInstanceHealth(params);
602
582
  await Promise.race([
603
- this.delay(STARTUP_STABILITY_DELAY_MS, params.signal),
604
- params.exitPromise.then((info) => {
605
- throw this.buildStartupError(params.workspaceId, "exited shortly after start", info, params.getLastOutput());
606
- }),
583
+ delay(STARTUP_STABILITY_DELAY_MS, undefined, { signal: params.signal }),
584
+ this.exitDuringStartup(params, "exited shortly after start"),
607
585
  ]);
608
586
  return version;
609
587
  }
610
588
  async waitForInstanceHealth(params) {
611
589
  const probeResult = await Promise.race([
612
590
  this.probeInstance(params.workspaceId, params.port, params.signal),
613
- params.exitPromise.then((info) => {
614
- throw this.buildStartupError(params.workspaceId, "exited during health checks", info, params.getLastOutput());
615
- }),
591
+ this.exitDuringStartup(params, "exited during health checks"),
616
592
  ]);
617
593
  if (probeResult.ok) {
618
594
  return probeResult.version;
@@ -624,6 +600,11 @@ export class WorkspaceManager {
624
600
  const reason = probeResult.reason ?? "Health check failed";
625
601
  throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`);
626
602
  }
603
+ exitDuringStartup(params, phase) {
604
+ return params.exitPromise.then((info) => {
605
+ throw this.buildStartupError(params.workspaceId, phase, info, params.getLastOutput());
606
+ });
607
+ }
627
608
  async probeInstance(workspaceId, port, signal) {
628
609
  const url = `http://127.0.0.1:${port}/global/health`;
629
610
  try {
@@ -665,34 +646,28 @@ export class WorkspaceManager {
665
646
  const deadline = Date.now() + timeoutMs;
666
647
  let settled = false;
667
648
  let retryTimer = null;
668
- let socket = null;
669
649
  const cleanup = () => {
670
650
  settled = true;
671
651
  if (retryTimer) {
672
652
  clearTimeout(retryTimer);
673
653
  retryTimer = null;
674
654
  }
675
- signal?.removeEventListener("abort", onAbort);
676
- };
677
- const onAbort = () => {
678
- if (settled)
679
- return;
680
- cleanup();
681
- socket?.destroy();
682
- reject(signal?.reason instanceof Error ? signal.reason : new Error("Workspace readiness was cancelled"));
683
655
  };
684
656
  const tryConnect = () => {
685
- if (settled) {
657
+ if (settled)
686
658
  return;
687
- }
688
- socket = connect({ port, host: "127.0.0.1" }, () => {
659
+ const socket = connect({ port, host: "127.0.0.1", signal }, () => {
689
660
  cleanup();
690
- socket?.end();
661
+ socket.end();
691
662
  resolve();
692
663
  });
693
664
  socket.once("error", () => {
694
- socket?.destroy();
695
- if (settled) {
665
+ socket.destroy();
666
+ if (settled)
667
+ return;
668
+ if (signal?.aborted) {
669
+ cleanup();
670
+ reject(signal.reason);
696
671
  return;
697
672
  }
698
673
  if (Date.now() >= deadline) {
@@ -707,35 +682,11 @@ export class WorkspaceManager {
707
682
  }
708
683
  });
709
684
  };
710
- if (signal?.aborted) {
711
- onAbort();
712
- return;
713
- }
714
- signal?.addEventListener("abort", onAbort, { once: true });
685
+ if (signal?.aborted)
686
+ return reject(signal.reason);
715
687
  tryConnect();
716
688
  });
717
689
  }
718
- delay(durationMs, signal) {
719
- if (durationMs <= 0) {
720
- return Promise.resolve();
721
- }
722
- return new Promise((resolve, reject) => {
723
- const timer = setTimeout(() => {
724
- signal?.removeEventListener("abort", onAbort);
725
- resolve();
726
- }, durationMs);
727
- const onAbort = () => {
728
- clearTimeout(timer);
729
- signal?.removeEventListener("abort", onAbort);
730
- reject(signal?.reason instanceof Error ? signal.reason : new Error("Workspace readiness was cancelled"));
731
- };
732
- if (signal?.aborted) {
733
- onAbort();
734
- return;
735
- }
736
- signal?.addEventListener("abort", onAbort, { once: true });
737
- });
738
- }
739
690
  describeExit(info) {
740
691
  if (info.signal) {
741
692
  return `signal ${info.signal}`;
@@ -749,15 +700,13 @@ export class WorkspaceManager {
749
700
  const record = this.workspaces.get(workspaceId);
750
701
  if (!record)
751
702
  return;
752
- const workspace = record.descriptor;
703
+ const workspace = record;
753
704
  this.opencodeAuth.delete(workspaceId);
754
705
  this.options.logger.info({ workspaceId, ...info }, "Workspace process exited");
755
706
  workspace.pid = undefined;
756
707
  workspace.port = undefined;
757
708
  workspace.updatedAt = new Date().toISOString();
758
- if (record.lifecycle.cancelled || info.requested || info.code === 0) {
759
- workspace.status = "stopped";
760
- workspace.error = undefined;
709
+ if (record[WORKSPACE_STATE].abortController.signal.aborted || info.requested || info.code === 0) {
761
710
  this.publishStopped(record);
762
711
  }
763
712
  else {