@myagentroam/node 0.11.39 → 0.11.41

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 (51) hide show
  1. package/dist/connector.js +176 -21
  2. package/dist/database.js +35 -11
  3. package/dist/main.js +12 -1
  4. package/dist/marctl-guidance.js +18 -27
  5. package/dist/migrations/v022.js +58 -0
  6. package/dist/runner/abstract-runner.js +14 -0
  7. package/dist/runner/claude/tool-normalizer.js +6 -1
  8. package/dist/runner/codex/conversation-parser.js +23 -4
  9. package/dist/runner/codex/managed-run-controller.js +4 -2
  10. package/dist/runner/codex/tool-normalizer.js +9 -0
  11. package/dist/runner/execution-presentation.js +56 -0
  12. package/dist/runner/mar-agent/agent-network-extension.js +168 -0
  13. package/dist/runner/mar-agent/agent-network-wait-output.js +72 -0
  14. package/dist/runner/mar-agent/conversation-normalizer.js +232 -118
  15. package/dist/runner/mar-agent/managed-run-controller.js +5 -0
  16. package/dist/runner/mar-agent/marctl-skill.js +3 -3
  17. package/dist/runner/mar-agent/runtime-model-factory.js +1 -0
  18. package/dist/runner/mar-agent/session-runtime.js +34 -1
  19. package/dist/runner/mar-agent-runner.js +36 -0
  20. package/dist/runner/opencode/tool-normalizer.js +6 -1
  21. package/dist/runtime-state.js +57 -0
  22. package/dist/service/agent-network-bindings.js +483 -0
  23. package/dist/service/agent-network-goal.js +19 -0
  24. package/dist/service/agent-network-invoke.js +179 -0
  25. package/dist/service/agent-network-observations.js +377 -0
  26. package/dist/service/agent-network-service.js +315 -0
  27. package/dist/service/agent-network-wait.js +130 -0
  28. package/dist/service/composer-message-input.js +9 -2
  29. package/dist/service/continuous-run-coordinator.js +24 -1
  30. package/dist/service/goal-continuation-coordinator.js +12 -2
  31. package/dist/service/marctl-caller.js +80 -0
  32. package/dist/service/marctl-execution-credential-control.js +65 -0
  33. package/dist/service/model-usage-service.js +9 -0
  34. package/dist/service/node-control-message-service.js +10 -0
  35. package/dist/service/node-request-service.js +52 -7
  36. package/dist/service/node-upgrade-coordinator.js +79 -8
  37. package/dist/service/node-upgrade-credential-control.js +57 -0
  38. package/dist/service/node-upgrade-reporter.js +108 -0
  39. package/dist/service/run-event-service.js +8 -0
  40. package/dist/service/run-workbench-service.js +11 -0
  41. package/dist/service/supervised-node-child.js +153 -0
  42. package/dist/service/supervisor-upgrade-handoff.js +104 -0
  43. package/dist/session/native-session-projection-service.js +69 -6
  44. package/dist/session/operation-admission.js +7 -0
  45. package/dist/session/runtime/conversation-projection.js +25 -3
  46. package/dist/session/session-catalog-service.js +156 -32
  47. package/dist/session/session-lifecycle-service.js +68 -42
  48. package/dist/session/session-message-service.js +16 -6
  49. package/dist/supervisor.js +209 -20
  50. package/dist/terminal.js +41 -21
  51. package/package.json +5 -5
@@ -6,6 +6,8 @@ import { parseSecretEnvironment } from '../mcp/secret-environment.js';
6
6
  import { ScheduledSessionStore } from '../service/scheduled-session-store.js';
7
7
  import { normalizeSessionPrompt } from '../runner/personal-instructions.js';
8
8
  import { resolveWorkspaceExecutionDirectory } from '../workspace/path-access.js';
