maka-agent 0.2.0-dev.76.20261009 → 0.2.0-dev.78.20261010

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 (78) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +3 -3
  3. package/dist/pi-tui-pickers.js +5 -2
  4. package/dist/pi-tui-runner.js +211 -33
  5. package/dist/runtime-host-session-driver.js +91 -13
  6. package/dist/session-catalog-limits.js +22 -0
  7. package/dist/session-driver.js +10 -0
  8. package/dist/tui-copy-catalog.js +15 -0
  9. package/native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe +0 -0
  10. package/node_modules/@maka/core/dist/bot-chat-settings.js +3 -0
  11. package/node_modules/@maka/core/dist/bot-events.js +1 -8
  12. package/node_modules/@maka/core/dist/local-memory.js +38 -1
  13. package/node_modules/@maka/core/dist/scheduled-task.js +14 -15
  14. package/node_modules/@maka/core/dist/session.js +3 -0
  15. package/node_modules/@maka/core/dist/text-sanitize.js +8 -3
  16. package/node_modules/@maka/runtime/dist/agent-graph-supervisor-wake.js +115 -17
  17. package/node_modules/@maka/runtime/dist/agent-run.js +11 -0
  18. package/node_modules/@maka/runtime/dist/ai-sdk-backend.js +11 -0
  19. package/node_modules/@maka/runtime/dist/ai-sdk-turn.js +13 -5
  20. package/node_modules/@maka/runtime/dist/bots/proxied-fetch.js +29 -1
  21. package/node_modules/@maka/runtime/dist/computer-use-observation-text.js +22 -0
  22. package/node_modules/@maka/runtime/dist/conversation-copy-agent-output.js +227 -0
  23. package/node_modules/@maka/runtime/dist/conversation-copy.js +176 -46
  24. package/node_modules/@maka/runtime/dist/file-stable-write.js +15 -3
  25. package/node_modules/@maka/runtime/dist/filesystem-worker/resource-resolver.js +3 -1
  26. package/node_modules/@maka/runtime/dist/model-adapter.js +20 -1
  27. package/node_modules/@maka/runtime/dist/network/proxy-test.js +28 -1
  28. package/node_modules/@maka/runtime/dist/network/scoped-fetch-transport.js +13 -2
  29. package/node_modules/@maka/runtime/dist/owned-child-process.js +209 -0
  30. package/node_modules/@maka/runtime/dist/owned-process-main.js +183 -0
  31. package/node_modules/@maka/runtime/dist/pipe-process-driver.js +19 -25
  32. package/node_modules/@maka/runtime/dist/runtime-kernel.js +216 -8
  33. package/node_modules/@maka/runtime/dist/sandbox-boundary-tool.js +12 -0
  34. package/node_modules/@maka/runtime/dist/session-manager.js +156 -17
  35. package/node_modules/@maka/runtime/dist/shell-exec.js +18 -20
  36. package/node_modules/@maka/runtime/dist/shell-run-manager.js +2 -1
  37. package/node_modules/@maka/runtime/dist/stream-graph-coordinator.js +279 -8
  38. package/node_modules/@maka/runtime/dist/stream-graph-schedule-reconcile.js +209 -37
  39. package/node_modules/@maka/runtime/dist/tool-availability.js +55 -18
  40. package/node_modules/@maka/runtime/dist/tool-call-snapshot.js +142 -0
  41. package/node_modules/@maka/runtime/dist/tool-runtime.js +27 -84
  42. package/node_modules/@maka/runtime/dist/workers/{filesystem-worker.js → filesystem-worker.mjs} +12 -1
  43. package/node_modules/@maka/runtime-host/dist/client/connect-or-spawn.js +30 -6
  44. package/node_modules/@maka/runtime-host/dist/client/index.js +1 -1
  45. package/node_modules/@maka/runtime-host/dist/client/reconnect-lifecycle.js +44 -13
  46. package/node_modules/@maka/runtime-host/dist/client/session-catalog-summary.js +6 -0
  47. package/node_modules/@maka/runtime-host/dist/client/wsl-environment.js +4 -0
  48. package/node_modules/@maka/runtime-host/dist/protocol/index.js +5 -1
  49. package/node_modules/@maka/runtime-host/dist/protocol/message.js +1 -13
  50. package/node_modules/@maka/runtime-host/dist/protocol/session-catalog.js +46 -9
  51. package/node_modules/@maka/runtime-host/dist/protocol/session-continuity.js +1 -13
  52. package/node_modules/@maka/runtime-host/dist/protocol/turn.js +1 -13
  53. package/node_modules/@maka/runtime-host/dist/protocol/workhub-coordination.js +4 -7
  54. package/node_modules/@maka/runtime-host/dist/server/archive-retention-coordinator.js +81 -105
  55. package/node_modules/@maka/runtime-host/dist/server/candidate.js +47 -40
  56. package/node_modules/@maka/runtime-host/dist/server/execution-composition.js +87 -67
  57. package/node_modules/@maka/runtime-host/dist/server/execution-service.js +5 -1
  58. package/node_modules/@maka/runtime-host/dist/server/host-maintenance-guards.js +95 -0
  59. package/node_modules/@maka/runtime-host/dist/server/hosted-execution-tool-profile.js +18 -8
  60. package/node_modules/@maka/runtime-host/dist/server/interactive-run-composer.js +32 -3
  61. package/node_modules/@maka/runtime-host/dist/server/project-catalog-coordinator.js +2 -0
  62. package/node_modules/@maka/runtime-host/dist/server/root-turn-coordinator.js +109 -8
  63. package/node_modules/@maka/runtime-host/dist/server/scheduled-task-coordinator.js +20 -3
  64. package/node_modules/@maka/runtime-host/dist/server/session-background-activity.js +71 -0
  65. package/node_modules/@maka/runtime-host/dist/server/session-catalog-coordinator.js +8 -4
  66. package/node_modules/@maka/runtime-host/dist/server/session-interaction-activity.js +78 -0
  67. package/node_modules/@maka/runtime-host/dist/server/session-retirement-coordinator.js +56 -0
  68. package/node_modules/@maka/runtime-host/dist/server/session-revision-graph-references.js +16 -2
  69. package/node_modules/@maka/runtime-host/dist/server/skill-catalog-coordinator.js +2 -0
  70. package/node_modules/@maka/runtime-host/dist/server/workhub-coordination-action-gate.js +19 -24
  71. package/node_modules/@maka/runtime-host/dist/server/workhub-coordination-coordinator.js +74 -19
  72. package/node_modules/@maka/runtime-host/dist/server/workhub-result-coordinator.js +1 -1
  73. package/node_modules/@maka/runtime-host/dist/server/workhub-result-runtime.js +6 -99
  74. package/node_modules/@maka/runtime-host/dist/server/workhub-target-execution-authority.js +14 -126
  75. package/node_modules/@maka/storage/dist/context-offload-store.js +8 -3
  76. package/node_modules/@maka/storage/dist/git-entry.js +142 -5
  77. package/node_modules/@maka/storage/dist/session-bundle-policy.js +54 -19
  78. package/package.json +2 -2
