@akira-tl/forgerelay 0.4.6 → 0.4.7
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/CHANGELOG.md +18 -0
- package/README.md +8 -7
- package/dist/hooks.js +43 -14
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/process-sessions.js +119 -14
- package/dist/server.js +165 -103
- package/docs/chatgpt-coding-workflow.md +10 -5
- package/docs/configuration.md +24 -15
- package/docs/roadmap.md +4 -1
- package/docs/versioning.md +9 -7
- package/package.json +3 -3
- package/scripts/debug/accept.mjs +1 -0
- package/scripts/release-proof.mjs +139 -0
- package/scripts/release-proof.test.mjs +104 -0
- package/scripts/release-version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.4.7] - 2026-08-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added independent `bash` execution deadlines with optional `timeoutMs`; `yieldTimeMs` now remains purely a feedback window, including `yieldTimeMs: 0` for immediate background handoff to a canonical `processId`.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Regular `bash` now defaults to a 10-second feedback window instead of occupying a full 300-second Host request; Agent-selected waits can still be up to 300 seconds, while execution can continue without a ForgeRelay deadline when `timeoutMs` is omitted.
|
|
16
|
+
- Completed background processes keep full buffered output for five minutes, then compact to a bounded completion record deliverable for up to 24 hours. Completed results no longer block workspace close and are delivered with the close response when available.
|
|
17
|
+
- Local `release:verify` now records a proof for the committed release HEAD, while the stable-tag `BeforeTool` Hook performs only a fast proof/HEAD/version/tag check before the push instead of rerunning the multi-minute release gate inside one MCP request.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Propagated Host request cancellation through lifecycle Hooks and shell process waits. If a Host cancels while a blocking `BeforeTool` Hook is still running, ForgeRelay terminates the Hook and does not execute the original tool side effect; cancellation of an initial shell run also terminates a process whose `processId` has not yet been delivered.
|
|
22
|
+
- Corrected `ProcessManager` yield bounding so a configured maximum also caps the default feedback window rather than only explicit `yieldTimeMs` values.
|
|
23
|
+
- Made the new release-proof and Hook-cancellation test harnesses use cross-platform Node path and shell invocation forms, covering Windows drive-letter paths and `cmd.exe` Hook execution as well as POSIX hosts.
|
|
24
|
+
|
|
7
25
|
## [0.4.6] - 2026-08-11
|
|
8
26
|
|
|
9
27
|
### Added
|
package/README.md
CHANGED
|
@@ -163,13 +163,13 @@ Hook 是 ForgeRelay 的自动生命周期规则。首选方式是一个 Hook 一
|
|
|
163
163
|
"tool": "bash",
|
|
164
164
|
"commandRegex": "git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+"
|
|
165
165
|
},
|
|
166
|
-
"command": "
|
|
167
|
-
"timeoutSeconds":
|
|
166
|
+
"command": "node scripts/release-proof.mjs check-hook",
|
|
167
|
+
"timeoutSeconds": 30,
|
|
168
168
|
"report": true
|
|
169
169
|
}
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
耗时的 `npm run release:verify` 应先在已提交的 release-ready HEAD 上运行,它会写入绑定 HEAD/package version 的本地 release proof。命中 `BeforeTool` 后,Hook 只快速验证该 proof、clean working tree(含 untracked)与 tag 指向;成功才继续原始 `git push`,失败则直接阻断。这样发布 gate 不依赖一个持续数分钟的 MCP 请求。Hook 结果会回到 Agent,Agent 应向用户说明重要 Hook 是否通过或阻断了操作。`report:false` 可以隐藏不重要的成功报告,但阻断失败始终可见。
|
|
173
173
|
|
|
174
174
|
旧的 inline `hooks` 和聚合 `hooks.json` 仍兼容;新配置建议都用独立 `hooks/*.json` 文件。
|
|
175
175
|
|
|
@@ -292,10 +292,11 @@ npm run release:major
|
|
|
292
292
|
npm run release:verify
|
|
293
293
|
```
|
|
294
294
|
|
|
295
|
-
Daily branch pushes do not run cloud CI. When preparing a release,
|
|
296
|
-
|
|
297
|
-
in an isolated Node 22.19.0 environment
|
|
298
|
-
CI runtime for native addons and high-risk
|
|
295
|
+
Daily branch pushes do not run cloud CI. When preparing a release, commit the
|
|
296
|
+
release-ready tree and run the full local release verification on that clean HEAD.
|
|
297
|
+
`release:verify` includes a focused parity pass in an isolated Node 22.19.0 environment
|
|
298
|
+
with its own `npm ci`, matching the cloud CI runtime for native addons and high-risk
|
|
299
|
+
LSP lifecycle tests, then writes the local proof consumed by the tag-push Hook. Pushing a matching
|
|
299
300
|
`vX.Y.Z` tag to `Akira-TL/forgerelay` is the only cloud CI and publish trigger:
|
|
300
301
|
GitHub Actions runs the reusable multi-platform CI, then publishes
|
|
301
302
|
`@akira-tl/forgerelay` and creates the matching GitHub Release only after CI
|
package/dist/hooks.js
CHANGED
|
@@ -105,7 +105,8 @@ export async function runToolWithHooks(runner, options) {
|
|
|
105
105
|
executions.push(...await runner.run("BeforeTool", {
|
|
106
106
|
...options.invocation,
|
|
107
107
|
payload: basePayload,
|
|
108
|
-
}));
|
|
108
|
+
}, options.signal));
|
|
109
|
+
options.signal?.throwIfAborted();
|
|
109
110
|
const result = await options.operation();
|
|
110
111
|
const afterCwd = options.afterCwd?.(result);
|
|
111
112
|
if (options.isFailure?.(result)) {
|
|
@@ -113,7 +114,7 @@ export async function runToolWithHooks(runner, options) {
|
|
|
113
114
|
...options.invocation,
|
|
114
115
|
cwd: afterCwd,
|
|
115
116
|
payload: basePayload,
|
|
116
|
-
}));
|
|
117
|
+
}, options.signal));
|
|
117
118
|
const reported = attachHookReports(result, executions);
|
|
118
119
|
return decorateToolResult(runner, options.invocation.workspaceId, reported);
|
|
119
120
|
}
|
|
@@ -121,14 +122,14 @@ export async function runToolWithHooks(runner, options) {
|
|
|
121
122
|
...options.invocation,
|
|
122
123
|
cwd: afterCwd,
|
|
123
124
|
payload: basePayload,
|
|
124
|
-
}));
|
|
125
|
+
}, options.signal));
|
|
125
126
|
const changedPaths = options.changedPaths?.(result) ?? [];
|
|
126
127
|
if (changedPaths.length > 0) {
|
|
127
128
|
executions.push(...await runner.run("AfterFileChange", {
|
|
128
129
|
...options.invocation,
|
|
129
130
|
cwd: afterCwd,
|
|
130
131
|
payload: { ...basePayload, paths: changedPaths },
|
|
131
|
-
}));
|
|
132
|
+
}, options.signal));
|
|
132
133
|
}
|
|
133
134
|
const reported = attachHookReports(result, executions);
|
|
134
135
|
return decorateToolResult(runner, options.invocation.workspaceId, reported);
|
|
@@ -137,13 +138,15 @@ export async function runToolWithHooks(runner, options) {
|
|
|
137
138
|
if (error instanceof HookExecutionError) {
|
|
138
139
|
executions.push(...error.executions);
|
|
139
140
|
}
|
|
140
|
-
|
|
141
|
-
...
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
if (!options.signal?.aborted) {
|
|
142
|
+
executions.push(...await runner.run("AfterToolFailure", {
|
|
143
|
+
...options.invocation,
|
|
144
|
+
payload: {
|
|
145
|
+
...basePayload,
|
|
146
|
+
errorType: error instanceof Error ? error.name : "Error",
|
|
147
|
+
},
|
|
148
|
+
}, options.signal));
|
|
149
|
+
}
|
|
147
150
|
const reportedError = appendHookReportsToError(error, executions);
|
|
148
151
|
throw decorateToolResult(runner, options.invocation.workspaceId, reportedError);
|
|
149
152
|
}
|
|
@@ -219,7 +222,8 @@ export class HookRunner {
|
|
|
219
222
|
decorateResult(workspaceId, result) {
|
|
220
223
|
return (this.resultDecorator?.(workspaceId, result) ?? result);
|
|
221
224
|
}
|
|
222
|
-
async run(event, invocation) {
|
|
225
|
+
async run(event, invocation, signal) {
|
|
226
|
+
signal?.throwIfAborted();
|
|
223
227
|
const projectRoot = event === "AfterWorktreeClose" && invocation.sourceRoot
|
|
224
228
|
? invocation.sourceRoot
|
|
225
229
|
: invocation.workspaceRoot;
|
|
@@ -246,7 +250,8 @@ export class HookRunner {
|
|
|
246
250
|
}]
|
|
247
251
|
: [];
|
|
248
252
|
for (const [index, { scope, handler, invocation: matchedInvocation }] of handlers.entries()) {
|
|
249
|
-
|
|
253
|
+
signal?.throwIfAborted();
|
|
254
|
+
const execution = await this.runHandler(event, handler, index, matchedInvocation, scope, signal);
|
|
250
255
|
executions.push(execution);
|
|
251
256
|
logEvent(this.logging, execution.status === "passed" ? "info" : "warn", "hook_call", {
|
|
252
257
|
hookEvent: event,
|
|
@@ -267,7 +272,7 @@ export class HookRunner {
|
|
|
267
272
|
}
|
|
268
273
|
return executions;
|
|
269
274
|
}
|
|
270
|
-
async runHandler(event, handler, index, invocation, scope) {
|
|
275
|
+
async runHandler(event, handler, index, invocation, scope, signal) {
|
|
271
276
|
const startedAt = performance.now();
|
|
272
277
|
const name = handler.name ?? `${event} handler ${index + 1}`;
|
|
273
278
|
const shell = resolveShellCommand(handler.command, process.platform, this.baseEnv);
|
|
@@ -282,6 +287,7 @@ export class HookRunner {
|
|
|
282
287
|
env,
|
|
283
288
|
timeoutMs: handler.timeoutSeconds * 1_000,
|
|
284
289
|
detached,
|
|
290
|
+
signal,
|
|
285
291
|
});
|
|
286
292
|
const durationMs = Math.round(performance.now() - startedAt);
|
|
287
293
|
if (result.exitCode === 0 && !result.timedOut) {
|
|
@@ -311,6 +317,8 @@ export class HookRunner {
|
|
|
311
317
|
};
|
|
312
318
|
}
|
|
313
319
|
catch (error) {
|
|
320
|
+
if (signal?.aborted)
|
|
321
|
+
throw error;
|
|
314
322
|
return {
|
|
315
323
|
event,
|
|
316
324
|
name,
|
|
@@ -522,6 +530,7 @@ function hookEnvironment(baseEnv, event, invocation) {
|
|
|
522
530
|
};
|
|
523
531
|
}
|
|
524
532
|
function executeHookCommand(input) {
|
|
533
|
+
input.signal?.throwIfAborted();
|
|
525
534
|
return new Promise((resolve, reject) => {
|
|
526
535
|
const child = spawn(input.executable, input.args, {
|
|
527
536
|
cwd: input.cwd,
|
|
@@ -535,6 +544,18 @@ function executeHookCommand(input) {
|
|
|
535
544
|
let stderr = "";
|
|
536
545
|
let timedOut = false;
|
|
537
546
|
let forceKillTimer;
|
|
547
|
+
let aborted = false;
|
|
548
|
+
const abort = () => {
|
|
549
|
+
if (aborted)
|
|
550
|
+
return;
|
|
551
|
+
aborted = true;
|
|
552
|
+
terminateProcessTree(child, "SIGTERM", input.detached);
|
|
553
|
+
forceKillTimer = setTimeout(() => {
|
|
554
|
+
terminateProcessTree(child, "SIGKILL", input.detached);
|
|
555
|
+
}, 500);
|
|
556
|
+
forceKillTimer.unref();
|
|
557
|
+
};
|
|
558
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
538
559
|
child.stdout?.on("data", (chunk) => {
|
|
539
560
|
stdout = appendCaptured(stdout, chunk);
|
|
540
561
|
});
|
|
@@ -554,12 +575,20 @@ function executeHookCommand(input) {
|
|
|
554
575
|
clearTimeout(timeout);
|
|
555
576
|
if (forceKillTimer)
|
|
556
577
|
clearTimeout(forceKillTimer);
|
|
578
|
+
input.signal?.removeEventListener("abort", abort);
|
|
557
579
|
reject(error);
|
|
558
580
|
});
|
|
559
581
|
child.once("close", (exitCode, signal) => {
|
|
560
582
|
clearTimeout(timeout);
|
|
561
583
|
if (forceKillTimer)
|
|
562
584
|
clearTimeout(forceKillTimer);
|
|
585
|
+
input.signal?.removeEventListener("abort", abort);
|
|
586
|
+
if (aborted) {
|
|
587
|
+
reject(input.signal?.reason instanceof Error
|
|
588
|
+
? input.signal.reason
|
|
589
|
+
: Object.assign(new Error("Hook execution cancelled by Host."), { name: "AbortError" }));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
563
592
|
resolve({ exitCode, signal, stdout, stderr, timedOut });
|
|
564
593
|
});
|
|
565
594
|
});
|
|
@@ -30,7 +30,7 @@ export function buildToolDescriptions(config) {
|
|
|
30
30
|
rename: `Rename or move one file or directory inside an open workspace or the OS temp directory without overwriting an existing destination. Source and destination must both remain inside the permitted file roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
31
31
|
delete: `Delete one file or directory inside an open workspace or the OS temp directory. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
32
32
|
applyPatch: `Apply one Codex-style patch inside an open workspace or the OS temp directory. Supports adding, overwriting, updating, deleting, and moving files. Workspace paths must remain relative; absolute paths are accepted only inside the OS temp directory. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
33
|
-
shell: `Run or manage a shell process inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace
|
|
33
|
+
shell: `Run or manage a shell process inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace containment does not make shell execution a sandbox. For action=run, yieldTimeMs is only the feedback wait (default 10000ms; 0 returns a processId immediately) and optional timeoutMs is the independent total execution limit. action=process polls/waits for incremental output, writes input, resizes a PTY, or interrupts by processId. Keep explicit waits below the Host request deadline; use 60000ms only when supported. Completed background results may be attached to a later result for the same workspaceId. Call ${toolNames.openWorkspace} first and pass workspaceId. Expose this capability only behind strong authentication.`,
|
|
34
34
|
shellCommand: "Shell command to run with the local user's authority.",
|
|
35
35
|
};
|
|
36
36
|
}
|
package/dist/process-sessions.js
CHANGED
|
@@ -6,21 +6,33 @@ const DEFAULT_POLL_YIELD_MS = 5_000;
|
|
|
6
6
|
const MAX_START_YIELD_MS = 300_000;
|
|
7
7
|
const MAX_COMMAND_YIELD_MS = 300_000;
|
|
8
8
|
const MAX_POLL_YIELD_MS = 300_000;
|
|
9
|
+
const MAX_EXECUTION_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
|
|
9
10
|
const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
|
|
10
11
|
const DEFAULT_BUFFER_CHARACTERS = 256_000;
|
|
11
12
|
const DEFAULT_MAX_ACTIVE_PROCESSES = 64;
|
|
12
13
|
const DEFAULT_MAX_COMPLETED_PROCESSES = 128;
|
|
13
14
|
const COMPLETED_PROCESS_TTL_MS = 5 * 60 * 1_000;
|
|
15
|
+
const COMPACT_COMPLETION_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
16
|
+
const COMPACT_COMPLETION_CHARACTERS = 16 * 1024;
|
|
17
|
+
const COMPACT_COMPLETION_OUTPUT_TOKENS = COMPACT_COMPLETION_CHARACTERS / 4;
|
|
14
18
|
const DEFAULT_COLUMNS = 80;
|
|
15
19
|
const DEFAULT_ROWS = 24;
|
|
16
20
|
function boundedInteger(value, fallback, maximum) {
|
|
17
21
|
if (value === undefined)
|
|
18
|
-
return fallback;
|
|
22
|
+
return Math.min(fallback, maximum);
|
|
19
23
|
if (!Number.isFinite(value) || value < 0) {
|
|
20
24
|
throw new Error("Duration and output limits must be non-negative.");
|
|
21
25
|
}
|
|
22
26
|
return Math.min(Math.floor(value), maximum);
|
|
23
27
|
}
|
|
28
|
+
function optionalExecutionTimeout(value) {
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_EXECUTION_TIMEOUT_MS) {
|
|
32
|
+
throw new Error(`Execution timeout must be an integer between 1 and ${MAX_EXECUTION_TIMEOUT_MS}ms.`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
24
36
|
function terminalSize(value, fallback) {
|
|
25
37
|
if (value === undefined)
|
|
26
38
|
return fallback;
|
|
@@ -215,9 +227,11 @@ export class ProcessManager {
|
|
|
215
227
|
this.monotonicNow = options.monotonicNow ?? (() => performance.now());
|
|
216
228
|
}
|
|
217
229
|
async start(input) {
|
|
230
|
+
input.signal?.throwIfAborted();
|
|
218
231
|
if (this.stats().running >= this.maxActiveProcesses) {
|
|
219
232
|
throw new Error(`Active process limit reached (${this.maxActiveProcesses}). Poll, interrupt, or wait for an existing process before starting another.`);
|
|
220
233
|
}
|
|
234
|
+
const executionTimeoutMs = optionalExecutionTimeout(input.timeoutMs);
|
|
221
235
|
const processEntry = this.createProcess(input);
|
|
222
236
|
this.processes.set(processEntry.id, processEntry);
|
|
223
237
|
try {
|
|
@@ -230,8 +244,19 @@ export class ProcessManager {
|
|
|
230
244
|
this.processes.delete(processEntry.id);
|
|
231
245
|
throw error;
|
|
232
246
|
}
|
|
247
|
+
this.armExecutionTimeout(processEntry, executionTimeoutMs);
|
|
233
248
|
const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS, this.maxStartYieldMs);
|
|
234
|
-
|
|
249
|
+
try {
|
|
250
|
+
await this.waitForExit(processEntry, yieldTimeMs, input.signal);
|
|
251
|
+
input.signal?.throwIfAborted();
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (input.signal?.aborted) {
|
|
255
|
+
processEntry.discardOnFinish = true;
|
|
256
|
+
this.stopProcess(processEntry, "SIGTERM");
|
|
257
|
+
}
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
235
260
|
if (processEntry.running)
|
|
236
261
|
processEntry.background = true;
|
|
237
262
|
const snapshot = this.consume(processEntry, input.maxOutputTokens);
|
|
@@ -263,7 +288,7 @@ export class ProcessManager {
|
|
|
263
288
|
const fallback = interactionRequested ? DEFAULT_INTERACTIVE_YIELD_MS : DEFAULT_POLL_YIELD_MS;
|
|
264
289
|
const maximum = interactionRequested ? MAX_COMMAND_YIELD_MS : MAX_POLL_YIELD_MS;
|
|
265
290
|
const yieldTimeMs = boundedInteger(input.yieldTimeMs, fallback, maximum);
|
|
266
|
-
await this.waitForExit(processEntry, yieldTimeMs);
|
|
291
|
+
await this.waitForExit(processEntry, yieldTimeMs, input.signal);
|
|
267
292
|
}
|
|
268
293
|
const snapshot = this.consume(processEntry, input.maxOutputTokens);
|
|
269
294
|
if (!processEntry.running)
|
|
@@ -271,7 +296,9 @@ export class ProcessManager {
|
|
|
271
296
|
return snapshot;
|
|
272
297
|
}
|
|
273
298
|
activeWorkspaceIds() {
|
|
274
|
-
return new Set([...this.processes.values()]
|
|
299
|
+
return new Set([...this.processes.values()]
|
|
300
|
+
.filter((processEntry) => processEntry.running)
|
|
301
|
+
.map((processEntry) => processEntry.workspaceId));
|
|
275
302
|
}
|
|
276
303
|
stats() {
|
|
277
304
|
let running = 0;
|
|
@@ -311,10 +338,17 @@ export class ProcessManager {
|
|
|
311
338
|
if (processEntry.running)
|
|
312
339
|
processEntry.process?.kill("SIGTERM");
|
|
313
340
|
}
|
|
341
|
+
discardUndelivered(workspaceId, processId) {
|
|
342
|
+
const processEntry = this.getOwnedProcess(workspaceId, processId);
|
|
343
|
+
processEntry.discardOnFinish = true;
|
|
344
|
+
if (processEntry.running)
|
|
345
|
+
this.stopProcess(processEntry, "SIGTERM");
|
|
346
|
+
else
|
|
347
|
+
this.removeProcess(processEntry.id);
|
|
348
|
+
}
|
|
314
349
|
shutdown() {
|
|
315
350
|
for (const processEntry of this.processes.values()) {
|
|
316
|
-
|
|
317
|
-
clearTimeout(processEntry.cleanupTimer);
|
|
351
|
+
this.clearProcessTimers(processEntry);
|
|
318
352
|
if (processEntry.running)
|
|
319
353
|
processEntry.process?.kill("SIGTERM");
|
|
320
354
|
}
|
|
@@ -322,19 +356,32 @@ export class ProcessManager {
|
|
|
322
356
|
this.completedByWorkspace.clear();
|
|
323
357
|
this.completedProcessIds.length = 0;
|
|
324
358
|
}
|
|
325
|
-
async waitForExit(processEntry, yieldTimeMs) {
|
|
359
|
+
async waitForExit(processEntry, yieldTimeMs, signal) {
|
|
360
|
+
signal?.throwIfAborted();
|
|
326
361
|
let timer;
|
|
362
|
+
let abortListener;
|
|
327
363
|
try {
|
|
328
|
-
|
|
364
|
+
const waits = [
|
|
329
365
|
processEntry.exitPromise,
|
|
330
366
|
new Promise((resolve) => {
|
|
331
367
|
timer = setTimeout(resolve, yieldTimeMs);
|
|
332
368
|
}),
|
|
333
|
-
]
|
|
369
|
+
];
|
|
370
|
+
if (signal) {
|
|
371
|
+
waits.push(new Promise((_resolve, reject) => {
|
|
372
|
+
abortListener = () => reject(signal.reason instanceof Error
|
|
373
|
+
? signal.reason
|
|
374
|
+
: Object.assign(new Error("Process wait cancelled by Host."), { name: "AbortError" }));
|
|
375
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
await Promise.race(waits);
|
|
334
379
|
}
|
|
335
380
|
finally {
|
|
336
381
|
if (timer)
|
|
337
382
|
clearTimeout(timer);
|
|
383
|
+
if (signal && abortListener)
|
|
384
|
+
signal.removeEventListener("abort", abortListener);
|
|
338
385
|
}
|
|
339
386
|
}
|
|
340
387
|
createProcess(input) {
|
|
@@ -351,7 +398,10 @@ export class ProcessManager {
|
|
|
351
398
|
rows: terminalSize(input.rows, DEFAULT_ROWS),
|
|
352
399
|
buffer: new HeadTailBuffer(this.maxBufferCharacters),
|
|
353
400
|
running: true,
|
|
401
|
+
timedOut: false,
|
|
402
|
+
outputWasTruncated: false,
|
|
354
403
|
background: false,
|
|
404
|
+
discardOnFinish: false,
|
|
355
405
|
exitPromise,
|
|
356
406
|
resolveExit,
|
|
357
407
|
};
|
|
@@ -423,9 +473,18 @@ export class ProcessManager {
|
|
|
423
473
|
processEntry.running = false;
|
|
424
474
|
processEntry.exitCode = exitCode;
|
|
425
475
|
processEntry.signal = signal;
|
|
476
|
+
processEntry.finishedAtMonotonic = this.monotonicNow();
|
|
426
477
|
processEntry.process = undefined;
|
|
478
|
+
if (processEntry.executionTimeoutTimer)
|
|
479
|
+
clearTimeout(processEntry.executionTimeoutTimer);
|
|
480
|
+
if (processEntry.forceKillTimer)
|
|
481
|
+
clearTimeout(processEntry.forceKillTimer);
|
|
427
482
|
processEntry.resolveExit();
|
|
428
|
-
|
|
483
|
+
if (processEntry.discardOnFinish) {
|
|
484
|
+
this.removeProcess(processEntry.id);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
processEntry.cleanupTimer = setTimeout(() => this.compactCompletedProcess(processEntry), this.completedProcessTtlMs);
|
|
429
488
|
processEntry.cleanupTimer.unref();
|
|
430
489
|
if (processEntry.background) {
|
|
431
490
|
const completed = this.completedByWorkspace.get(processEntry.workspaceId) ?? [];
|
|
@@ -442,6 +501,17 @@ export class ProcessManager {
|
|
|
442
501
|
}
|
|
443
502
|
}
|
|
444
503
|
}
|
|
504
|
+
compactCompletedProcess(processEntry) {
|
|
505
|
+
if (processEntry.running || !this.processes.has(processEntry.id))
|
|
506
|
+
return;
|
|
507
|
+
const compacted = this.consume(processEntry, COMPACT_COMPLETION_OUTPUT_TOKENS);
|
|
508
|
+
processEntry.buffer = new HeadTailBuffer(COMPACT_COMPLETION_CHARACTERS);
|
|
509
|
+
processEntry.buffer.append(compacted.output);
|
|
510
|
+
processEntry.outputWasTruncated = compacted.outputTruncated;
|
|
511
|
+
const remainingMs = Math.max(1, COMPACT_COMPLETION_TTL_MS - this.completedProcessTtlMs);
|
|
512
|
+
processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), remainingMs);
|
|
513
|
+
processEntry.cleanupTimer.unref();
|
|
514
|
+
}
|
|
445
515
|
append(processEntry, output) {
|
|
446
516
|
processEntry.buffer.append(output);
|
|
447
517
|
}
|
|
@@ -449,18 +519,53 @@ export class ProcessManager {
|
|
|
449
519
|
const limit = boundedInteger(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000);
|
|
450
520
|
const maxCharacters = Math.max(256, limit * 4);
|
|
451
521
|
const buffered = processEntry.buffer.drain(maxCharacters);
|
|
522
|
+
if (buffered.truncated)
|
|
523
|
+
processEntry.outputWasTruncated = true;
|
|
452
524
|
const processId = processEntry.running ? processEntry.id : undefined;
|
|
525
|
+
const endedAt = processEntry.finishedAtMonotonic ?? this.monotonicNow();
|
|
453
526
|
return {
|
|
454
527
|
processId,
|
|
455
528
|
sessionId: processId,
|
|
456
529
|
output: buffered.output,
|
|
457
|
-
outputTruncated:
|
|
530
|
+
outputTruncated: processEntry.outputWasTruncated,
|
|
458
531
|
running: processEntry.running,
|
|
459
532
|
exitCode: processEntry.exitCode,
|
|
460
533
|
signal: processEntry.signal,
|
|
461
|
-
|
|
534
|
+
timedOut: processEntry.timedOut,
|
|
535
|
+
wallTimeMs: Math.max(0, Math.round(endedAt - processEntry.startedAtMonotonic)),
|
|
462
536
|
};
|
|
463
537
|
}
|
|
538
|
+
armExecutionTimeout(processEntry, timeoutMs) {
|
|
539
|
+
if (timeoutMs === undefined)
|
|
540
|
+
return;
|
|
541
|
+
processEntry.executionTimeoutTimer = setTimeout(() => {
|
|
542
|
+
if (!processEntry.running)
|
|
543
|
+
return;
|
|
544
|
+
processEntry.timedOut = true;
|
|
545
|
+
this.stopProcess(processEntry, "SIGTERM");
|
|
546
|
+
}, timeoutMs);
|
|
547
|
+
processEntry.executionTimeoutTimer.unref();
|
|
548
|
+
}
|
|
549
|
+
stopProcess(processEntry, signal) {
|
|
550
|
+
if (!processEntry.running)
|
|
551
|
+
return;
|
|
552
|
+
processEntry.process?.kill(signal);
|
|
553
|
+
if (processEntry.forceKillTimer)
|
|
554
|
+
clearTimeout(processEntry.forceKillTimer);
|
|
555
|
+
processEntry.forceKillTimer = setTimeout(() => {
|
|
556
|
+
if (processEntry.running)
|
|
557
|
+
processEntry.process?.kill("SIGKILL");
|
|
558
|
+
}, 500);
|
|
559
|
+
processEntry.forceKillTimer.unref();
|
|
560
|
+
}
|
|
561
|
+
clearProcessTimers(processEntry) {
|
|
562
|
+
if (processEntry.cleanupTimer)
|
|
563
|
+
clearTimeout(processEntry.cleanupTimer);
|
|
564
|
+
if (processEntry.executionTimeoutTimer)
|
|
565
|
+
clearTimeout(processEntry.executionTimeoutTimer);
|
|
566
|
+
if (processEntry.forceKillTimer)
|
|
567
|
+
clearTimeout(processEntry.forceKillTimer);
|
|
568
|
+
}
|
|
464
569
|
getOwnedProcess(workspaceId, processId) {
|
|
465
570
|
const processEntry = this.processes.get(processId);
|
|
466
571
|
if (!processEntry)
|
|
@@ -472,8 +577,8 @@ export class ProcessManager {
|
|
|
472
577
|
}
|
|
473
578
|
removeProcess(processId) {
|
|
474
579
|
const processEntry = this.processes.get(processId);
|
|
475
|
-
if (processEntry
|
|
476
|
-
|
|
580
|
+
if (processEntry)
|
|
581
|
+
this.clearProcessTimers(processEntry);
|
|
477
582
|
this.processes.delete(processId);
|
|
478
583
|
const completedIndex = this.completedProcessIds.indexOf(processId);
|
|
479
584
|
if (completedIndex >= 0)
|