9
+ import { checkSessionAdmission } from './operation-admission.js';
10
+ const AGENT_GOAL_START = Symbol('agent-goal-start');
9
11
  export class SessionLifecycleService {
10
12
  options;
11
13
  starts = new Map();
@@ -24,6 +26,10 @@ export class SessionLifecycleService {
24
26
  clear() {
25
27
  this.starts.clear();
26
28
  }
29
+ /** Use the normal Session bootstrap while its Goal owner submits the first message. */
30
+ startAgentWithGoal(input, submit) {
31
+ return this.start({ ...input, [AGENT_GOAL_START]: submit });
32
+ }
27
33
  async updateMetadata(input) {
28
34
  if (input.parentSessionId !== undefined ||
29
35
  (input.clearParent !== undefined &&
@@ -47,27 +53,8 @@ export class SessionLifecycleService {
47
53
  (await this.options.projection.createMetadataProjection(input.sessionId));
48
54
  if (current === undefined)
49
55
  throw new Error('SESSION_NOT_FOUND');
50
- if (input.clearParent === true) {
51
- if (current.externalSessionId === null)
52
- throw new Error('SESSION_ACTIVATION_REQUIRED');
53
- const metadata = this.options
54
- .database()
55
- .getNativeSessionMetadata(current.runner, current.externalSessionId, current.workspaceId);
56
- const parentSessionId = metadata === undefined ? current.parentSessionId : metadata.parentSessionId;
57
- if (parentSessionId == null) {
58
- const next = current.parentSessionId == null
59
- ? current
60
- : this.options.runtime.clearSessionParent(current.id);
61
- return { session: this.options.present(next) };
62
- }
63
- this.options
64
- .database()
65
- .clearSessionParent(current.runner, current.externalSessionId, current.workspaceId);
66
- const next = this.options.runtime.clearSessionParent(current.id);
67
- this.options.parentCleared?.(next);
68
- this.options.emitSession(next);
69
- return { session: this.options.present(next) };
70
- }
56
+ if (input.clearParent === true)
57
+ throw new Error('SESSION_PARENT_IMMUTABLE');
71
58
  if (sessionPrompt !== undefined && current.externalSessionId === null)
72
59
  throw new Error('SESSION_ACTIVATION_REQUIRED');
73
60
  if (input.pinned === false) {
@@ -142,6 +129,7 @@ export class SessionLifecycleService {
142
129
  if (workspace === undefined)
143
130
  throw new Error('WORKSPACE_NOT_FOUND');
144
131
  const cwd = await resolveWorkspaceExecutionDirectory(workspace.path, input.cwd);
132
+ checkSessionAdmission(input);
145
133
  if (database.getWorkspace(workspace.id)?.path !== workspace.path)
146
134
  throw new Error('WORKSPACE_NOT_FOUND');
147
135
  let parentSessionId;
@@ -157,6 +145,26 @@ export class SessionLifecycleService {
157
145
  if (liveParent?.externalSessionId === null)
158
146
  throw new Error('SESSION_ACTIVATION_REQUIRED');
159
147
  parentSessionId = liveParent?.externalSessionId ?? parent.id;
148
+ this.options.runtime.assertSessionHistoryMutable(parent.id);
149
+ if (parentSessionId !== parent.id)
150
+ this.options.runtime.assertSessionHistoryMutable(parentSessionId);
151
+ const visited = new Set();
152
+ let ancestorId = parentSessionId;
153
+ let depth = 0;
154
+ while (ancestorId !== null) {
155
+ if (visited.has(ancestorId))
156
+ throw new Error('SESSION_RELATION_INVALID');
157
+ visited.add(ancestorId);
158
+ const live = this.options.runtime.getAgentSession(ancestorId);
159
+ const stored = database.getSessionRecord(ancestorId);
160
+ const ancestorWorkspace = live?.workspaceId ?? stored?.workspaceId;
161
+ if (ancestorWorkspace !== workspace.id)
162
+ throw new Error('SESSION_RELATION_INVALID');
163
+ depth += 1;
164
+ if (depth >= 3)
165
+ throw new Error('SESSION_DEPTH_EXCEEDED');
166
+ ancestorId = live?.parentSessionId ?? stored?.metadata.parentSessionId ?? null;
167
+ }
160
168
  }
161
169
  const environment = parseSecretEnvironment(input.secretEnvironment);
162
170
  const saved = database.getRunnerDefaults().find((value) => value.runner === runner.name);
@@ -173,6 +181,8 @@ export class SessionLifecycleService {
173
181
  };
174
182
  if (runner.name === 'mar-agent' && !['auto', 'full-access'].includes(configuration.access))
175
183
  throw new Error('SESSION_INVALID');
184
+ if (parentSessionId !== undefined)
185
+ runner.assertChildModel(configuration.model);
176
186
  const session = this.options.runtime.createAgentSession({
177
187
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
178
188
  workspaceId: input.workspaceId,
@@ -211,6 +221,8 @@ export class SessionLifecycleService {
211
221
  input.access !== undefined &&
212
222
  !['auto', 'full-access'].includes(input.access))
213
223
  throw new Error('SESSION_INVALID');
224
+ if (current.parentSessionId != null && typeof input.model === 'string')
225
+ this.options.runners.require(current.runner).assertChildModel(input.model);
214
226
  const session = this.options.runtime.updateAgentSessionConfiguration({
215
227
  sessionId: current.id,
216
228
  ...(typeof input.model === 'string' ? { model: input.model } : {}),
@@ -230,6 +242,7 @@ export class SessionLifecycleService {
230
242
  return { session: this.options.present(session) };
231
243
  }
232
244
  async start(input) {
245
+ checkSessionAdmission(input);
233
246
  const attachments = parseMessageAttachments(input.attachments);
234
247
  if (typeof input.clientMessageId !== 'string' ||
235
248
  input.clientMessageId.length === 0 ||
@@ -292,27 +305,40 @@ export class SessionLifecycleService {
292
305
  const messageSessionId = interceptedId ?? bootstrapId;
293
306
  if (interceptedId !== undefined)
294
307
  this.options.identity.promote(bootstrapId, interceptedId);
295
- const message = (await this.options.execute('session.message', {
296
- sessionId: messageSessionId,
297
- clientMessageId: input.clientMessageId,
298
- content: input.content,
299
- ...(input.__marctlOrigin === undefined ? {} : { __marctlOrigin: input.__marctlOrigin }),
300
- ...(input.__suppressNotifications === true ? { __suppressNotifications: true } : {}),
301
- ...(input.__requestUserId === undefined ? {} : { __requestUserId: input.__requestUserId }),
302
- ...(input.__usageGrant === undefined ? {} : { __usageGrant: input.__usageGrant }),
303
- ...(input.secretEnvironment === undefined
304
- ? {}
305
- : { secretEnvironment: input.secretEnvironment }),
306
- ...(input.personalInstructions === undefined
307
- ? {}
308
- : { personalInstructions: input.personalInstructions }),
309
- [RUNNER_EXECUTION_CONTEXT]: this.options.runners
310
- .require(created.session.runner)
311
- .prepareExecutionContext(input),
312
- ...(input.computerUse === undefined ? {} : { computerUse: input.computerUse }),
313
- ...(input.attachmentIds === undefined ? {} : { attachmentIds: input.attachmentIds }),
314
- ...(input.attachments === undefined ? {} : { attachments: input.attachments })
315
- }));
308
+ const executionContext = this.options.runners
309
+ .require(created.session.runner)
310
+ .prepareExecutionContext(input);
311
+ const goalStart = input[AGENT_GOAL_START];
312
+ const goalSession = goalStart === undefined
313
+ ? undefined
314
+ : this.options.runtime.getAgentSession(messageSessionId);
315
+ if (goalStart !== undefined && goalSession === undefined)
316
+ throw new Error('SESSION_START_FAILED');
317
+ const message = (goalStart === undefined
318
+ ? await this.options.execute('session.message', {
319
+ sessionId: messageSessionId,
320
+ clientMessageId: input.clientMessageId,
321
+ content: input.content,
322
+ ...(input.__marctlOrigin === undefined
323
+ ? {}
324
+ : { __marctlOrigin: input.__marctlOrigin }),
325
+ ...(input.__suppressNotifications === true ? { __suppressNotifications: true } : {}),
326
+ ...(input.__requestUserId === undefined
327
+ ? {}
328
+ : { __requestUserId: input.__requestUserId }),
329
+ ...(input.__usageGrant === undefined ? {} : { __usageGrant: input.__usageGrant }),
330
+ ...(input.secretEnvironment === undefined
331
+ ? {}
332
+ : { secretEnvironment: input.secretEnvironment }),
333
+ ...(input.personalInstructions === undefined
334
+ ? {}
335
+ : { personalInstructions: input.personalInstructions }),
336
+ [RUNNER_EXECUTION_CONTEXT]: executionContext,
337
+ ...(input.computerUse === undefined ? {} : { computerUse: input.computerUse }),
338
+ ...(input.attachmentIds === undefined ? {} : { attachmentIds: input.attachmentIds }),
339
+ ...(input.attachments === undefined ? {} : { attachments: input.attachments })
340
+ })
341
+ : await goalStart(goalSession, executionContext));
316
342
  const nativeSessionId = await identity;
317
343
  const session = this.options.runtime.getAgentSession(nativeSessionId);
318
344
  const run = this.options.runtime.getRun(message.run.id);
@@ -5,6 +5,7 @@ import { sessionTitleFromFirstMessage } from './input-validation.js';
5
5
  import { isImageAttachment, isRunnerImageAttachment, parseComposerAttachmentIds, parseMessageAttachments, withNonImageAttachmentPrompt } from './attachments.js';
6
6
  import { parseSecretEnvironment } from '../mcp/secret-environment.js';
7
7
  import { personalInstructionsForRunner } from '../runner/personal-instructions.js';
8
+ import { checkSessionAdmission } from './operation-admission.js';
8
9
  import { safeErrorCode } from '../util/safe-error.js';
9
10
  import { publicGoalStartText } from '../service/goal-runtime.js';
10
11
  import { serializeComposerTag, composerVisibleText, steerComposerMessage } from '@myagentroam/protocol';
@@ -45,12 +46,17 @@ export class SessionMessageService {
45
46
  __suppressNotifications: input.suppressNotifications === true ||
46
47
  (input.kind === 'continue' &&
47
48
  this.options.runtime.activeSessionRun(input.sessionId)?.suppressNotifications === true),
48
- clientMessageId: `goal:${randomUUID()}`,
49
+ clientMessageId: input.clientMessageId ?? `goal:${randomUUID()}`,
49
50
  content: input.kind === 'start'
50
- ? publicGoalStartText(input.objective ?? '')
51
+ ? [publicGoalStartText(input.objective ?? ''), input.messageContent]
52
+ .filter(Boolean)
53
+ .join('\n')
51
54
  : serializeComposerTag('mar_goal_continue', {}),
52
- __runnerContent: input.content,
55
+ __runnerContent: [input.content, input.messageContent].filter(Boolean).join('\n\n'),
53
56
  __goalRef: input.goal,
57
+ ...(input.messageOrigin === undefined ? {} : { __marctlOrigin: input.messageOrigin }),
58
+ ...(input.attachments === undefined ? {} : { attachments: input.attachments }),
59
+ ...(input.attachmentIds === undefined ? {} : { attachmentIds: input.attachmentIds }),
54
60
  ...(input.initiatedByUserId === undefined
55
61
  ? {}
56
62
  : { __requestUserId: input.initiatedByUserId }),
@@ -84,6 +90,7 @@ export class SessionMessageService {
84
90
  return task.finally(() => this.preparations.delete(task));
85
91
  }
86
92
  async prepareMessage(input) {
93
+ checkSessionAdmission(input);
87
94
  if (typeof input.sessionId !== 'string' ||
88
95
  typeof input.clientMessageId !== 'string' ||
89
96
  input.clientMessageId.length === 0 ||
@@ -107,6 +114,7 @@ export class SessionMessageService {
107
114
  if (inlineAttachments.length > 0 && attachmentIds.length > 0)
108
115
  throw new Error('MESSAGE_ATTACHMENTS_INVALID');
109
116
  const accepted = await this.options.resolve(input.sessionId);
117
+ checkSessionAdmission(input);
110
118
  const session = accepted.session;
111
119
  if (input.workspaceId !== undefined && session.workspaceId !== input.workspaceId)
112
120
  throw new Error('SESSION_NOT_FOUND');
@@ -135,20 +143,22 @@ export class SessionMessageService {
135
143
  idempotent: true,
136
144
  auditReceipt: auditReceipt(session, existing.kind === 'queue' ? 'QUEUE' : 'SEND', existing.kind === 'queue' ? existing.item.id : existing.run.id, existing.kind === 'queue' ? existing.item.id : undefined)
137
145
  };
146
+ if (session.parentSessionId != null)
147
+ this.options.runners.require(session.runner).assertChildModel(session.model);
138
148
  const executionContext = input[RUNNER_EXECUTION_CONTEXT] ??
139
149
  this.options.runners.require(session.runner).prepareExecutionContext(input);
140
- const sourceContent = withSessionMessageOrigin(content, input.__marctlOrigin);
150
+ const sourceContent = withSessionMessageOrigin(content, input.__marctlOrigin, session.parentSessionId);
141
151
  const goalMessage = internalGoal || internalSchedule || internalRestore
142
152
  ? undefined
143
153
  : this.options.goalMessage(session, content, requestedIntent);
144
154
  const executionContent = goalMessage?.runnerContent ?? internalRunnerContent;
145
155
  const runnerContent = executionContent === content
146
156
  ? sourceContent
147
- : withSessionMessageOrigin(executionContent, input.__marctlOrigin);
157
+ : withSessionMessageOrigin(executionContent, input.__marctlOrigin, session.parentSessionId);
148
158
  const publicContent = goalMessage?.activated === true
149
159
  ? withSessionMessageOrigin([publicGoalStartText(composerVisibleText(content)), composerResourceInputTags(content)]
150
160
  .filter(Boolean)
151
- .join('\n'), input.__marctlOrigin)
161
+ .join('\n'), input.__marctlOrigin, session.parentSessionId)
152
162
  : sourceContent;
153
163
  const intent = goalMessage?.activated === true ? 'REPLACE_CURRENT' : requestedIntent;
154
164
  const releasePreparation = this.options.attachments.protectSession(session.id);
@@ -6,6 +6,9 @@ import { nodeLog, platformCliInvocation } from './operational.js';
6
6
  import { nodeUpgradeRequestPath } from './service/node-upgrade-coordinator.js';
7
7
  import { safeErrorCode } from './util/safe-error.js';
8
8
  import { NODE_OWNER_CONFLICT_EXIT_CODE } from './node-process-lock.js';
9
+ import { runSupervisedNodeChild } from './service/supervised-node-child.js';
10
+ import { NodeUpgradeReporter } from './service/node-upgrade-reporter.js';
11
+ import { terminateProcessTree } from './process-tree.js';
9
12
  const UPGRADE_EXIT_CODE = 75;
10
13
  const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
11
14
  export async function superviseNode(configPath, bootstrapEntrypoint) {
@@ -13,26 +16,92 @@ export async function superviseNode(configPath, bootstrapEntrypoint) {
13
16
  const dataDirectory = dirname(nodeDatabasePath(config, configPath));
14
17
  const activePath = resolve(dataDirectory, 'node-active-entrypoint');
15
18
  let entrypoint = await readActiveEntrypoint(activePath, bootstrapEntrypoint);
19
+ let reporter;
20
+ let awaitingOnline;
16
21
  for (;;) {
17
- const childResult = await runProcess(process.execPath, [entrypoint, 'run', '--config', configPath], true, { ...process.env, MAR_NODE_SUPERVISED: '1' });
22
+ const requestPath = nodeUpgradeRequestPath(nodeDatabasePath(config, configPath));
23
+ let reportingIdentity;
24
+ const childResult = await runSupervisedNodeChild(entrypoint, configPath, async (handoff) => {
25
+ const currentConfig = await loadNodeConfig(configPath);
26
+ if (currentConfig.nodeId === undefined ||
27
+ !STABLE_VERSION.test(handoff.targetVersion) ||
28
+ !/^[0-9a-f-]{36}$/u.test(handoff.attemptId) ||
29
+ !/^[A-Za-z0-9_-]{43}$/u.test(handoff.credential))
30
+ return false;
31
+ const request = JSON.parse(await readFile(requestPath, 'utf8'));
32
+ const matches = request['schemaVersion'] === 1 &&
33
+ request['attemptId'] === handoff.attemptId &&
34
+ request['targetVersion'] === handoff.targetVersion;
35
+ if (matches)
36
+ reportingIdentity = { serverUrl: currentConfig.serverUrl, nodeId: currentConfig.nodeId };
37
+ return matches;
38
+ }, (handoff) => {
39
+ if (reportingIdentity === undefined)
40
+ throw new Error('NODE_UPGRADE_HANDOFF_UNAVAILABLE');
41
+ const next = new NodeUpgradeReporter(reportingIdentity.serverUrl, reportingIdentity.nodeId, handoff.credential);
42
+ reporter?.stop();
43
+ reporter = next;
44
+ next.update({ attemptId: handoff.attemptId, step: 'CLOSING_NODE' });
45
+ }, awaitingOnline);
18
46
  if (childResult.signalled) {
47
+ reporter?.stop();
19
48
  process.exitCode = childResult.code ?? 1;
20
49
  return;
21
50
  }
51
+ if (childResult.reclaimFailed) {
52
+ if (childResult.handoff !== undefined)
53
+ await reporter?.finish({
54
+ attemptId: childResult.handoff.attemptId,
55
+ step: 'CLOSING_NODE',
56
+ result: 'FAILED',
57
+ failure: {
58
+ code: 'NODE_UPGRADE_RECLAIM_FAILED',
59
+ message: 'The old Node process could not be stopped.',
60
+ step: 'CLOSING_NODE',
61
+ kind: 'INSTALL'
62
+ }
63
+ });
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ if (childResult.forced)
68
+ nodeLog('node.supervisor.upgrade-old-node-reclaimed', {
69
+ code: 'NODE_UPGRADE_EXIT_TIMEOUT_RECLAIMED'
70
+ });
22
71
  const code = childResult.code;
23
- if (code === NODE_OWNER_CONFLICT_EXIT_CODE) {
72
+ if (childResult.handoff === undefined &&
73
+ awaitingOnline !== undefined &&
74
+ reporter !== undefined) {
75
+ await reporter.finish({
76
+ attemptId: awaitingOnline,
77
+ step: 'WAITING_FOR_ONLINE',
78
+ result: 'FAILED',
79
+ operationEnded: true,
80
+ failure: {
81
+ code: 'NODE_UPGRADE_START_FAILED',
82
+ message: 'The upgraded Node process exited before reporting online.',
83
+ step: 'WAITING_FOR_ONLINE',
84
+ kind: 'START'
85
+ }
86
+ });
87
+ reporter = undefined;
88
+ }
89
+ if (code === NODE_OWNER_CONFLICT_EXIT_CODE && childResult.handoff === undefined) {
24
90
  process.exitCode = code;
25
91
  return;
26
92
  }
27
- if (code !== UPGRADE_EXIT_CODE) {
93
+ if (code !== UPGRADE_EXIT_CODE && childResult.handoff === undefined) {
28
94
  if (code === 0)
29
95
  return;
30
96
  nodeLog('node.supervisor.child-exited', { code });
31
97
  await delay(1_000);
32
98
  continue;
33
99
  }
34
- const requestPath = nodeUpgradeRequestPath(nodeDatabasePath(config, configPath));
35
100
  let stopping = false;
101
+ let switched = false;
102
+ let installReclaimFailed = false;
103
+ let failureStage = 'VALIDATE_REQUEST';
104
+ let failureExitCode;
36
105
  try {
37
106
  const request = JSON.parse(await readFile(requestPath, 'utf8'));
38
107
  const targetVersion = request['targetVersion'];
@@ -41,6 +110,10 @@ export async function superviseNode(configPath, bootstrapEntrypoint) {
41
110
  !STABLE_VERSION.test(targetVersion))
42
111
  throw new Error('NODE_UPGRADE_REQUEST_INVALID');
43
112
  const prefix = resolve(dataDirectory, 'node-versions', targetVersion);
113
+ const installDeadline = Date.now() + 15 * 60_000;
114
+ if (childResult.handoff !== undefined)
115
+ reporter?.update({ attemptId: childResult.handoff.attemptId, step: 'INSTALLING' });
116
+ failureStage = 'PREPARE_INSTALL_DIRECTORY';
44
117
  await rm(prefix, { recursive: true, force: true });
45
118
  await mkdir(prefix, { recursive: true, mode: 0o700 });
46
119
  const invocation = platformCliInvocation('npm', [
@@ -52,62 +125,166 @@ export async function superviseNode(configPath, bootstrapEntrypoint) {
52
125
  '--no-fund',
53
126
  `@myagentroam/node@${targetVersion}`
54
127
  ]);
55
- const installResult = await runProcess(invocation.command, invocation.args, false);
128
+ failureStage = 'NPM_INSTALL';
129
+ const installResult = await runProcess(invocation.command, invocation.args, false, installDeadline);
130
+ failureExitCode = installResult.code;
131
+ if (installResult.reclaimFailed) {
132
+ installReclaimFailed = true;
133
+ stopping = true;
134
+ throw new Error('NODE_UPGRADE_INSTALL_RECLAIM_FAILED');
135
+ }
56
136
  if (installResult.signalled) {
57
137
  stopping = true;
58
138
  throw new Error('NODE_UPGRADE_INSTALL_INTERRUPTED');
59
139
  }
140
+ if (installResult.timedOut)
141
+ throw new Error('NODE_UPGRADE_INSTALL_TIMEOUT');
60
142
  if (installResult.code !== 0)
61
143
  throw new Error('NODE_UPGRADE_INSTALL_FAILED');
62
144
  const rebuild = platformCliInvocation('npm', ['rebuild', '--prefix', prefix, 'node-pty']);
63
- const rebuildResult = await runProcess(rebuild.command, rebuild.args, false);
145
+ failureStage = 'NATIVE_REBUILD';
146
+ failureExitCode = undefined;
147
+ const rebuildResult = await runProcess(rebuild.command, rebuild.args, false, installDeadline);
148
+ failureExitCode = rebuildResult.code;
149
+ if (rebuildResult.reclaimFailed) {
150
+ installReclaimFailed = true;
151
+ stopping = true;
152
+ throw new Error('NODE_UPGRADE_INSTALL_RECLAIM_FAILED');
153
+ }
64
154
  if (rebuildResult.signalled) {
65
155
  stopping = true;
66
156
  throw new Error('NODE_UPGRADE_INSTALL_INTERRUPTED');
67
157
  }
158
+ if (rebuildResult.timedOut)
159
+ throw new Error('NODE_UPGRADE_INSTALL_TIMEOUT');
68
160
  if (rebuildResult.code !== 0)
69
161
  throw new Error('NODE_UPGRADE_NATIVE_REBUILD_FAILED');
162
+ failureStage = 'BROWSER_INSTALL';
163
+ failureExitCode = undefined;
70
164
  const browserInstall = await runProcess(process.execPath, [
71
165
  resolve(prefix, 'node_modules', '@myagentroam', 'node', 'scripts', 'install-web-browser.mjs')
72
- ], true);
166
+ ], true, installDeadline);
167
+ failureExitCode = browserInstall.code;
168
+ if (browserInstall.reclaimFailed) {
169
+ installReclaimFailed = true;
170
+ stopping = true;
171
+ throw new Error('NODE_UPGRADE_INSTALL_RECLAIM_FAILED');
172
+ }
73
173
  if (browserInstall.signalled) {
74
174
  stopping = true;
75
175
  throw new Error('NODE_UPGRADE_INSTALL_INTERRUPTED');
76
176
  }
177
+ if (browserInstall.timedOut)
178
+ throw new Error('NODE_UPGRADE_INSTALL_TIMEOUT');
77
179
  if (browserInstall.code !== 0)
78
180
  throw new Error('NODE_UPGRADE_BROWSER_INSTALL_FAILED');
181
+ failureStage = 'ENTRYPOINT_SWITCH';
182
+ failureExitCode = undefined;
79
183
  const nextEntrypoint = resolve(prefix, 'node_modules', '@myagentroam', 'node', 'dist', 'main.js');
80
184
  await access(nextEntrypoint);
81
185
  await writeAtomic(activePath, `${nextEntrypoint}\n`);
186
+ entrypoint = nextEntrypoint;
187
+ switched = true;
188
+ if (childResult.handoff !== undefined) {
189
+ awaitingOnline = childResult.handoff.attemptId;
190
+ reporter?.update({
191
+ attemptId: childResult.handoff.attemptId,
192
+ step: 'WAITING_FOR_ONLINE',
193
+ result: 'INSTALLED'
194
+ });
195
+ }
82
196
  await writeResult(dataDirectory, {
83
197
  state: 'SUCCEEDED',
84
198
  targetVersion,
85
199
  updatedAt: Date.now()
86
200
  });
87
- await rm(requestPath, { force: true });
88
- entrypoint = nextEntrypoint;
89
201
  }
90
202
  catch (error) {
91
- await writeResult(dataDirectory, {
92
- state: 'FAILED',
93
- errorCode: safeErrorCode(error, 'NODE_UPGRADE_INSTALL_FAILED'),
94
- updatedAt: Date.now()
95
- });
203
+ if (switched) {
204
+ nodeLog('node.supervisor.upgrade-result-write-failed', {
205
+ code: 'NODE_UPGRADE_RESULT_WRITE_FAILED'
206
+ });
207
+ }
208
+ else {
209
+ if (childResult.handoff !== undefined)
210
+ await reporter?.finish({
211
+ attemptId: childResult.handoff.attemptId,
212
+ step: 'INSTALLING',
213
+ result: 'FAILED',
214
+ ...(installReclaimFailed ? {} : { operationEnded: true }),
215
+ failure: {
216
+ code: safeErrorCode(error, 'NODE_UPGRADE_INSTALL_FAILED'),
217
+ message: 'The Node upgrade could not be installed.',
218
+ step: 'INSTALLING',
219
+ kind: 'INSTALL',
220
+ details: `Stage: ${failureStage}${failureExitCode == null ? '' : `; exit code: ${failureExitCode}`}`
221
+ }
222
+ });
223
+ reporter = undefined;
224
+ try {
225
+ await writeResult(dataDirectory, {
226
+ state: 'FAILED',
227
+ errorCode: safeErrorCode(error, 'NODE_UPGRADE_INSTALL_FAILED'),
228
+ updatedAt: Date.now()
229
+ });
230
+ }
231
+ catch {
232
+ nodeLog('node.supervisor.upgrade-result-write-failed', {
233
+ code: 'NODE_UPGRADE_RESULT_WRITE_FAILED'
234
+ });
235
+ }
236
+ }
237
+ }
238
+ try {
96
239
  await rm(requestPath, { force: true });
97
240
  }
241
+ catch {
242
+ nodeLog('node.supervisor.upgrade-request-cleanup-failed', {
243
+ code: 'NODE_UPGRADE_REQUEST_CLEANUP_FAILED'
244
+ });
245
+ }
98
246
  if (stopping)
99
247
  return;
100
248
  }
101
249
  }
102
- async function runProcess(command, args, inherit, env = process.env) {
250
+ async function runProcess(command, args, inherit, deadlineAt) {
103
251
  return new Promise((resolveProcess) => {
104
252
  const child = spawn(command, args, {
105
253
  windowsHide: true,
106
254
  stdio: inherit ? 'inherit' : 'ignore',
107
- env
255
+ env: process.env,
256
+ detached: process.platform !== 'win32'
108
257
  });
109
258
  let settled = false;
110
259
  let signalled = false;
260
+ let timedOut = false;
261
+ let reclaimed = false;
262
+ let exited;
263
+ let reapDeadline;
264
+ const deadline = setTimeout(() => {
265
+ timedOut = true;
266
+ reapDeadline = setTimeout(() => finish(null, true), 10_000);
267
+ void terminateProcessTree(child)
268
+ .then(() => {
269
+ reclaimed = true;
270
+ if (exited !== undefined)
271
+ finish(exited.code);
272
+ })
273
+ .catch(() => finish(null, true));
274
+ if (process.platform !== 'win32' && child.pid !== undefined) {
275
+ try {
276
+ process.kill(-child.pid, 'SIGKILL');
277
+ }
278
+ catch {
279
+ try {
280
+ child.kill('SIGKILL');
281
+ }
282
+ catch {
283
+ /* Wait for exit or timeout. */
284
+ }
285
+ }
286
+ }
287
+ }, Math.max(0, deadlineAt - Date.now()));
111
288
  const forward = (signal) => {
112
289
  if (signalled)
113
290
  return;
@@ -116,18 +293,30 @@ async function runProcess(command, args, inherit, env = process.env) {
116
293
  };
117
294
  const onSigint = () => forward('SIGINT');
118
295
  const onSigterm = () => forward('SIGTERM');
119
- const finish = (code) => {
296
+ const finish = (code, reclaimFailed = false) => {
120
297
  if (settled)
121
298
  return;
122
299
  settled = true;
300
+ clearTimeout(deadline);
301
+ if (reapDeadline !== undefined)
302
+ clearTimeout(reapDeadline);
123
303
  process.off('SIGINT', onSigint);
124
304
  process.off('SIGTERM', onSigterm);
125
- resolveProcess({ code, signalled });
305
+ if (reclaimFailed)
306
+ child.unref();
307
+ resolveProcess({ code, signalled, timedOut, reclaimFailed });
126
308
  };
127
309
  process.on('SIGINT', onSigint);
128
310
  process.on('SIGTERM', onSigterm);
129
- child.once('error', () => finish(null));
130
- child.once('close', finish);
311
+ child.on('error', () => {
312
+ if (child.pid === undefined)
313
+ finish(null);
314
+ });
315
+ child.once('exit', (code) => {
316
+ exited = { code };
317
+ if (!timedOut || reclaimed)
318
+ finish(code);
319
+ });
131
320
  });
132
321
  }
133
322
  async function readActiveEntrypoint(path, fallback) {
package/dist/terminal.js CHANGED
@@ -32,6 +32,7 @@ export class TerminalManager {
32
32
  pipeFactory;
33
33
  terminate;
34
34
  terminals = new Map();
35
+ closing = new Map();
35
36
  attachments = new Map();
36
37
  frameListeners = new Set();
37
38
  summaryListeners = new Set();
@@ -309,37 +310,56 @@ export class TerminalManager {
309
310
  }
310
311
  await this.close(terminal.id);
311
312
  }
312
- async close(terminalId) {
313
+ close(terminalId) {
313
314
  const terminal = this.requireTerminal(terminalId);
315
+ const pending = this.closing.get(terminalId);
316
+ if (pending !== undefined)
317
+ return pending;
314
318
  if (terminal.state !== 'RUNNING')
315
- return this.summary(terminal);
319
+ return Promise.resolve(this.summary(terminal));
316
320
  terminal.state = 'CLOSED';
317
321
  terminal.updatedAt = this.now();
318
- this.detachAll(terminal);
319
- const summary = this.notifySummary(terminal);
320
- try {
321
- if (terminal.pty !== undefined) {
322
- // Native PTY hangup reaches POSIX foreground jobs in a separate process group
323
- // and releases the native console on Windows; shell-group TERM cannot do this.
324
- try {
325
- terminal.pty.kill();
326
- }
327
- catch {
328
- await this.terminate(terminal.pty, this.platform);
322
+ let resolveClosing;
323
+ let rejectClosing;
324
+ const closing = new Promise((resolve, reject) => {
325
+ resolveClosing = resolve;
326
+ rejectClosing = reject;
327
+ });
328
+ this.closing.set(terminalId, closing);
329
+ void (async () => {
330
+ this.detachAll(terminal);
331
+ const summary = this.notifySummary(terminal);
332
+ try {
333
+ if (terminal.pty !== undefined) {
334
+ // Native PTY hangup reaches POSIX foreground jobs in a separate process group
335
+ // and releases the native console on Windows; shell-group TERM cannot do this.
336
+ try {
337
+ terminal.pty.kill();
338
+ }
339
+ catch {
340
+ await this.terminate(terminal.pty, this.platform);
341
+ }
329
342
  }
343
+ if (terminal.pipe !== undefined)
344
+ await this.terminate(terminal.pipe, this.platform);
330
345
  }
331
- if (terminal.pipe !== undefined)
332
- await this.terminate(terminal.pipe, this.platform);
333
- }
334
- finally {
335
- this.emit({ type: 'exit', terminalId: terminal.id, exitCode: terminal.exitCode });
336
- }
337
- return summary;
346
+ finally {
347
+ this.emit({ type: 'exit', terminalId: terminal.id, exitCode: terminal.exitCode });
348
+ }
349
+ return summary;
350
+ })().then((summary) => {
351
+ this.closing.delete(terminalId);
352
+ resolveClosing(summary);
353
+ }, (error) => {
354
+ this.closing.delete(terminalId);
355
+ rejectClosing(error);
356
+ });
357
+ return closing;
338
358
  }
339
359
  /** Ends current terminals without disposing the manager's idle collection. */
340
360
  async closeAll() {
341
361
  await Promise.all([...this.terminals.values()]
342
- .filter((terminal) => terminal.state === 'RUNNING')
362
+ .filter((terminal) => terminal.state === 'RUNNING' || this.closing.has(terminal.id))
343
363
  .map((terminal) => this.close(terminal.id)));
344
364
  }
345
365
  async shutdown() {