@@ -134,13 +134,25 @@ async function openExistingNoTruncate(path) {
134
134
  * only after the identity was validated on the fd. Write-step failures
135
135
  * (ENOSPC/EIO/EDQUOT/EFBIG) can leave the file truncated or half-written, so
136
136
  * they surface as `outcome_unknown` — the file's state is genuinely unknown.
137
+ * Short writes complete the remaining bytes; zero progress also reports outcome_unknown.
137
138
  */
138
139
  export async function writeThroughHandle(handle, content) {
140
+ const bytes = Buffer.from(content, 'utf8');
139
141
  try {
140
142
  await handle.truncate(0);
141
- // Position 0 explicitly: a prior readFile leaves the fd position at EOF,
142
- // and a positionless write would create a NUL-prefixed sparse file.
143
- await handle.write(content, 0, 'utf8');
143
+ // Use an explicit offset from zero: a previous readFile may leave the fd at
144
+ // EOF, where an implicit write would create a sparse prefix.
145
+ // A successful write may consume only part of the buffer. Keep byte
146
+ // offsets (not string indices) so a short write inside UTF-8 is lossless.
147
+ let offset = 0;
148
+ while (offset < bytes.length) {
149
+ const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset, offset);
150
+ if (bytesWritten <= 0) {
151
+ throw new StableWriteFailure('outcome_unknown', 'The write stopped making progress; the file may be truncated. ' +
152
+ 'Re-read the file before writing to it again.');
153
+ }
154
+ offset += bytesWritten;
155
+ }
144
156
  }
145
157
  catch (error) {
146
158
  const code = error.code;
@@ -19,7 +19,9 @@
19
19
  import { realpath, stat } from 'node:fs/promises';
20
20
  import { dirname, join, resolve } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
- export const FILESYSTEM_WORKER_BUNDLE_NAME = 'filesystem-worker.js';
22
+ // `.mjs` so Node never looks for a package.json to decide the module type:
23
+ // the Windows AppContainer sandbox denies that read and the worker crashes.
24
+ export const FILESYSTEM_WORKER_BUNDLE_NAME = 'filesystem-worker.mjs';
23
25
  export function filesystemWorkerBundleCandidate(location) {
24
26
  if (location.kind === 'desktop-packaged') {
25
27
  return join(location.resourcesPath, 'workers', FILESYSTEM_WORKER_BUNDLE_NAME);
@@ -181,7 +181,8 @@ export class ModelAdapter {
181
181
  sdkTools[TOOL_SEARCH_PROVIDER_NAME] = sdkTools[TOOL_SEARCH_NAME];
182
182
  delete sdkTools[TOOL_SEARCH_NAME];
183
183
  }
184
- const fullMessages = this.runtime.wire === 'openai-chat' ? lowerChatToolImages(input.messages) : input.messages;
184
+ const messages = withAnthropicHistoryCacheBreakpoint(input.messages, input.historyMessageCount, this.input.providerOptions);
185
+ const fullMessages = this.runtime.wire === 'openai-chat' ? lowerChatToolImages(messages) : messages;
185
186
  const responsesLane = input.continuationKey && usesNativeOpenAiResponses(this.input.connection, this.runtime)
186
187
  ? input.continuationKey
187
188
  : undefined;
@@ -895,6 +896,24 @@ function translateChunk(chunk, openAiChatReasoningTransportState, runtime, runti
895
896
  return [];
896
897
  }
897
898
  }
899
+ function withAnthropicHistoryCacheBreakpoint(messages, historyMessageCount, providerOptions) {
900
+ const cacheControl = providerOptions?.anthropic
901
+ ?.cacheControl;
902
+ if (cacheControl === undefined ||
903
+ historyMessageCount === undefined ||
904
+ historyMessageCount >= messages.length) {
905
+ return messages;
906
+ }
907
+ return messages.map((message, index) => index === historyMessageCount - 1
908
+ ? {
909
+ ...message,
910
+ providerOptions: {
911
+ ...message.providerOptions,
912
+ anthropic: { ...message.providerOptions?.anthropic, cacheControl },
913
+ },
914
+ }
915
+ : message);
916
+ }
898
917
  function lowerChatToolImages(messages) {
899
918
  const result = [];
900
919
  let images = [];
@@ -21,6 +21,10 @@ import { buildProxyDispatcher } from './proxy-dispatcher.js';
21
21
  import { fetch } from 'undici';
22
22
  const DEFAULT_PROBE_URL = 'https://icanhazip.com';
23
23
  const DEFAULT_TIMEOUT_MS = 8_000;
24
+ // Bounded wait for dispatcher teardown: a graceful close that never settles
25
+ // (observed behind real proxies after an abort) must not keep this call —
26
+ // and the serialized settings lane it runs on — pending forever.
27
+ const DISPATCHER_CLOSE_GRACE_MS = 1_000;
24
28
  export async function testProxyConnection(input = {}, storedProxy) {
25
29
  const proxy = parseProxyConfig(input.proxy ?? storedProxy);
26
30
  const probeUrl = input.url ?? DEFAULT_PROBE_URL;
@@ -81,8 +85,31 @@ export async function testProxyConnection(input = {}, storedProxy) {
81
85
  };
82
86
  }
83
87
  finally {
88
+ // Mirror the shared transport teardown ordering: begin dispatcher
89
+ // teardown before aborting, so the graceful close can retire live
90
+ // CONNECT tunnels before the signal kills their sockets; abort still
91
+ // cancels any pending connect or handshake. Keep the wait bounded so a
92
+ // stalled close cannot hold this call past its result.
93
+ const teardown = disposeDispatcher(timedOut);
84
94
  controller.abort();
85
- await disposeDispatcher(timedOut);
95
+ let graceTimer;
96
+ const graceExpired = await Promise.race([
97
+ teardown.then(() => false),
98
+ new Promise((resolve) => {
99
+ graceTimer = setTimeout(() => resolve(true), DISPATCHER_CLOSE_GRACE_MS);
100
+ }),
101
+ ]);
102
+ if (graceTimer)
103
+ clearTimeout(graceTimer);
104
+ if (graceExpired) {
105
+ // The graceful close never settled within the grace, so destroy the
106
+ // dispatcher instead of leaking it. Fire-and-forget with the error
107
+ // swallowed: teardown must never replace the result this call owes.
108
+ const disposable = dispatcher;
109
+ if (typeof disposable.destroy === 'function') {
110
+ void Promise.resolve(disposable.destroy.call(dispatcher, new Error('Dispatcher close grace expired'))).catch(() => { });
111
+ }
112
+ }
86
113
  }
87
114
  }
88
115
  async function lookupCountry(ip, dispatcher, signal) {
@@ -21,6 +21,7 @@ import { matchesBypassList } from './bypass-matcher.js';
21
21
  import { buildProxyDispatcher } from './proxy-dispatcher.js';
22
22
  import { buildAbortableConnector } from './abortable-connector.js';
23
23
  export const FETCH_PROXY_SNAPSHOT = Symbol.for('maka.fetch.proxy-snapshot');
24
+ const TRANSPORT_CLOSE_GRACE_MS = 1_000;
24
25
  export function inheritFetchProxySnapshot(fetch, source) {
25
26
  const descriptor = Object.getOwnPropertyDescriptor(source, FETCH_PROXY_SNAPSHOT);
26
27
  if (descriptor)
@@ -62,8 +63,7 @@ export function createProxiedFetchTransport(proxy) {
62
63
  if (closePromise)
63
64
  return closePromise;
64
65
  closed = true;
65
- connections.abort(new Error('Connection effect fetch transport closed'));
66
- closePromise = Promise.all([
66
+ const destroyed = Promise.all([
67
67
  directDispatcher
68
68
  .destroy(new Error('Connection effect fetch transport closed'))
69
69
  .catch(() => { }),
@@ -71,6 +71,17 @@ export function createProxiedFetchTransport(proxy) {
71
71
  ?.destroy(new Error('Connection effect fetch transport closed'))
72
72
  .catch(() => { }),
73
73
  ]).then(() => undefined);
74
+ // Let dispatchers begin teardown before aborting CONNECT tunnels, so Undici
75
+ // can retire HTTP/2 sessions before their sockets disappear. Abort still
76
+ // cancels pending connects and handshakes; retain a bound for stalled teardown.
77
+ connections.abort(new Error('Connection effect fetch transport closed'));
78
+ let deadline;
79
+ closePromise = Promise.race([
80
+ destroyed,
81
+ new Promise((resolve) => {
82
+ deadline = setTimeout(resolve, TRANSPORT_CLOSE_GRACE_MS);
83
+ }),
84
+ ]).finally(() => clearTimeout(deadline));
74
85
  return closePromise;
75
86
  };
76
87
  return { fetch, close };
@@ -0,0 +1,209 @@
1
+ /*
2
+ * Licensed to the Apache Software Foundation (ASF) under one
3
+ * or more contributor license agreements. See the NOTICE file
4
+ * distributed with this work for additional information
5
+ * regarding copyright ownership. The ASF licenses this file
6
+ * to you under the Apache License, Version 2.0 (the
7
+ * "License"); you may not use this file except in compliance
8
+ * with the License. You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing,
13
+ * software distributed under the License is distributed on an
14
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15
+ * KIND, either express or implied. See the License for the
16
+ * specific language governing permissions and limitations
17
+ * under the License.
18
+ */
19
+ import { spawn } from 'node:child_process';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { buildSpawnStdio } from './child-fd-input.js';
22
+ import { terminateProcessTree } from './process-tree-terminator.js';
23
+ /** The returned PID owns the command group. Its private IPC channel is a
24
+ * lifetime lease: only the supervisor can spawn the command, and losing the
25
+ * owner tears down the group even when no Host cleanup callback can run.
26
+ * Output and descriptor payloads flow directly through inherited OS handles.
27
+ * `ready` resolves with the command's own PID once the supervisor admits it.
28
+ */
29
+ export function spawnOwnedProcess(input) {
30
+ const stdio = buildSpawnStdio(input.fdInputs, input.stdin);
31
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./owned-process-main.js', import.meta.url))], {
32
+ cwd: input.cwd,
33
+ // Caller-supplied environment belongs to the command, not this trusted supervisor.
34
+ env: {
35
+ ...process.env,
36
+ NODE_OPTIONS: '',
37
+ ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
38
+ },
39
+ stdio: [...stdio, 'ipc'],
40
+ // POSIX: the supervisor leads a new process group that the command joins.
41
+ // Termination signals the group and removes descendants visible outside
42
+ // it at each process-table snapshot. Windows has no process groups;
43
+ // taskkill /T owns the equivalent cleanup. There, detaching also keeps
44
+ // the supervisor out of the Host's libuv job, which would kill it with
45
+ // the Host before it could stop the command's descendants; the command
46
+ // itself stays in the supervisor's job.
47
+ detached: true,
48
+ windowsHide: true,
49
+ });
50
+ let started = false;
51
+ let completed = false;
52
+ let failureReported = false;
53
+ let resolveReady;
54
+ let rejectReady;
55
+ const ready = new Promise((resolve, reject) => {
56
+ resolveReady = resolve;
57
+ rejectReady = reject;
58
+ });
59
+ // Some callers observe ChildProcess errors instead of awaiting admission.
60
+ void ready.catch(() => { });
61
+ const timer = setTimeout(() => fail(new Error('Command supervisor startup timed out')), 10_000);
62
+ timer.unref();
63
+ const terminate = () => {
64
+ if (child.pid)
65
+ void terminateProcessTree({ pid: child.pid, signal: 'SIGKILL' }).catch(() => { });
66
+ };
67
+ // Once the supervisor is reaped its PID can be reused, so a tree walk from it
68
+ // could reach unrelated processes. Its POSIX process group cannot be
69
+ // reallocated while members remain, so it alone still names the command.
70
+ // Windows has no such handle; taskkill /T on a reaped PID is skipped.
71
+ const terminateOrphanedGroup = () => {
72
+ if (process.platform === 'win32' || !child.pid)
73
+ return;
74
+ try {
75
+ process.kill(-child.pid, 'SIGKILL');
76
+ }
77
+ catch {
78
+ // The group is already empty.
79
+ }
80
+ };
81
+ // Command output reaches these pipes directly, racing the IPC admission
82
+ // message. Hold it until callers have observed `ready`, so output never
83
+ // precedes admission, as with a direct spawn. Callers attach 'data'
84
+ // listeners synchronously; an explicit pause keeps those from resuming.
85
+ const outputs = [child.stdout, child.stderr].filter((stream) => stream !== null);
86
+ for (const stream of outputs)
87
+ stream.pause();
88
+ let outputReleased = false;
89
+ const releaseOutput = () => {
90
+ if (outputReleased)
91
+ return;
92
+ outputReleased = true;
93
+ for (const stream of outputs)
94
+ stream.resume();
95
+ };
96
+ /**
97
+ * `stop` says who stops the tree: `tree` walks it from the live supervisor;
98
+ * `group` signals only its process group, because the supervisor is exiting
99
+ * on its own and its PID may be reaped before a walk could start; `none`
100
+ * leaves the stop to the supervisor, which walks its own tree.
101
+ */
102
+ function fail(error, stop = 'tree') {
103
+ if (failureReported)
104
+ return;
105
+ failureReported = true;
106
+ clearTimeout(timer);
107
+ rejectReady(error);
108
+ releaseOutput();
109
+ if (stop === 'tree')
110
+ terminate();
111
+ else if (stop === 'group')
112
+ terminateOrphanedGroup();
113
+ child.emit('error', error);
114
+ }
115
+ child.once('error', (error) => {
116
+ clearTimeout(timer);
117
+ rejectReady(error);
118
+ releaseOutput();
119
+ });
120
+ child.once('spawn', () => {
121
+ const request = {
122
+ kind: 'launch',
123
+ program: input.program,
124
+ args: input.args,
125
+ cwd: input.cwd,
126
+ env: input.env ?? process.env,
127
+ shell: input.shell,
128
+ inheritedFds: (input.fdInputs ?? []).map(({ fd }) => fd),
129
+ };
130
+ child.send(request, (error) => {
131
+ if (error && child.exitCode === null && child.signalCode === null)
132
+ fail(error);
133
+ });
134
+ });
135
+ child.on('message', (value) => {
136
+ const message = value;
137
+ if (message.kind === 'started') {
138
+ started = true;
139
+ clearTimeout(timer);
140
+ resolveReady(message.pid);
141
+ // Promise continuations of `ready` run before the check phase; a
142
+ // nextTick resume would deliver output ahead of them.
143
+ setImmediate(releaseOutput);
144
+ }
145
+ else if (message.kind === 'completed') {
146
+ completed = true;
147
+ }
148
+ else if (message.kind === 'failed') {
149
+ // A result already arrived; a later report cannot replace it.
150
+ if (completed)
151
+ return;
152
+ const error = Object.assign(new Error(message.message), { code: message.code });
153
+ if (!started) {
154
+ // The command never spawned; the supervisor is exiting on its own.
155
+ completed = true;
156
+ fail(error, 'group');
157
+ }
158
+ else {
159
+ // A supervisor fault after admission: it stops its own tree, including
160
+ // descendants that left its group, before exiting. Signalling the group
161
+ // now would kill it mid-walk. The group kill on its exit stays the
162
+ // backstop, so `completed` remains unset.
163
+ fail(error, 'none');
164
+ }
165
+ }
166
+ });
167
+ child.once('exit', (code) => {
168
+ clearTimeout(timer);
169
+ releaseOutput();
170
+ // `exit` is not ordered after IPC messages still being read; the channel's
171
+ // close is. Decide a missing result only once the channel has drained.
172
+ if (completed || !child.connected)
173
+ settleExit(code);
174
+ else
175
+ child.once('disconnect', () => settleExit(code));
176
+ });
177
+ function settleExit(code) {
178
+ if (!started)
179
+ rejectReady(new Error('Command supervisor exited before admission'));
180
+ if (completed)
181
+ return;
182
+ // The group can outlive its leader. An unexpected supervisor exit must
183
+ // not leave an admitted command running while the Host is still alive.
184
+ terminateOrphanedGroup();
185
+ // A clean exit without `completed` lost the command's result. On POSIX the
186
+ // Host's own stops end the supervisor by signal, so any exit code there is
187
+ // a supervisor fault too. On Windows taskkill, the Host's forced stop, also
188
+ // ends with an ordinary code.
189
+ const lost = code === 0 || (code !== null && process.platform !== 'win32');
190
+ if (lost && !failureReported) {
191
+ child.emit('error', new Error('Command supervisor lost its result'));
192
+ }
193
+ }
194
+ return { child, ready };
195
+ }
196
+ /** A supervisor PID denotes the whole command group, including during startup. */
197
+ export function signalOwnedProcess(child, signal) {
198
+ if (process.platform === 'win32' || !child.pid)
199
+ return child.kill(signal);
200
+ try {
201
+ process.kill(-child.pid, signal);
202
+ return true;
203
+ }
204
+ catch (error) {
205
+ if (error.code === 'ESRCH')
206
+ return false;
207
+ throw error;
208
+ }
209
+ }
@@ -0,0 +1,183 @@
1
+ /*
2
+ * Licensed to the Apache Software Foundation (ASF) under one
3
+ * or more contributor license agreements. See the NOTICE file
4
+ * distributed with this work for additional information
5
+ * regarding copyright ownership. The ASF licenses this file
6
+ * to you under the Apache License, Version 2.0 (the
7
+ * "License"); you may not use this file except in compliance
8
+ * with the License. You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing,
13
+ * software distributed under the License is distributed on an
14
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15
+ * KIND, either express or implied. See the License for the
16
+ * specific language governing permissions and limitations
17
+ * under the License.
18
+ */
19
+ import { spawn } from 'node:child_process';
20
+ import { terminateProcessTree } from './process-tree-terminator.js';
21
+ let command;
22
+ let stopping = false;
23
+ let completed = false;
24
+ let ownerLost = false;
25
+ let finishing = false;
26
+ function loseOwner() {
27
+ if (completed || ownerLost)
28
+ return;
29
+ ownerLost = true;
30
+ stopping = true;
31
+ // This process is the POSIX group leader; the command joins its group. The
32
+ // Windows implementation uses taskkill /T. Discovery also includes descendants
33
+ // that have moved into another process group while still attached to this tree.
34
+ void terminateProcessTree({
35
+ pid: process.pid,
36
+ signal: 'SIGKILL',
37
+ fallback: () => process.exit(1),
38
+ }).then(() => process.exit(1), () => process.exit(1));
39
+ }
40
+ /** An unexpected supervisor error. Stopping the tree ends this process with
41
+ * its own group's SIGKILL, which the Host cannot tell from a forced stop, so
42
+ * report the fault first while the lease is still up. */
43
+ function fault(error) {
44
+ if (completed || ownerLost)
45
+ return;
46
+ // The command has exited and its result is already on the way; a fault
47
+ // now must not turn it into a failure. Only make sure this process ends.
48
+ if (finishing) {
49
+ setTimeout(() => process.exit(1), 1_000);
50
+ return;
51
+ }
52
+ if (!process.connected) {
53
+ loseOwner();
54
+ return;
55
+ }
56
+ // The tree stops whether or not the report goes through.
57
+ setTimeout(loseOwner, 1_000);
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ try {
60
+ process.send({ kind: 'failed', message: `Command supervisor failed: ${message}` }, () => loseOwner());
61
+ }
62
+ catch {
63
+ loseOwner();
64
+ }
65
+ }
66
+ process.on('disconnect', loseOwner);
67
+ process.on('uncaughtException', fault);
68
+ process.on('unhandledRejection', fault);
69
+ // The command shares this process group, so a group-wide signal its own script
70
+ // sends (`kill -HUP 0`) reaches the supervisor too. Ignore the ones whose
71
+ // default action would end it, or for SIGUSR1 start the inspector, so the
72
+ // command alone decides how to handle them. Windows cannot listen for these.
73
+ if (process.platform !== 'win32') {
74
+ for (const signal of ['SIGHUP', 'SIGUSR1', 'SIGUSR2', 'SIGQUIT', 'SIGALRM']) {
75
+ process.on(signal, () => { });
76
+ }
77
+ }
78
+ for (const signal of ['SIGTERM', 'SIGINT']) {
79
+ process.on(signal, () => {
80
+ stopping = true;
81
+ // On POSIX the Host signals our entire group, including the command. Do not
82
+ // forward the signal a second time. Before admission, stop without spawning.
83
+ if (!command)
84
+ finish(null, signal);
85
+ });
86
+ }
87
+ function send(message, then) {
88
+ if (!process.connected) {
89
+ loseOwner();
90
+ return;
91
+ }
92
+ process.send(message, (error) => {
93
+ if (error)
94
+ loseOwner();
95
+ else
96
+ then?.();
97
+ });
98
+ }
99
+ function finish(code, signal) {
100
+ finishing = true;
101
+ send({ kind: 'completed' }, () => {
102
+ completed = true;
103
+ if (signal)
104
+ exitWithSignal(signal);
105
+ else
106
+ process.exit(code ?? 1);
107
+ });
108
+ }
109
+ /** Mirror the command's terminating signal. Node ignores SIGPIPE and reserves
110
+ * SIGUSR1 for its inspector even without listeners; removing a listener resets
111
+ * the handler to the default action, so the re-raised signal ends this process.
112
+ */
113
+ function exitWithSignal(signal) {
114
+ process.removeAllListeners(signal);
115
+ try {
116
+ process.on(signal, () => { });
117
+ process.removeAllListeners(signal);
118
+ }
119
+ catch {
120
+ // SIGKILL and SIGSTOP accept no listener; their default action already applies.
121
+ }
122
+ process.kill(process.pid, signal);
123
+ // Never outlive the command if the signal did not end this process.
124
+ setTimeout(() => process.exit(1), 1_000);
125
+ }
126
+ if (!process.connected)
127
+ process.exit(1);
128
+ process.once('message', (message) => {
129
+ if (!process.connected) {
130
+ loseOwner();
131
+ return;
132
+ }
133
+ // A stop that arrived before the launch is already finishing.
134
+ if (stopping)
135
+ return;
136
+ const input = message;
137
+ if (input.kind !== 'launch') {
138
+ loseOwner();
139
+ return;
140
+ }
141
+ // A group signal can be pending in this loop turn alongside the launch.
142
+ // Spawn in the check phase, after its handler has run, so a stop that
143
+ // arrived first ends the supervisor without starting the command.
144
+ setImmediate(() => {
145
+ if (stopping)
146
+ return;
147
+ if (!process.connected) {
148
+ loseOwner();
149
+ return;
150
+ }
151
+ launch(input);
152
+ });
153
+ });
154
+ function launch(input) {
155
+ const stdio = [0, 1, 2];
156
+ for (const fd of input.inheritedFds) {
157
+ while (stdio.length <= fd)
158
+ stdio.push('ignore');
159
+ stdio[fd] = fd;
160
+ }
161
+ try {
162
+ command = spawn(input.program, [...input.args], {
163
+ cwd: input.cwd,
164
+ env: input.env,
165
+ shell: input.shell,
166
+ stdio,
167
+ detached: false,
168
+ windowsHide: true,
169
+ });
170
+ command.once('spawn', () => send({ kind: 'started', pid: command?.pid }));
171
+ command.once('error', failed);
172
+ command.once('exit', finish);
173
+ }
174
+ catch (error) {
175
+ failed(error);
176
+ }
177
+ }
178
+ function failed(error) {
179
+ send({ kind: 'failed', message: error.message, code: error.code }, () => {
180
+ completed = true;
181
+ process.exit(1);
182
+ });
183
+ }
@@ -16,11 +16,12 @@
16
16
  * specific language governing permissions and limitations
17
17
  * under the License.
18
18
  */
19
- import { spawn } from 'node:child_process';
20
- import { buildSpawnStdio, closeChildFdSources, writeChildFdInputs, } from './child-fd-input.js';
19
+ import { signalOwnedProcess, spawnOwnedProcess } from './owned-child-process.js';
20
+ import { closeChildFdSources, writeChildFdInputs } from './child-fd-input.js';
21
21
  import { trackCapturedOutputDrain, } from './child-process-lifecycle.js';
22
22
  export class PipeProcessDriver {
23
23
  options;
24
+ /** Supervisor PID; it leads the command's process group, so signals target it. */
24
25
  pid;
25
26
  ready;
26
27
  child;
@@ -34,16 +35,24 @@ export class PipeProcessDriver {
34
35
  rootExit;
35
36
  stdinSettled;
36
37
  stdinError;
38
+ admittedPid;
37
39
  constructor(options) {
38
40
  this.options = options;
39
41
  try {
40
- this.child = spawn(options.plan.file, options.plan.args, {
42
+ const spawned = spawnOwnedProcess({
43
+ program: options.plan.file,
44
+ args: options.plan.args,
41
45
  cwd: options.cwd,
42
46
  env: options.env,
43
47
  shell: options.plan.useShellOption,
44
- stdio: buildSpawnStdio(options.fdInputs, options.plan.stdin === undefined ? 'ignore' : 'pipe'),
45
- detached: process.platform !== 'win32',
48
+ stdin: options.plan.stdin === undefined ? 'ignore' : 'pipe',
49
+ fdInputs: options.fdInputs,
46
50
  });
51
+ this.child = spawned.child;
52
+ this.ready = spawned.ready.then((pid) => {
53
+ this.admittedPid = pid;
54
+ });
55
+ void this.ready.catch(() => { });
47
56
  }
48
57
  finally {
49
58
  closeChildFdSources(options.fdInputs);
@@ -78,7 +87,10 @@ export class PipeProcessDriver {
78
87
  this.child.on('exit', this.onRootExit);
79
88
  this.child.on('close', this.onCloseFallback);
80
89
  this.child.on('error', this.onError);
81
- this.ready = waitForSpawn(this.child);
90
+ }
91
+ /** The command's own PID, known once `ready` resolves; this is what users see. */
92
+ get commandPid() {
93
+ return this.admittedPid;
82
94
  }
83
95
  writeInputs() {
84
96
  writeChildFdInputs(this.child, this.options.fdInputs);
@@ -88,7 +100,7 @@ export class PipeProcessDriver {
88
100
  this.stdin.end(this.options.plan.stdin, this.onStdinEnd);
89
101
  }
90
102
  kill(signal) {
91
- return this.child.kill(signal);
103
+ return signalOwnedProcess(this.child, signal);
92
104
  }
93
105
  dispose() {
94
106
  if (this.disposed)
@@ -157,21 +169,3 @@ export class PipeProcessDriver {
157
169
  this.settleAfterDrain();
158
170
  };
159
171
  }
160
- function waitForSpawn(child) {
161
- return new Promise((resolve, reject) => {
162
- const onSpawn = () => {
163
- cleanup();
164
- resolve();
165
- };
166
- const onError = (error) => {
167
- cleanup();
168
- reject(error);
169
- };
170
- const cleanup = () => {
171
- child.off('spawn', onSpawn);
172
- child.off('error', onError);
173
- };
174
- child.once('spawn', onSpawn);
175
- child.once('error', onError);
176
- });
177
- }