clawspec 1.0.2 → 1.0.4
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/package.json +5 -2
- package/src/acp/client.ts +171 -5
- package/src/dependencies/acpx.ts +76 -2
- package/test/acp-client.test.ts +78 -3
- package/test/acpx-dependency.test.ts +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawspec",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw plugin that orchestrates OpenSpec project workflows with visible main-agent execution.",
|
|
6
6
|
"keywords": [
|
|
@@ -32,7 +32,10 @@
|
|
|
32
32
|
"openclaw": {
|
|
33
33
|
"extensions": [
|
|
34
34
|
"./index.ts"
|
|
35
|
-
]
|
|
35
|
+
],
|
|
36
|
+
"compat": {
|
|
37
|
+
"pluginApi": ">=2026.3.24"
|
|
38
|
+
}
|
|
36
39
|
},
|
|
37
40
|
"peerDependencies": {
|
|
38
41
|
"openclaw": ">=0.0.0"
|
package/src/acp/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
1
2
|
import { createInterface } from "node:readline";
|
|
2
|
-
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
|
3
3
|
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
4
4
|
import { runShellCommand, spawnShellCommand, terminateChildProcess } from "../utils/shell-command.ts";
|
|
5
5
|
|
|
@@ -54,6 +54,8 @@ type AcpWorkerClientOptions = {
|
|
|
54
54
|
env?: NodeJS.ProcessEnv;
|
|
55
55
|
permissionMode?: "approve-all" | "approve-reads" | "deny-all";
|
|
56
56
|
queueOwnerTtlSeconds?: number;
|
|
57
|
+
gatewayPid?: number;
|
|
58
|
+
gatewayWatchdogPollMs?: number;
|
|
57
59
|
};
|
|
58
60
|
|
|
59
61
|
type EnsureSessionParams = {
|
|
@@ -81,6 +83,7 @@ type SessionDescriptor = {
|
|
|
81
83
|
type ActiveSessionProcess = {
|
|
82
84
|
sessionKey: string;
|
|
83
85
|
child: ChildProcessWithoutNullStreams;
|
|
86
|
+
watchdog?: ChildProcess;
|
|
84
87
|
cwd: string;
|
|
85
88
|
agentId: string;
|
|
86
89
|
startedAt: string;
|
|
@@ -93,6 +96,7 @@ type SessionExitState = {
|
|
|
93
96
|
|
|
94
97
|
const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 30;
|
|
95
98
|
const DEFAULT_PERMISSION_MODE = "approve-all";
|
|
99
|
+
const DEFAULT_GATEWAY_WATCHDOG_POLL_MS = 1_000;
|
|
96
100
|
|
|
97
101
|
export class AcpWorkerClient {
|
|
98
102
|
readonly agentId: string;
|
|
@@ -101,6 +105,8 @@ export class AcpWorkerClient {
|
|
|
101
105
|
readonly env?: NodeJS.ProcessEnv;
|
|
102
106
|
readonly permissionMode: "approve-all" | "approve-reads" | "deny-all";
|
|
103
107
|
readonly queueOwnerTtlSeconds: number;
|
|
108
|
+
readonly gatewayPid: number;
|
|
109
|
+
readonly gatewayWatchdogPollMs: number;
|
|
104
110
|
readonly handles = new Map<string, AcpWorkerHandle>();
|
|
105
111
|
readonly sessionDescriptors = new Map<string, SessionDescriptor>();
|
|
106
112
|
readonly activeProcesses = new Map<string, ActiveSessionProcess>();
|
|
@@ -113,6 +119,8 @@ export class AcpWorkerClient {
|
|
|
113
119
|
this.env = options.env;
|
|
114
120
|
this.permissionMode = options.permissionMode ?? DEFAULT_PERMISSION_MODE;
|
|
115
121
|
this.queueOwnerTtlSeconds = options.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS;
|
|
122
|
+
this.gatewayPid = normalizePid(options.gatewayPid) ?? process.pid;
|
|
123
|
+
this.gatewayWatchdogPollMs = normalizeWatchdogPollMs(options.gatewayWatchdogPollMs);
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
async ensureSession(params: EnsureSessionParams): Promise<{
|
|
@@ -183,9 +191,11 @@ export class AcpWorkerClient {
|
|
|
183
191
|
child.stdin.end(params.text);
|
|
184
192
|
|
|
185
193
|
const startedAt = new Date().toISOString();
|
|
194
|
+
const watchdog = this.startGatewayWatchdog(params.sessionKey, child);
|
|
186
195
|
this.activeProcesses.set(params.sessionKey, {
|
|
187
196
|
sessionKey: params.sessionKey,
|
|
188
197
|
child,
|
|
198
|
+
watchdog,
|
|
189
199
|
cwd: descriptor.cwd,
|
|
190
200
|
agentId: descriptor.agentId,
|
|
191
201
|
startedAt,
|
|
@@ -243,14 +253,21 @@ export class AcpWorkerClient {
|
|
|
243
253
|
if (exit.error) {
|
|
244
254
|
throw exit.error;
|
|
245
255
|
}
|
|
256
|
+
if (exit.signal && !sawError) {
|
|
257
|
+
throw new Error(formatAcpxExitMessage(stderr, exit.code, exit.signal));
|
|
258
|
+
}
|
|
246
259
|
if ((exit.code ?? 0) !== 0 && !sawError) {
|
|
247
|
-
throw new Error(formatAcpxExitMessage(stderr, exit.code));
|
|
260
|
+
throw new Error(formatAcpxExitMessage(stderr, exit.code, exit.signal));
|
|
248
261
|
}
|
|
249
262
|
if (!sawDone && !sawError) {
|
|
250
263
|
await params.onEvent?.({ type: "done" });
|
|
251
264
|
}
|
|
252
265
|
return ensured;
|
|
253
266
|
} finally {
|
|
267
|
+
const active = this.activeProcesses.get(params.sessionKey);
|
|
268
|
+
if (active?.watchdog) {
|
|
269
|
+
safeKill(active.watchdog);
|
|
270
|
+
}
|
|
254
271
|
this.activeProcesses.delete(params.sessionKey);
|
|
255
272
|
lines.close();
|
|
256
273
|
if (params.signal) {
|
|
@@ -360,6 +377,7 @@ export class AcpWorkerClient {
|
|
|
360
377
|
|
|
361
378
|
const active = this.activeProcesses.get(sessionKey);
|
|
362
379
|
if (active) {
|
|
380
|
+
safeKill(active.watchdog);
|
|
363
381
|
safeKill(active.child);
|
|
364
382
|
this.activeProcesses.delete(sessionKey);
|
|
365
383
|
this.recordSessionExit(sessionKey, active, active.child.pid, null, "SIGTERM", reason);
|
|
@@ -385,6 +403,7 @@ export class AcpWorkerClient {
|
|
|
385
403
|
|
|
386
404
|
const active = this.activeProcesses.get(sessionKey);
|
|
387
405
|
if (active) {
|
|
406
|
+
safeKill(active.watchdog);
|
|
388
407
|
safeKill(active.child);
|
|
389
408
|
this.activeProcesses.delete(sessionKey);
|
|
390
409
|
this.recordSessionExit(sessionKey, active, active.child.pid, null, "SIGTERM", reason);
|
|
@@ -513,6 +532,40 @@ export class AcpWorkerClient {
|
|
|
513
532
|
`[clawspec] acpx worker exited: session=${sessionKey} pid=${pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`,
|
|
514
533
|
);
|
|
515
534
|
}
|
|
535
|
+
|
|
536
|
+
private startGatewayWatchdog(
|
|
537
|
+
sessionKey: string,
|
|
538
|
+
child: ChildProcessWithoutNullStreams,
|
|
539
|
+
): ChildProcess | undefined {
|
|
540
|
+
const workerPid = normalizePid(child.pid);
|
|
541
|
+
if (!workerPid) {
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const watchdog = spawn(process.execPath, [
|
|
547
|
+
"-e",
|
|
548
|
+
GATEWAY_WATCHDOG_SOURCE,
|
|
549
|
+
String(this.gatewayPid),
|
|
550
|
+
String(workerPid),
|
|
551
|
+
String(this.gatewayWatchdogPollMs),
|
|
552
|
+
], {
|
|
553
|
+
stdio: "ignore",
|
|
554
|
+
windowsHide: true,
|
|
555
|
+
detached: true,
|
|
556
|
+
});
|
|
557
|
+
watchdog.unref();
|
|
558
|
+
this.logger.debug?.(
|
|
559
|
+
`[clawspec] gateway watchdog armed: session=${sessionKey} gatewayPid=${this.gatewayPid} workerPid=${workerPid}`,
|
|
560
|
+
);
|
|
561
|
+
return watchdog;
|
|
562
|
+
} catch (error) {
|
|
563
|
+
this.logger.warn(
|
|
564
|
+
`[clawspec] failed to start gateway watchdog for ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`,
|
|
565
|
+
);
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
516
569
|
}
|
|
517
570
|
|
|
518
571
|
function parseJsonLines(value: string): Array<Record<string, unknown>> {
|
|
@@ -660,7 +713,10 @@ async function waitForExit(child: ChildProcessWithoutNullStreams): Promise<{
|
|
|
660
713
|
});
|
|
661
714
|
}
|
|
662
715
|
|
|
663
|
-
function safeKill(child:
|
|
716
|
+
function safeKill(child: Pick<ChildProcess, "pid" | "killed" | "kill"> | undefined): void {
|
|
717
|
+
if (!child) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
664
720
|
terminateChildProcess(child);
|
|
665
721
|
}
|
|
666
722
|
|
|
@@ -674,9 +730,19 @@ function buildPermissionArgs(mode: "approve-all" | "approve-reads" | "deny-all")
|
|
|
674
730
|
return ["--approve-all"];
|
|
675
731
|
}
|
|
676
732
|
|
|
677
|
-
function formatAcpxExitMessage(
|
|
733
|
+
function formatAcpxExitMessage(
|
|
734
|
+
stderr: string,
|
|
735
|
+
exitCode: number | null | undefined,
|
|
736
|
+
signal?: NodeJS.Signals | null,
|
|
737
|
+
): string {
|
|
678
738
|
const detail = stderr.trim();
|
|
679
|
-
|
|
739
|
+
if (detail) {
|
|
740
|
+
return detail;
|
|
741
|
+
}
|
|
742
|
+
if (signal) {
|
|
743
|
+
return `acpx terminated by signal ${signal}`;
|
|
744
|
+
}
|
|
745
|
+
return `acpx exited with code ${exitCode ?? "unknown"}`;
|
|
680
746
|
}
|
|
681
747
|
|
|
682
748
|
function asTrimmedString(value: unknown): string {
|
|
@@ -691,3 +757,103 @@ function asOptionalString(value: unknown): string | undefined {
|
|
|
691
757
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
692
758
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
693
759
|
}
|
|
760
|
+
|
|
761
|
+
function normalizePid(value: number | undefined): number | undefined {
|
|
762
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
763
|
+
? Math.trunc(value)
|
|
764
|
+
: undefined;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function normalizeWatchdogPollMs(value: number | undefined): number {
|
|
768
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 50) {
|
|
769
|
+
return DEFAULT_GATEWAY_WATCHDOG_POLL_MS;
|
|
770
|
+
}
|
|
771
|
+
return Math.trunc(value);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const GATEWAY_WATCHDOG_SOURCE = String.raw`
|
|
775
|
+
const { spawn } = require("node:child_process");
|
|
776
|
+
|
|
777
|
+
const gatewayPid = Number(process.argv[1]);
|
|
778
|
+
const workerPid = Number(process.argv[2]);
|
|
779
|
+
const pollMs = Number(process.argv[3]);
|
|
780
|
+
|
|
781
|
+
function normalizePid(value) {
|
|
782
|
+
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : undefined;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function isAlive(pid) {
|
|
786
|
+
const normalized = normalizePid(pid);
|
|
787
|
+
if (!normalized) {
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
try {
|
|
791
|
+
process.kill(normalized, 0);
|
|
792
|
+
return true;
|
|
793
|
+
} catch (error) {
|
|
794
|
+
const code = error && typeof error === "object" ? error.code : undefined;
|
|
795
|
+
return code === "EPERM";
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function killWorkerTree(pid) {
|
|
800
|
+
const normalized = normalizePid(pid);
|
|
801
|
+
if (!normalized) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (process.platform === "win32") {
|
|
805
|
+
try {
|
|
806
|
+
const killer = spawn("taskkill", ["/PID", String(normalized), "/T", "/F"], {
|
|
807
|
+
stdio: "ignore",
|
|
808
|
+
windowsHide: true,
|
|
809
|
+
detached: true,
|
|
810
|
+
});
|
|
811
|
+
killer.unref();
|
|
812
|
+
} catch {}
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
process.kill(-normalized, "SIGTERM");
|
|
817
|
+
} catch {
|
|
818
|
+
try {
|
|
819
|
+
process.kill(normalized, "SIGTERM");
|
|
820
|
+
} catch {}
|
|
821
|
+
}
|
|
822
|
+
const escalator = setTimeout(() => {
|
|
823
|
+
try {
|
|
824
|
+
process.kill(-normalized, "SIGKILL");
|
|
825
|
+
} catch {
|
|
826
|
+
try {
|
|
827
|
+
process.kill(normalized, "SIGKILL");
|
|
828
|
+
} catch {}
|
|
829
|
+
}
|
|
830
|
+
}, 1000);
|
|
831
|
+
escalator.unref?.();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const safeGatewayPid = normalizePid(gatewayPid);
|
|
835
|
+
const safeWorkerPid = normalizePid(workerPid);
|
|
836
|
+
const safePollMs = Number.isFinite(pollMs) && pollMs >= 50 ? Math.trunc(pollMs) : 1000;
|
|
837
|
+
|
|
838
|
+
if (!safeGatewayPid || !safeWorkerPid) {
|
|
839
|
+
process.exit(0);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (!isAlive(safeWorkerPid)) {
|
|
843
|
+
process.exit(0);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const timer = setInterval(() => {
|
|
847
|
+
if (!isAlive(safeWorkerPid)) {
|
|
848
|
+
clearInterval(timer);
|
|
849
|
+
process.exit(0);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (!isAlive(safeGatewayPid)) {
|
|
853
|
+
clearInterval(timer);
|
|
854
|
+
killWorkerTree(safeWorkerPid);
|
|
855
|
+
const exitTimer = setTimeout(() => process.exit(0), 250);
|
|
856
|
+
exitTimer.unref?.();
|
|
857
|
+
}
|
|
858
|
+
}, safePollMs);
|
|
859
|
+
`;
|
package/src/dependencies/acpx.ts
CHANGED
|
@@ -178,10 +178,10 @@ async function checkAcpxVersion(
|
|
|
178
178
|
message: "acpx --version did not return a parseable version",
|
|
179
179
|
};
|
|
180
180
|
}
|
|
181
|
-
if (version
|
|
181
|
+
if (compareSemver(version, params.expectedVersion) < 0) {
|
|
182
182
|
return {
|
|
183
183
|
ok: false,
|
|
184
|
-
message: `acpx version
|
|
184
|
+
message: `acpx version too old: found ${version}, require >= ${params.expectedVersion}`,
|
|
185
185
|
};
|
|
186
186
|
}
|
|
187
187
|
return { ok: true, version };
|
|
@@ -219,3 +219,77 @@ function getBuiltInAcpxCommand(runtimeEntrypoint: string | undefined): string |
|
|
|
219
219
|
process.platform === "win32" ? "acpx.cmd" : "acpx",
|
|
220
220
|
);
|
|
221
221
|
}
|
|
222
|
+
|
|
223
|
+
function compareSemver(left: string, right: string): number {
|
|
224
|
+
const parsedLeft = parseSemver(left);
|
|
225
|
+
const parsedRight = parseSemver(right);
|
|
226
|
+
if (!parsedLeft || !parsedRight) {
|
|
227
|
+
return left.localeCompare(right);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (let index = 0; index < 3; index += 1) {
|
|
231
|
+
const delta = parsedLeft.core[index] - parsedRight.core[index];
|
|
232
|
+
if (delta !== 0) {
|
|
233
|
+
return delta;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const leftPre = parsedLeft.prerelease;
|
|
238
|
+
const rightPre = parsedRight.prerelease;
|
|
239
|
+
if (leftPre.length === 0 && rightPre.length === 0) {
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
if (leftPre.length === 0) {
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
if (rightPre.length === 0) {
|
|
246
|
+
return -1;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const maxLength = Math.max(leftPre.length, rightPre.length);
|
|
250
|
+
for (let index = 0; index < maxLength; index += 1) {
|
|
251
|
+
const leftToken = leftPre[index];
|
|
252
|
+
const rightToken = rightPre[index];
|
|
253
|
+
if (leftToken === undefined) {
|
|
254
|
+
return -1;
|
|
255
|
+
}
|
|
256
|
+
if (rightToken === undefined) {
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
const delta = comparePrereleaseToken(leftToken, rightToken);
|
|
260
|
+
if (delta !== 0) {
|
|
261
|
+
return delta;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function parseSemver(value: string): { core: [number, number, number]; prerelease: string[] } | null {
|
|
268
|
+
const match = value.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
269
|
+
if (!match) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
core: [
|
|
274
|
+
Number.parseInt(match[1]!, 10),
|
|
275
|
+
Number.parseInt(match[2]!, 10),
|
|
276
|
+
Number.parseInt(match[3]!, 10),
|
|
277
|
+
],
|
|
278
|
+
prerelease: match[4] ? match[4].split(".") : [],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function comparePrereleaseToken(left: string, right: string): number {
|
|
283
|
+
const leftNumeric = /^\d+$/.test(left);
|
|
284
|
+
const rightNumeric = /^\d+$/.test(right);
|
|
285
|
+
if (leftNumeric && rightNumeric) {
|
|
286
|
+
return Number.parseInt(left, 10) - Number.parseInt(right, 10);
|
|
287
|
+
}
|
|
288
|
+
if (leftNumeric) {
|
|
289
|
+
return -1;
|
|
290
|
+
}
|
|
291
|
+
if (rightNumeric) {
|
|
292
|
+
return 1;
|
|
293
|
+
}
|
|
294
|
+
return left.localeCompare(right);
|
|
295
|
+
}
|
package/test/acp-client.test.ts
CHANGED
|
@@ -2,8 +2,10 @@ import test from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
5
6
|
import { chmod, mkdtemp, writeFile } from "node:fs/promises";
|
|
6
7
|
import { AcpWorkerClient } from "../src/acp/client.ts";
|
|
8
|
+
import { terminateChildProcess } from "../src/utils/shell-command.ts";
|
|
7
9
|
|
|
8
10
|
test("AcpWorkerClient tracks active worker lifecycle through acpx CLI", async () => {
|
|
9
11
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-acpx-client-"));
|
|
@@ -46,6 +48,63 @@ test("AcpWorkerClient tracks active worker lifecycle through acpx CLI", async ()
|
|
|
46
48
|
assert.match(finalStatus?.summary ?? "", /status=dead/);
|
|
47
49
|
});
|
|
48
50
|
|
|
51
|
+
test("AcpWorkerClient stops the worker when the gateway process disappears", async () => {
|
|
52
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "clawspec-acpx-watchdog-"));
|
|
53
|
+
const fake = await createFakeAcpx(tempRoot);
|
|
54
|
+
const gateway = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
|
55
|
+
stdio: "ignore",
|
|
56
|
+
windowsHide: true,
|
|
57
|
+
detached: true,
|
|
58
|
+
});
|
|
59
|
+
gateway.unref();
|
|
60
|
+
const client = new AcpWorkerClient({
|
|
61
|
+
agentId: "codex",
|
|
62
|
+
logger: createLogger(),
|
|
63
|
+
command: fake.command,
|
|
64
|
+
env: fake.env,
|
|
65
|
+
gatewayPid: gateway.pid,
|
|
66
|
+
gatewayWatchdogPollMs: 50,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
let sawStarted = false;
|
|
71
|
+
const runPromise = client.runTurn({
|
|
72
|
+
sessionKey: "session-watchdog",
|
|
73
|
+
cwd: tempRoot,
|
|
74
|
+
text: "stay alive",
|
|
75
|
+
onEvent: async (event) => {
|
|
76
|
+
if (event.type === "text_delta" && event.text?.includes("Working on stay alive")) {
|
|
77
|
+
sawStarted = true;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const observedRunPromise = runPromise.catch((error) => {
|
|
82
|
+
throw error;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await waitFor(async () => sawStarted === true);
|
|
87
|
+
|
|
88
|
+
terminateChildProcess(gateway, { force: true });
|
|
89
|
+
|
|
90
|
+
await assert.rejects(
|
|
91
|
+
observedRunPromise,
|
|
92
|
+
/acpx exited with code|signal=SIGTERM|terminated/i,
|
|
93
|
+
);
|
|
94
|
+
} finally {
|
|
95
|
+
terminateChildProcess(gateway, { force: true });
|
|
96
|
+
await observedRunPromise.catch(() => undefined);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
assert.ok(Date.now() - startedAt < 4_000, "watchdog should stop the worker quickly");
|
|
100
|
+
const finalStatus = await client.getSessionStatus({
|
|
101
|
+
sessionKey: "session-watchdog",
|
|
102
|
+
cwd: tempRoot,
|
|
103
|
+
agentId: "codex",
|
|
104
|
+
});
|
|
105
|
+
assert.match(finalStatus?.summary ?? "", /status=dead/);
|
|
106
|
+
});
|
|
107
|
+
|
|
49
108
|
async function createFakeAcpx(tempRoot: string): Promise<{ command: string; env: NodeJS.ProcessEnv }> {
|
|
50
109
|
const scriptPath = path.join(tempRoot, "fake-acpx.js");
|
|
51
110
|
const wrapperPath = path.join(tempRoot, process.platform === "win32" ? "fake-acpx.cmd" : "fake-acpx");
|
|
@@ -93,6 +152,18 @@ async function readStdin() {
|
|
|
93
152
|
return Buffer.concat(chunks).toString("utf8");
|
|
94
153
|
}
|
|
95
154
|
|
|
155
|
+
function isAlive(pid) {
|
|
156
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
process.kill(pid, 0);
|
|
161
|
+
return true;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return error && typeof error === "object" && error.code === "EPERM";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
96
167
|
async function writeJsonLine(value) {
|
|
97
168
|
process.stdout.write(JSON.stringify(value) + "\\n");
|
|
98
169
|
}
|
|
@@ -122,8 +193,8 @@ async function main() {
|
|
|
122
193
|
|
|
123
194
|
async function runningExists() {
|
|
124
195
|
try {
|
|
125
|
-
await fs.
|
|
126
|
-
return
|
|
196
|
+
const payload = JSON.parse(await fs.readFile(runningFile, "utf8"));
|
|
197
|
+
return isAlive(payload && payload.pid);
|
|
127
198
|
} catch {
|
|
128
199
|
return false;
|
|
129
200
|
}
|
|
@@ -167,13 +238,17 @@ async function main() {
|
|
|
167
238
|
|
|
168
239
|
if (verb === "prompt") {
|
|
169
240
|
const text = (await readStdin()).trim();
|
|
170
|
-
await fs.writeFile(runningFile,
|
|
241
|
+
await fs.writeFile(runningFile, JSON.stringify({ pid: process.pid }), "utf8");
|
|
171
242
|
process.on("SIGTERM", async () => {
|
|
172
243
|
await fs.rm(runningFile, { force: true });
|
|
173
244
|
process.exit(143);
|
|
174
245
|
});
|
|
175
246
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
176
247
|
await writeJsonLine({ text: "Working on " + text });
|
|
248
|
+
if (sessionName === "session-watchdog" || text.includes("stay alive")) {
|
|
249
|
+
setInterval(() => {}, 1_000);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
177
252
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
178
253
|
await fs.rm(runningFile, { force: true });
|
|
179
254
|
await writeJsonLine({ type: "done" });
|
|
@@ -52,6 +52,27 @@ test("ensureAcpxCli uses the global acpx command when available", async () => {
|
|
|
52
52
|
assert.equal(calls.some((call) => call.command === "npm"), false);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
test("ensureAcpxCli accepts a newer global acpx version", async () => {
|
|
56
|
+
const calls: Array<{ command: string; args: string[] }> = [];
|
|
57
|
+
const result = await ensureAcpxCli({
|
|
58
|
+
pluginRoot: ROOT_PREFIX,
|
|
59
|
+
runner: async ({ command, args }) => {
|
|
60
|
+
calls.push({ command, args });
|
|
61
|
+
if (command === LOCAL_COMMAND) {
|
|
62
|
+
return { code: 1, stdout: "", stderr: "not found" };
|
|
63
|
+
}
|
|
64
|
+
if (command === "acpx") {
|
|
65
|
+
return { code: 0, stdout: "0.3.2\n", stderr: "" };
|
|
66
|
+
}
|
|
67
|
+
return { code: 1, stdout: "", stderr: "unexpected command" };
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.equal(result.source, "global");
|
|
72
|
+
assert.equal(result.version, "0.3.2");
|
|
73
|
+
assert.equal(calls.some((call) => call.command === "npm"), false);
|
|
74
|
+
});
|
|
75
|
+
|
|
55
76
|
test("ensureAcpxCli prefers the OpenClaw builtin acpx over an incompatible PATH acpx", async () => {
|
|
56
77
|
const calls: Array<{ command: string; args: string[] }> = [];
|
|
57
78
|
const result = await ensureAcpxCli({
|