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.
- package/README.md +1 -1
- package/README.zh-CN.md +3 -3
- package/dist/pi-tui-pickers.js +5 -2
- package/dist/pi-tui-runner.js +211 -33
- package/dist/runtime-host-session-driver.js +91 -13
- package/dist/session-catalog-limits.js +22 -0
- package/dist/session-driver.js +10 -0
- package/dist/tui-copy-catalog.js +15 -0
- package/native/runtime-host-windows-task-launcher/prebuilds/win32-x64/maka-runtime-host-task-launcher.exe +0 -0
- package/node_modules/@maka/core/dist/bot-chat-settings.js +3 -0
- package/node_modules/@maka/core/dist/bot-events.js +1 -8
- package/node_modules/@maka/core/dist/local-memory.js +38 -1
- package/node_modules/@maka/core/dist/scheduled-task.js +14 -15
- package/node_modules/@maka/core/dist/session.js +3 -0
- package/node_modules/@maka/core/dist/text-sanitize.js +8 -3
- package/node_modules/@maka/runtime/dist/agent-graph-supervisor-wake.js +115 -17
- package/node_modules/@maka/runtime/dist/agent-run.js +11 -0
- package/node_modules/@maka/runtime/dist/ai-sdk-backend.js +11 -0
- package/node_modules/@maka/runtime/dist/ai-sdk-turn.js +13 -5
- package/node_modules/@maka/runtime/dist/bots/proxied-fetch.js +29 -1
- package/node_modules/@maka/runtime/dist/computer-use-observation-text.js +22 -0
- package/node_modules/@maka/runtime/dist/conversation-copy-agent-output.js +227 -0
- package/node_modules/@maka/runtime/dist/conversation-copy.js +176 -46
- package/node_modules/@maka/runtime/dist/file-stable-write.js +15 -3
- package/node_modules/@maka/runtime/dist/filesystem-worker/resource-resolver.js +3 -1
- package/node_modules/@maka/runtime/dist/model-adapter.js +20 -1
- package/node_modules/@maka/runtime/dist/network/proxy-test.js +28 -1
- package/node_modules/@maka/runtime/dist/network/scoped-fetch-transport.js +13 -2
- package/node_modules/@maka/runtime/dist/owned-child-process.js +209 -0
- package/node_modules/@maka/runtime/dist/owned-process-main.js +183 -0
- package/node_modules/@maka/runtime/dist/pipe-process-driver.js +19 -25
- package/node_modules/@maka/runtime/dist/runtime-kernel.js +216 -8
- package/node_modules/@maka/runtime/dist/sandbox-boundary-tool.js +12 -0
- package/node_modules/@maka/runtime/dist/session-manager.js +156 -17
- package/node_modules/@maka/runtime/dist/shell-exec.js +18 -20
- package/node_modules/@maka/runtime/dist/shell-run-manager.js +2 -1
- package/node_modules/@maka/runtime/dist/stream-graph-coordinator.js +279 -8
- package/node_modules/@maka/runtime/dist/stream-graph-schedule-reconcile.js +209 -37
- package/node_modules/@maka/runtime/dist/tool-availability.js +55 -18
- package/node_modules/@maka/runtime/dist/tool-call-snapshot.js +142 -0
- package/node_modules/@maka/runtime/dist/tool-runtime.js +27 -84
- package/node_modules/@maka/runtime/dist/workers/{filesystem-worker.js → filesystem-worker.mjs} +12 -1
- package/node_modules/@maka/runtime-host/dist/client/connect-or-spawn.js +30 -6
- package/node_modules/@maka/runtime-host/dist/client/index.js +1 -1
- package/node_modules/@maka/runtime-host/dist/client/reconnect-lifecycle.js +44 -13
- package/node_modules/@maka/runtime-host/dist/client/session-catalog-summary.js +6 -0
- package/node_modules/@maka/runtime-host/dist/client/wsl-environment.js +4 -0
- package/node_modules/@maka/runtime-host/dist/protocol/index.js +5 -1
- package/node_modules/@maka/runtime-host/dist/protocol/message.js +1 -13
- package/node_modules/@maka/runtime-host/dist/protocol/session-catalog.js +46 -9
- package/node_modules/@maka/runtime-host/dist/protocol/session-continuity.js +1 -13
- package/node_modules/@maka/runtime-host/dist/protocol/turn.js +1 -13
- package/node_modules/@maka/runtime-host/dist/protocol/workhub-coordination.js +4 -7
- package/node_modules/@maka/runtime-host/dist/server/archive-retention-coordinator.js +81 -105
- package/node_modules/@maka/runtime-host/dist/server/candidate.js +47 -40
- package/node_modules/@maka/runtime-host/dist/server/execution-composition.js +87 -67
- package/node_modules/@maka/runtime-host/dist/server/execution-service.js +5 -1
- package/node_modules/@maka/runtime-host/dist/server/host-maintenance-guards.js +95 -0
- package/node_modules/@maka/runtime-host/dist/server/hosted-execution-tool-profile.js +18 -8
- package/node_modules/@maka/runtime-host/dist/server/interactive-run-composer.js +32 -3
- package/node_modules/@maka/runtime-host/dist/server/project-catalog-coordinator.js +2 -0
- package/node_modules/@maka/runtime-host/dist/server/root-turn-coordinator.js +109 -8
- package/node_modules/@maka/runtime-host/dist/server/scheduled-task-coordinator.js +20 -3
- package/node_modules/@maka/runtime-host/dist/server/session-background-activity.js +71 -0
- package/node_modules/@maka/runtime-host/dist/server/session-catalog-coordinator.js +8 -4
- package/node_modules/@maka/runtime-host/dist/server/session-interaction-activity.js +78 -0
- package/node_modules/@maka/runtime-host/dist/server/session-retirement-coordinator.js +56 -0
- package/node_modules/@maka/runtime-host/dist/server/session-revision-graph-references.js +16 -2
- package/node_modules/@maka/runtime-host/dist/server/skill-catalog-coordinator.js +2 -0
- package/node_modules/@maka/runtime-host/dist/server/workhub-coordination-action-gate.js +19 -24
- package/node_modules/@maka/runtime-host/dist/server/workhub-coordination-coordinator.js +74 -19
- package/node_modules/@maka/runtime-host/dist/server/workhub-result-coordinator.js +1 -1
- package/node_modules/@maka/runtime-host/dist/server/workhub-result-runtime.js +6 -99
- package/node_modules/@maka/runtime-host/dist/server/workhub-target-execution-authority.js +14 -126
- package/node_modules/@maka/storage/dist/context-offload-store.js +8 -3
- package/node_modules/@maka/storage/dist/git-entry.js +142 -5
- package/node_modules/@maka/storage/dist/session-bundle-policy.js +54 -19
- 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
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
20
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|